feat(wisekeep): migrate from archive to local-first + Hono architecture

- Move from apps-archived/ to apps/
- Delete NestJS backend, mobile app, legacy Python, shared-types
- Create Hono/Bun server with Groq Whisper transcription via yt-dlp
- Create local-first store (transcripts, playlists) with guest seed
- Rewrite web app: Transcribe page, Library with search/expand,
  Playlists CRUD, auth via shared-auth-ui, AuthGate with guest mode
- Remove broken landing page subpages (Prettier-incompatible Astro)
- Add wisekeep to root CLAUDE.md and dev scripts
- Fix duplicate wisekeep entries in shared-branding
- 0 type errors on both server and web

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-29 20:03:55 +02:00
parent f06c98709a
commit d7b4042164
151 changed files with 1490 additions and 11980 deletions

View file

@ -57,6 +57,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
| **taktik** | Time tracking | Web |
| **uload** | URL shortener & link management | Server, Web, Landing |
| **news** | AI news reader & personal library | Server, Web, Landing |
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
| **calc** | Calculator & converter | Web |
| **playground** | LLM playground | Web |

View file

@ -1,233 +0,0 @@
# CLAUDE.md - Wisekeep
This file provides guidance to Claude Code when working with the Wisekeep project.
## Project Overview
Wisekeep is an AI-powered wisdom extraction application that captures insights from video content:
- YouTube video download via yt-dlp
- Ultra-fast audio transcription using Groq Whisper API (~300x realtime)
- Fallback to local Whisper for offline use
- Playlist management for batch processing
- Real-time progress updates via WebSocket
- Multi-platform support (Web, Mobile, Landing)
## Architecture
```
apps/wisekeep/
├── apps/
│ ├── backend/ # NestJS API server (port 3006)
│ ├── web/ # SvelteKit web application
│ ├── landing/ # Astro landing/content site
│ └── mobile/ # Expo React Native app
├── packages/
│ └── shared-types/ # Shared TypeScript types
├── data/ # Transcripts & playlists (gitignored)
├── legacy/ # Original Python code (reference)
├── package.json # Root orchestrator
└── CLAUDE.md # This file
```
## Quick Start
### Prerequisites
- Node.js 20+
- pnpm 9.15.0+
- yt-dlp installed (`brew install yt-dlp` on macOS)
- For local Whisper: Python 3 with openai-whisper package
### Development
```bash
# From monorepo root
pnpm install
# Start all wisekeep apps
pnpm wisekeep:dev
# Start individual apps
pnpm dev:wisekeep:backend # NestJS backend (port 3006)
pnpm dev:wisekeep:web # SvelteKit web (port 5173)
pnpm dev:wisekeep:landing # Astro landing (port 4321)
pnpm dev:wisekeep:mobile # Expo mobile
# Start web + backend together
pnpm dev:wisekeep:app
```
### Environment Variables
Create `apps/wisekeep/apps/backend/.env`:
```bash
PORT=3006
WHISPER_PROVIDER=groq # groq or local
WHISPER_MODEL=whisper-large-v3-turbo # whisper-large-v3-turbo, whisper-large-v3 (groq) | tiny, base, small, medium, large (local)
GROQ_API_KEY=gsk_... # Required for Groq provider
TEMP_AUDIO_DIR=./temp_audio
TRANSCRIPTS_DIR=./data/transcripts
PLAYLISTS_DIR=./data/playlists
```
## API Endpoints
### Transcription
| Method | Endpoint | Description |
| ------ | ---------------------- | --------------------------- |
| POST | `/transcription` | Start new transcription job |
| GET | `/transcription` | List all jobs |
| GET | `/transcription/:id` | Get job status |
| DELETE | `/transcription/:id` | Cancel job |
| GET | `/transcription/stats` | Get statistics |
### Playlists
| Method | Endpoint | Description |
| ------ | --------------------------- | --------------------- |
| GET | `/playlist` | List all playlists |
| GET | `/playlist/:category/:name` | Get specific playlist |
| POST | `/playlist` | Create playlist |
| DELETE | `/playlist/:category/:name` | Delete playlist |
### Whisper
| Method | Endpoint | Description |
| ------ | ----------------- | -------------------- |
| GET | `/whisper/models` | Get available models |
### Health
| Method | Endpoint | Description |
| ------ | --------------- | --------------- |
| GET | `/health` | Health check |
| GET | `/health/ready` | Readiness check |
| GET | `/health/live` | Liveness check |
## WebSocket
Connect to `/progress` namespace for real-time updates:
```typescript
const socket = io('http://localhost:3006/progress');
socket.on('job_update', (data) => {
// { type, jobId, status, progress, videoInfo }
});
socket.on('job_complete', (data) => {
// { type, jobId, status, transcriptPath }
});
socket.on('job_error', (data) => {
// { type, jobId, error }
});
```
## Whisper Configuration
### Groq Whisper API (Recommended)
- Ultra-fast, cloud-based (~300x realtime speed)
- Cost: ~$0.04/hour (whisper-large-v3-turbo) or ~$0.111/hour (whisper-large-v3)
- No GPU required
- Models: `whisper-large-v3-turbo` (fast) or `whisper-large-v3` (accurate)
- Set `WHISPER_PROVIDER=groq` and `GROQ_API_KEY`
### Local Whisper
- Free, runs locally
- Requires Python + openai-whisper
- GPU recommended for larger models
- Models: `tiny`, `base`, `small`, `medium`, `large`
- Set `WHISPER_PROVIDER=local` and `WHISPER_MODEL`
## Technology Stack
| Component | Technology |
| -------------- | --------------------------------- |
| Backend | NestJS 10, TypeScript |
| Web | SvelteKit 2, Svelte 5, Tailwind |
| Landing | Astro 4, Tailwind |
| Mobile | Expo 52, React Native, NativeWind |
| YouTube | yt-dlp (via child_process) |
| Transcription | Groq Whisper API / local Whisper |
| Real-time | Socket.io |
| State (Mobile) | Zustand |
## Code Patterns
### Backend Services
```typescript
@Injectable()
export class TranscriptionService {
async createJob(dto: TranscribeRequestDto): Promise<TranscriptionJob> {
// Background processing with WebSocket updates
}
}
```
### Web (Svelte 5 Runes)
```typescript
// Correct - Svelte 5
let jobs = $state<Job[]>([]);
let activeJobs = $derived(jobs.filter((j) => j.status === 'active'));
// Wrong - Old Svelte syntax
let jobs = [];
$: activeJobs = jobs.filter((j) => j.status === 'active');
```
### Mobile (Zustand)
```typescript
export const useJobStore = create<JobStore>((set) => ({
jobs: [],
addJob: (job) => set((state) => ({ jobs: [...state.jobs, job] })),
}));
```
## Legacy Python Code
The original Python implementation is preserved in `legacy/` for reference:
- `transcriber_v4_parallel.py` - Main transcription logic
- `api_server.py` - FastAPI server (replaced by NestJS)
- `requirements.txt` - Python dependencies
## Troubleshooting
### yt-dlp not found
```bash
# macOS
brew install yt-dlp
# Linux
pip install yt-dlp
```
### Local Whisper not working
```bash
# Install Whisper
pip install openai-whisper
# Test
python3 -c "import whisper; print(whisper.available_models())"
```
### Backend can't start
```bash
# Check port 3006
lsof -i :3006 && kill -9 $(lsof -t -i:3006)
# Check environment
cat apps/backend/.env
```

View file

@ -1,392 +0,0 @@
# 🎥 YouTube Transcriber System
Ein vollständiges System zur automatischen Transkription, Aufbereitung und Präsentation von YouTube-Videos mit OpenAI's Whisper, FastAPI Backend und Astro.js Frontend.
## ✨ System-Komponenten
### 🔧 Backend (Python)
- **OpenAI Whisper** - Lokale Speech-to-Text Transkription
- **FastAPI Server** - REST API für Web-Interface
- **Parallel Processing** - Bis zu 3.3x schnellere Verarbeitung
- **Playlist Management** - Automatische Batch-Verarbeitung
### 🌐 Frontend (Astro.js)
- **Public Website** - Aufbereitete Vorträge als Wisdom Library
- **Admin Panel** - Transkriptions-Management (localhost only)
- **Content Collections** - Strukturierte Inhalte mit Markdown
- **Responsive Design** - Optimiert für alle Geräte
## 🏗️ Architektur
```
YoutubeDL/
├── 🐍 Python Backend
│ ├── transcriber_v4_parallel.py # Parallel-Verarbeitung
│ ├── api_server.py # FastAPI REST API
│ └── playlists/ # YouTube URL-Listen
├── 🌐 Website
│ ├── src/pages/ # Public & Admin Pages
│ ├── src/content/talks/ # Aufbereitete Vorträge
│ └── src/components/admin/ # Admin-Komponenten
└── 📂 Output
└── transcripts/ # Transkribierte Texte
```
## 🛠 Installation
### Voraussetzungen
- Python 3.10+
- FFmpeg
- macOS (optimiert für Apple Silicon M1/M2)
### Setup
1. **Repository klonen:**
```bash
git clone https://github.com/yourusername/youtube-transcriber.git
cd youtube-transcriber
```
2. **Virtual Environment erstellen:**
```bash
python3 -m venv venv
source venv/bin/activate
```
3. **Dependencies installieren:**
```bash
pip install -r requirements.txt
```
## 🚀 Schnellstart
### Kompletter Workflow: Von YouTube zu Website
#### 1. Speaker Content sammeln
Erstelle eine Playlist für einen Speaker (z.B. Simon Sinek):
```bash
# playlists/people/simon-sinek.txt erstellen
# Simon Sinek Videos
# Popular talks and interviews from YouTube
# Created: 2025-09-09
# TED Talks
# How great leaders inspire action (Start with Why) - 60M+ views
https://www.youtube.com/watch?v=u4ZoJKF_VuA
# Why good leaders make you feel safe - 18M+ views
https://www.youtube.com/watch?v=lmyZMtPVodo
```
#### 2. Videos transkribieren
```bash
# Virtual Environment aktivieren
source venv/bin/activate
# Parallel-Verarbeitung starten (3-4x schneller)
python3 transcriber_v4_parallel.py --playlist playlists/people/simon-sinek.txt --model base --language en
```
#### 3. Website Content erstellen
**a) Content Schema erweitern** (wenn neue Kategorie):
```typescript
// website/src/content/config.ts
category: z.enum([
'behavioral-economics',
'psychology',
'leadership', // Neue Kategorie hinzufügen
// ...
]),
```
**b) Speaker Profil erstellen**:
```bash
# website/src/pages/speakers/simon-sinek.astro
```
**c) Talk-Seiten erstellen**:
```bash
# Für jedes erfolgreich transkribierte Video:
# website/src/content/talks/simon-sinek-[talk-slug].md
```
**d) SearchableContentList aktualisieren**:
```typescript
// website/src/components/SearchableContentList.tsx
// Neue Talks zur Inhaltsliste hinzufügen
```
#### 4. Website starten
```bash
cd website
npm run dev
```
### Einzelnes Video transkribieren
```bash
# Mit Large-Modell (beste Qualität)
python3 transcriber_v3.py process "https://www.youtube.com/watch?v=VIDEO_ID" --model large
# Mit Tiny-Modell (schneller Test)
python3 transcriber_v3.py process "https://www.youtube.com/watch?v=VIDEO_ID" --model tiny
```
### Playlists verwalten
1. **Playlist erstellen:**
- Erstelle eine `.txt` Datei im `playlists/` Ordner
- Füge YouTube-URLs ein (eine pro Zeile)
```bash
# playlists/tech/python_tutorials.txt
https://www.youtube.com/watch?v=VIDEO_ID1
https://www.youtube.com/watch?v=VIDEO_ID2
```
2. **Alle Playlists scannen:**
```bash
python3 transcriber_v3.py scan --model large
```
3. **Spezifische Playlist verarbeiten:**
```bash
python3 transcriber_v3.py scan --playlist tech/python_tutorials
```
### Quick-Script verwenden
```bash
./quick_transcribe.sh
```
Bietet ein interaktives Menü zur Modell-Auswahl.
## 📂 Projektstruktur
```
YoutubeDL/
├── playlists/ # YouTube URL-Listen nach Themen
│ ├── tech/
│ │ └── python_tutorials.txt
│ ├── people/
│ │ └── rory-sutherland.txt
│ └── musik/
│ └── klassik.txt
├── transcripts/ # Transkribierte Texte (automatisch organisiert)
│ ├── tech_python_tutorials/
│ │ └── [Kanal]/
│ │ └── [Video]_[Timestamp].txt
│ └── people_rory-sutherland/
│ └── TED/
├── .cache/ # Cache für bereits verarbeitete Videos
├── temp_audio/ # Temporäre Audio-Dateien
├── venv/ # Python Virtual Environment
├── transcriber.py # v1: Basis-Funktionalität
├── transcriber_v2.py # v2: Mit Rich UI
├── transcriber_v3.py # v3: Mit Playlist-Management
└── quick_transcribe.sh # Schnellzugriff-Script
```
## 🎯 Whisper-Modelle
| Modell | Größe | Geschwindigkeit | Genauigkeit | Verwendung |
|--------|-------|-----------------|-------------|------------|
| **tiny** | 39 MB | ~10x Echtzeit | 75% | Schnelle Tests |
| **base** | 74 MB | ~7x Echtzeit | 85% | Guter Kompromiss |
| **small** | 244 MB | ~4x Echtzeit | 91% | Solide Qualität |
| **medium** | 769 MB | ~2x Echtzeit | 94% | Hohe Qualität |
| **large** | 1.5 GB | ~1x Echtzeit | 96-98% | Beste Qualität |
## 📋 Befehle
### Hauptbefehle
```bash
# Zeige alle Playlists
python3 transcriber_v3.py list
# Verarbeite alle neuen Videos in allen Playlists
python3 transcriber_v3.py scan
# Verarbeite einzelnes Video
python3 transcriber_v3.py process "URL"
# Mit spezifischem Modell
python3 transcriber_v3.py scan --model large
# Andere Sprache
python3 transcriber_v3.py scan --language en
```
### Optionen
- `--model {tiny,base,small,medium,large}` - Whisper-Modell auswählen
- `--language LANG` - Sprache setzen (default: de)
- `--playlist NAME` - Spezifische Playlist verarbeiten
- `--output DIR` - Ausgabe-Verzeichnis (default: transcripts)
- `--force` - Cache ignorieren und neu transkribieren
## 🔄 Automatisierung
### Cron-Job einrichten
Für tägliche automatische Verarbeitung:
```bash
# Crontab öffnen
crontab -e
# Täglich um 3 Uhr nachts alle Playlists scannen
0 3 * * * cd /path/to/YoutubeDL && source venv/bin/activate && python3 transcriber_v3.py scan --model large
```
## 💡 Tipps
1. **Organisiere nach Themen**: Erstelle Unterordner in `playlists/` für verschiedene Themen
2. **Cache nutzen**: Das System merkt sich bereits transkribierte Videos automatisch
3. **Modell-Auswahl**:
- Nutze `tiny` für schnelle Tests
- Nutze `large` für wichtige Transkriptionen
4. **Batch-Verarbeitung**: Füge alle URLs zur Playlist hinzu und lasse über Nacht laufen
## 🎨 Features im Detail
### Rich Terminal UI (v2+)
- Farbige Ausgabe mit Emojis
- Progress Bars für Download und Transkription
- Zeitschätzungen basierend auf Video-Länge
- Video-Metadaten vor Download
### Playlist-Management (v3)
- Automatisches Scannen von URL-Listen
- Themen-basierte Organisation
- Nur neue Videos werden verarbeitet
- Batch-Verarbeitung mehrerer Playlists
### Cache-System
- Verhindert doppelte Verarbeitung
- Speichert Metadaten zu transkribierten Videos
- `.cache/transcribed_videos.json` enthält Historie
## 🐛 Troubleshooting
**FFmpeg nicht gefunden:**
```bash
# macOS
brew install ffmpeg
```
**Whisper-Modell lädt sehr lange:**
- Beim ersten Mal wird das Modell heruntergeladen
- Large: ~1.5GB, kann 10-30 Minuten dauern
**"Video bereits transkribiert":**
- Nutze `--force` Flag zum Überschreiben
- Oder lösche `.cache/` Ordner für kompletten Reset
## 📈 Performance (Apple Silicon M1)
- **Tiny**: ~10x Echtzeit (6 Min Video → 36 Sek)
- **Base**: ~7x Echtzeit (6 Min Video → 50 Sek)
- **Small**: ~4x Echtzeit (6 Min Video → 1.5 Min)
- **Large**: ~1x Echtzeit (6 Min Video → 6 Min)
## 🔒 Datenschutz
- Alle Verarbeitung erfolgt **lokal** auf deinem Computer
- Keine Daten werden an externe Server gesendet
- Whisper läuft komplett offline
## 📝 Lizenz
MIT License - Siehe LICENSE Datei
## 🙏 Credits
- **OpenAI Whisper** - Speech-to-Text Engine
- **yt-dlp** - YouTube Download Tool
- **Rich** - Terminal UI Library
- **FFmpeg** - Audio/Video Verarbeitung
## 🌐 Website Integration
Das System generiert nicht nur Transkripte, sondern auch eine vollständige Website mit den aufbereiteten Inhalten.
### Website-Features
- **📚 Content Collections**: Strukturierte Talk-Seiten mit Markdown
- **🔍 Suchfunktion**: Volltextsuche über alle Talks
- **👤 Speaker Profile**: Übersichtsseiten für jeden Speaker
- **🏷️ Tag-System**: Kategorisierung nach Themen
- **📱 Responsive**: Optimiert für alle Geräte
- **🎨 Theming**: Verschiedene Farbschemata
### Content-Struktur
```
website/src/
├── content/
│ ├── config.ts # Content Schema
│ └── talks/ # Aufbereitete Talk-Seiten
│ ├── simon-sinek-why-good-leaders-make-you-feel-safe.md
│ ├── simon-sinek-millennials-in-the-workplace.md
│ └── simon-sinek-love-your-work.md
├── pages/
│ ├── speakers/
│ │ ├── index.astro # Speaker-Übersicht
│ │ └── simon-sinek.astro # Speaker-Profile
│ └── talks/
│ └── [slug].astro # Dynamische Talk-Seiten
└── components/
├── SearchableContentList.tsx # Hauptsuche
├── ContentCard.tsx # Talk-Vorschau
└── speakers/
├── SpeakerHero.astro # Speaker-Header
├── TalkGrid.astro # Talk-Grid
└── QuoteCollection.astro # Zitate-Sammlung
```
### Website entwickeln
```bash
# Website Dependencies installieren
cd website
npm install
# Entwicklungsserver starten
npm run dev
# Website bauen für Produktion
npm run build
```
### Content-Erstellung Workflow
1. **Transkription**: Videos mit Python-Backend transkribieren
2. **Content-Aufbereitung**: Markdown-Dateien mit Metadaten erstellen
3. **Speaker-Profile**: Übersichtsseiten für neue Speaker
4. **Integration**: Neue Inhalte in Suchfunktion einbinden
5. **Deployment**: Website bauen und deployen
## 🚧 Roadmap
- [x] **Parallel Processing** - 3-4x schnellere Transkription
- [x] **Website Integration** - Vollständige Content-Website
- [x] **Speaker Profiles** - Detaillierte Speaker-Übersichten
- [x] **Content Collections** - Strukturierte Talk-Aufbereitung
- [ ] **Admin Interface** - Web-UI für Transkriptions-Management
- [ ] **Speaker Diarization** - Wer spricht wann
- [ ] **Automatische Zusammenfassungen** - LLM-basierte Summaries
- [ ] **Export Formate** - SRT, VTT, JSON Export
- [ ] **YouTube Playlist Auto-Import** - Direkte Playlist-Integration
---
**Entwickelt mit ❤️ für automatische Transkription**

View file

@ -1,14 +0,0 @@
# Server
PORT=3006
# Whisper Configuration
WHISPER_PROVIDER=openai # openai or local
WHISPER_MODEL=base # tiny, base, small, medium, large (for local)
# OpenAI API (for cloud transcription)
OPENAI_API_KEY=sk-your-openai-api-key
# Directories
TEMP_AUDIO_DIR=./temp_audio
TRANSCRIPTS_DIR=./data/transcripts
PLAYLISTS_DIR=./data/playlists

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,52 +0,0 @@
{
"name": "@wisekeep/backend",
"version": "1.0.0",
"private": true,
"description": "Wisekeep Backend - NestJS API for wisdom extraction",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "nest start",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-socket.io": "^10.4.15",
"@nestjs/websockets": "^10.4.15",
"@types/socket.io-client": "^3.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"openai": "^4.73.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.1",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0",
"eslint": "^9.16.0",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.2"
}
}

View file

@ -1,24 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TranscriptionModule } from './transcription/transcription.module';
import { PlaylistModule } from './playlist/playlist.module';
import { YoutubeModule } from './youtube/youtube.module';
import { WhisperModule } from './whisper/whisper.module';
import { WebsocketModule } from './websocket/websocket.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
TranscriptionModule,
PlaylistModule,
YoutubeModule,
WhisperModule,
WebsocketModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -1,30 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'transcriber-backend',
version: '1.0.0',
};
}
@Get('ready')
ready() {
return {
status: 'ready',
timestamp: new Date().toISOString(),
};
}
@Get('live')
live() {
return {
status: 'alive',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -1,31 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: [
'http://localhost:5173', // SvelteKit dev
'http://localhost:4321', // Astro dev
'http://localhost:3000', // Alternative dev
],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
const port = process.env.PORT || 3006;
await app.listen(port);
console.log(`[Transcriber Backend] Running on http://localhost:${port}`);
}
bootstrap();

View file

@ -1,37 +0,0 @@
import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common';
import { PlaylistService, CreatePlaylistDto } from './playlist.service';
@Controller('playlist')
export class PlaylistController {
constructor(private readonly playlistService: PlaylistService) {}
@Get()
async getAll() {
return this.playlistService.getAll();
}
@Get(':category/:name')
async getOne(@Param('category') category: string, @Param('name') name: string) {
return this.playlistService.getOne(category, name);
}
@Post()
async create(@Body() dto: CreatePlaylistDto) {
return this.playlistService.create(dto);
}
@Delete(':category/:name')
async delete(@Param('category') category: string, @Param('name') name: string) {
await this.playlistService.delete(category, name);
return { message: 'Playlist deleted' };
}
@Post(':category/:name/url')
async addUrl(
@Param('category') category: string,
@Param('name') name: string,
@Body('url') url: string
) {
return this.playlistService.addUrl(category, name, url);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { PlaylistController } from './playlist.controller';
import { PlaylistService } from './playlist.service';
@Module({
controllers: [PlaylistController],
providers: [PlaylistService],
exports: [PlaylistService],
})
export class PlaylistModule {}

View file

@ -1,173 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
export interface Playlist {
category: string;
name: string;
path: string;
urlCount: number;
urls: string[];
description?: string;
}
export interface CreatePlaylistDto {
name: string;
description?: string;
urls: string[];
}
@Injectable()
export class PlaylistService {
private readonly logger = new Logger(PlaylistService.name);
private readonly playlistsDir: string;
constructor(private configService: ConfigService) {
this.playlistsDir = this.configService.get<string>('PLAYLISTS_DIR') || './data/playlists';
// Ensure playlists directory exists
if (!fs.existsSync(this.playlistsDir)) {
fs.mkdirSync(this.playlistsDir, { recursive: true });
}
}
async getAll(): Promise<Playlist[]> {
const playlists: Playlist[] = [];
if (!fs.existsSync(this.playlistsDir)) {
return playlists;
}
const categories = fs
.readdirSync(this.playlistsDir, { withFileTypes: true })
.filter((d) => d.isDirectory());
for (const category of categories) {
const categoryPath = path.join(this.playlistsDir, category.name);
const files = fs.readdirSync(categoryPath).filter((f) => f.endsWith('.txt'));
for (const file of files) {
const filePath = path.join(categoryPath, file);
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
let description: string | undefined;
const urls: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('# ') && !description) {
description = trimmed.substring(2);
} else if (trimmed && !trimmed.startsWith('#')) {
urls.push(trimmed);
}
}
playlists.push({
category: category.name,
name: file.replace('.txt', ''),
path: filePath,
urlCount: urls.length,
urls,
description,
});
}
}
return playlists;
}
async getOne(category: string, name: string): Promise<Playlist> {
const filePath = path.join(this.playlistsDir, category, `${name}.txt`);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(`Playlist ${category}/${name} not found`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
let description: string | undefined;
const urls: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('# ') && !description) {
description = trimmed.substring(2);
} else if (trimmed && !trimmed.startsWith('#')) {
urls.push(trimmed);
}
}
return {
category,
name,
path: filePath,
urlCount: urls.length,
urls,
description,
};
}
async create(dto: CreatePlaylistDto): Promise<Playlist> {
// Parse category/name format
const parts = dto.name.split('/');
const category = parts.length > 1 ? parts[0] : 'general';
const name = parts.length > 1 ? parts[1] : dto.name;
const categoryDir = path.join(this.playlistsDir, category);
if (!fs.existsSync(categoryDir)) {
fs.mkdirSync(categoryDir, { recursive: true });
}
const filePath = path.join(categoryDir, `${name}.txt`);
let content = '';
if (dto.description) {
content += `# ${dto.description}\n`;
}
content += '# One URL per line\n\n';
content += dto.urls.join('\n') + '\n';
fs.writeFileSync(filePath, content, 'utf-8');
this.logger.log(`Created playlist: ${category}/${name}`);
return {
category,
name,
path: filePath,
urlCount: dto.urls.length,
urls: dto.urls,
description: dto.description,
};
}
async delete(category: string, name: string): Promise<void> {
const filePath = path.join(this.playlistsDir, category, `${name}.txt`);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(`Playlist ${category}/${name} not found`);
}
fs.unlinkSync(filePath);
this.logger.log(`Deleted playlist: ${category}/${name}`);
}
async addUrl(category: string, name: string, url: string): Promise<Playlist> {
const playlist = await this.getOne(category, name);
playlist.urls.push(url);
const content =
(playlist.description ? `# ${playlist.description}\n` : '') +
'# One URL per line\n\n' +
playlist.urls.join('\n') +
'\n';
fs.writeFileSync(playlist.path, content, 'utf-8');
playlist.urlCount = playlist.urls.length;
return playlist;
}
}

View file

@ -1,35 +0,0 @@
import { IsString, IsOptional, IsUrl, IsEnum } from 'class-validator';
export enum WhisperProviderEnum {
GROQ = 'groq',
LOCAL = 'local',
}
export enum WhisperModelEnum {
// Groq models (cloud)
WHISPER_LARGE_V3_TURBO = 'whisper-large-v3-turbo',
WHISPER_LARGE_V3 = 'whisper-large-v3',
// Local models
TINY = 'tiny',
BASE = 'base',
SMALL = 'small',
MEDIUM = 'medium',
LARGE = 'large',
}
export class TranscribeRequestDto {
@IsUrl()
url: string;
@IsString()
@IsOptional()
language?: string = 'de';
@IsEnum(WhisperProviderEnum)
@IsOptional()
provider?: WhisperProviderEnum;
@IsEnum(WhisperModelEnum)
@IsOptional()
model?: WhisperModelEnum;
}

View file

@ -1,46 +0,0 @@
export enum JobStatus {
PENDING = 'pending',
DOWNLOADING = 'downloading',
TRANSCRIBING = 'transcribing',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
}
export interface VideoInfo {
id: string;
title: string;
description: string;
duration: number;
channel: string;
channelId: string;
thumbnail: string;
uploadDate: string;
}
export class TranscriptionJob {
id: string;
url: string;
language: string;
provider: string;
model?: string;
status: JobStatus;
progress: number;
createdAt: Date;
completedAt?: Date;
videoInfo?: VideoInfo;
transcriptPath?: string;
transcriptText?: string;
error?: string;
constructor(id: string, url: string, language: string, provider: string, model?: string) {
this.id = id;
this.url = url;
this.language = language;
this.provider = provider;
this.model = model;
this.status = JobStatus.PENDING;
this.progress = 0;
this.createdAt = new Date();
}
}

View file

@ -1,33 +0,0 @@
import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common';
import { TranscriptionService } from './transcription.service';
import { TranscribeRequestDto } from './dto/transcribe-request.dto';
@Controller('transcription')
export class TranscriptionController {
constructor(private readonly transcriptionService: TranscriptionService) {}
@Post()
async createJob(@Body() dto: TranscribeRequestDto) {
return this.transcriptionService.createJob(dto);
}
@Get()
async getAllJobs() {
return this.transcriptionService.getAllJobs();
}
@Get('stats')
async getStats() {
return this.transcriptionService.getStats();
}
@Get(':id')
async getJob(@Param('id') id: string) {
return this.transcriptionService.getJob(id);
}
@Delete(':id')
async cancelJob(@Param('id') id: string) {
return this.transcriptionService.cancelJob(id);
}
}

View file

@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { TranscriptionController } from './transcription.controller';
import { TranscriptionService } from './transcription.service';
import { YoutubeModule } from '../youtube/youtube.module';
import { WhisperModule } from '../whisper/whisper.module';
import { WebsocketModule } from '../websocket/websocket.module';
@Module({
imports: [YoutubeModule, WhisperModule, WebsocketModule],
controllers: [TranscriptionController],
providers: [TranscriptionService],
exports: [TranscriptionService],
})
export class TranscriptionModule {}

View file

@ -1,254 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
import * as path from 'path';
import { YoutubeService } from '../youtube/youtube.service';
import { WhisperService, WhisperProvider, WhisperModel } from '../whisper/whisper.service';
import { ProgressGateway } from '../websocket/progress.gateway';
import { TranscriptionJob, JobStatus } from './entities/transcription-job.entity';
import { TranscribeRequestDto } from './dto/transcribe-request.dto';
@Injectable()
export class TranscriptionService {
private readonly logger = new Logger(TranscriptionService.name);
private readonly jobs: Map<string, TranscriptionJob> = new Map();
private readonly transcriptsDir: string;
constructor(
private readonly configService: ConfigService,
private readonly youtubeService: YoutubeService,
private readonly whisperService: WhisperService,
private readonly progressGateway: ProgressGateway
) {
this.transcriptsDir = this.configService.get<string>('TRANSCRIPTS_DIR') || './data/transcripts';
// Ensure transcripts directory exists
if (!fs.existsSync(this.transcriptsDir)) {
fs.mkdirSync(this.transcriptsDir, { recursive: true });
}
}
async createJob(dto: TranscribeRequestDto): Promise<TranscriptionJob> {
const jobId = uuidv4();
const job = new TranscriptionJob(
jobId,
dto.url,
dto.language || 'de',
dto.provider || 'openai',
dto.model
);
this.jobs.set(jobId, job);
// Start processing in background
this.processJob(job);
return job;
}
async getJob(id: string): Promise<TranscriptionJob> {
const job = this.jobs.get(id);
if (!job) {
throw new NotFoundException(`Job ${id} not found`);
}
return job;
}
async getAllJobs(): Promise<TranscriptionJob[]> {
return Array.from(this.jobs.values());
}
async cancelJob(id: string): Promise<TranscriptionJob> {
const job = this.jobs.get(id);
if (!job) {
throw new NotFoundException(`Job ${id} not found`);
}
if (
job.status === JobStatus.PENDING ||
job.status === JobStatus.DOWNLOADING ||
job.status === JobStatus.TRANSCRIBING
) {
job.status = JobStatus.CANCELLED;
job.error = 'Cancelled by user';
this.progressGateway.broadcastJobUpdate(job.id, {
status: job.status,
error: job.error,
});
}
return job;
}
private async processJob(job: TranscriptionJob): Promise<void> {
let audioPath: string | null = null;
const jobId = job.id;
// Helper to check if job was cancelled (re-reads from map to get current status)
const isCancelled = (): boolean => {
const currentJob = this.jobs.get(jobId);
return currentJob?.status === JobStatus.CANCELLED;
};
try {
// Step 1: Get video info
this.updateJobProgress(job, JobStatus.DOWNLOADING, 5);
const videoInfo = await this.youtubeService.getVideoInfo(job.url);
job.videoInfo = videoInfo;
this.updateJobProgress(job, JobStatus.DOWNLOADING, 10);
this.logger.log(`Processing: ${videoInfo.title}`);
// Check if cancelled
if (isCancelled()) return;
// Step 2: Download audio
audioPath = await this.youtubeService.downloadAudio(job.url, (progress) => {
const overallProgress = 10 + progress.percent * 0.4; // 10-50%
this.updateJobProgress(job, JobStatus.DOWNLOADING, Math.round(overallProgress));
});
this.updateJobProgress(job, JobStatus.DOWNLOADING, 50);
// Check if cancelled
if (isCancelled()) {
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
return;
}
// Step 3: Transcribe
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 55);
const result = await this.whisperService.transcribe(
audioPath,
job.language,
job.provider as WhisperProvider,
job.model as WhisperModel
);
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 90);
// Check if cancelled
if (isCancelled()) {
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
return;
}
// Step 4: Save transcript
const transcriptPath = await this.saveTranscript(job, videoInfo, result.text);
job.transcriptPath = transcriptPath;
job.transcriptText = result.text;
job.status = JobStatus.COMPLETED;
job.progress = 100;
job.completedAt = new Date();
this.progressGateway.broadcastJobUpdate(job.id, {
status: job.status,
progress: job.progress,
transcriptPath: job.transcriptPath,
});
this.logger.log(`Completed: ${videoInfo.title}`);
} catch (error) {
job.status = JobStatus.FAILED;
job.error = error instanceof Error ? error.message : 'Unknown error';
this.progressGateway.broadcastJobUpdate(job.id, {
status: job.status,
error: job.error,
});
this.logger.error(`Job failed: ${job.error}`);
} finally {
// Cleanup audio file
if (audioPath) {
await this.youtubeService.cleanupFile(audioPath);
}
}
}
private updateJobProgress(job: TranscriptionJob, status: JobStatus, progress: number): void {
job.status = status;
job.progress = progress;
this.progressGateway.broadcastJobUpdate(job.id, {
status: job.status,
progress: job.progress,
videoInfo: job.videoInfo,
});
}
private async saveTranscript(
job: TranscriptionJob,
videoInfo: { channel: string; title: string; id: string },
text: string
): Promise<string> {
// Sanitize names for filesystem
const sanitize = (str: string) => str.replace(/[^a-z0-9äöüß\-_]/gi, '_').substring(0, 50);
const channelDir = path.join(this.transcriptsDir, sanitize(videoInfo.channel));
if (!fs.existsSync(channelDir)) {
fs.mkdirSync(channelDir, { recursive: true });
}
const filename = `${sanitize(videoInfo.title)}_${videoInfo.id}.txt`;
const filePath = path.join(channelDir, filename);
const content = `# ${videoInfo.title}
Channel: ${videoInfo.channel}
Video ID: ${videoInfo.id}
Language: ${job.language}
Transcribed: ${new Date().toISOString()}
Provider: ${job.provider}
---
${text}
`;
fs.writeFileSync(filePath, content, 'utf-8');
return filePath;
}
async getStats() {
const jobs = Array.from(this.jobs.values());
let totalTranscripts = 0;
let totalSize = 0;
if (fs.existsSync(this.transcriptsDir)) {
const countFiles = (dir: string) => {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
countFiles(fullPath);
} else if (item.name.endsWith('.txt')) {
totalTranscripts++;
totalSize += fs.statSync(fullPath).size;
}
}
};
countFiles(this.transcriptsDir);
}
return {
totalTranscripts,
totalSizeMB: Math.round((totalSize / 1024 / 1024) * 100) / 100,
activeJobs: jobs.filter(
(j) =>
j.status === JobStatus.PENDING ||
j.status === JobStatus.DOWNLOADING ||
j.status === JobStatus.TRANSCRIBING
).length,
completedJobs: jobs.filter((j) => j.status === JobStatus.COMPLETED).length,
failedJobs: jobs.filter((j) => j.status === JobStatus.FAILED).length,
};
}
}

View file

@ -1,79 +0,0 @@
import {
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
export interface JobUpdatePayload {
status: string;
progress?: number;
error?: string;
videoInfo?: {
id: string;
title: string;
channel: string;
thumbnail: string;
};
transcriptPath?: string;
}
@WebSocketGateway({
cors: {
origin: ['http://localhost:5173', 'http://localhost:4321', 'http://localhost:3000'],
credentials: true,
},
namespace: '/progress',
})
export class ProgressGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(ProgressGateway.name);
@WebSocketServer()
server: Server;
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
// Send heartbeat every 10 seconds
const interval = setInterval(() => {
client.emit('heartbeat', { timestamp: Date.now() });
}, 10000);
client.on('disconnect', () => {
clearInterval(interval);
});
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
broadcastJobUpdate(jobId: string, payload: JobUpdatePayload) {
this.server.emit('job_update', {
type: 'job_update',
jobId,
...payload,
timestamp: Date.now(),
});
}
broadcastJobComplete(jobId: string, payload: JobUpdatePayload) {
this.server.emit('job_complete', {
type: 'job_complete',
jobId,
...payload,
timestamp: Date.now(),
});
}
broadcastJobError(jobId: string, error: string) {
this.server.emit('job_error', {
type: 'job_error',
jobId,
error,
timestamp: Date.now(),
});
}
}

View file

@ -1,9 +0,0 @@
import { Module, Global } from '@nestjs/common';
import { ProgressGateway } from './progress.gateway';
@Global()
@Module({
providers: [ProgressGateway],
exports: [ProgressGateway],
})
export class WebsocketModule {}

View file

@ -1,17 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { WhisperService } from './whisper.service';
@Controller('whisper')
export class WhisperController {
constructor(private readonly whisperService: WhisperService) {}
@Get('models')
getModels() {
return {
models: this.whisperService.getAvailableModels(),
defaultProvider: this.whisperService.getDefaultProvider(),
defaultModel: this.whisperService.getDefaultModel(),
groqAvailable: this.whisperService.isGroqAvailable(),
};
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { WhisperService } from './whisper.service';
import { WhisperController } from './whisper.controller';
@Module({
controllers: [WhisperController],
providers: [WhisperService],
exports: [WhisperService],
})
export class WhisperModule {}

View file

@ -1,219 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import * as fs from 'fs';
import OpenAI from 'openai';
export type WhisperProvider = 'groq' | 'local';
export type GroqWhisperModel = 'whisper-large-v3-turbo' | 'whisper-large-v3';
export type LocalWhisperModel = 'tiny' | 'base' | 'small' | 'medium' | 'large';
export type WhisperModel = GroqWhisperModel | LocalWhisperModel;
export interface TranscriptionResult {
text: string;
language: string;
duration: number;
provider: WhisperProvider;
}
export interface WhisperModelInfo {
name: string;
provider: WhisperProvider;
speed: string;
accuracy: string;
cost?: string;
}
@Injectable()
export class WhisperService {
private readonly logger = new Logger(WhisperService.name);
private readonly groqClient: OpenAI | null;
private readonly defaultProvider: WhisperProvider;
private readonly defaultModel: WhisperModel;
constructor(private configService: ConfigService) {
const groqApiKey = this.configService.get<string>('GROQ_API_KEY');
if (groqApiKey) {
// Groq uses OpenAI-compatible API
this.groqClient = new OpenAI({
apiKey: groqApiKey,
baseURL: 'https://api.groq.com/openai/v1',
});
this.logger.log('Groq API configured successfully');
} else {
this.groqClient = null;
this.logger.warn('Groq API key not configured. Only local Whisper available.');
}
this.defaultProvider =
(this.configService.get<string>('WHISPER_PROVIDER') as WhisperProvider) || 'groq';
this.defaultModel =
(this.configService.get<string>('WHISPER_MODEL') as WhisperModel) || 'whisper-large-v3-turbo';
}
async transcribe(
audioPath: string,
language: string = 'de',
provider?: WhisperProvider,
model?: WhisperModel
): Promise<TranscriptionResult> {
const selectedProvider = provider || this.defaultProvider;
const selectedModel = model || this.defaultModel;
// Fallback to local if Groq not available
if (selectedProvider === 'groq' && !this.groqClient) {
this.logger.warn('Groq not configured, falling back to local Whisper');
return this.transcribeWithLocalWhisper(
audioPath,
language,
selectedModel as LocalWhisperModel
);
}
if (selectedProvider === 'groq') {
return this.transcribeWithGroq(audioPath, language, selectedModel as GroqWhisperModel);
}
return this.transcribeWithLocalWhisper(audioPath, language, selectedModel as LocalWhisperModel);
}
private async transcribeWithGroq(
audioPath: string,
language: string,
model: GroqWhisperModel = 'whisper-large-v3-turbo'
): Promise<TranscriptionResult> {
if (!this.groqClient) {
throw new Error('Groq API not configured');
}
this.logger.log(`Transcribing with Groq Whisper API (${model}): ${audioPath}`);
const startTime = Date.now();
const transcription = await this.groqClient.audio.transcriptions.create({
file: fs.createReadStream(audioPath),
model: model,
language,
response_format: 'verbose_json',
});
const duration = (Date.now() - startTime) / 1000;
this.logger.log(`Groq transcription completed in ${duration.toFixed(2)}s`);
return {
text: transcription.text,
language: transcription.language || language,
duration,
provider: 'groq',
};
}
private async transcribeWithLocalWhisper(
audioPath: string,
language: string,
model: WhisperModel
): Promise<TranscriptionResult> {
this.logger.log(`Transcribing with local Whisper (model: ${model}): ${audioPath}`);
const startTime = Date.now();
return new Promise((resolve, reject) => {
// Python script to run Whisper
const pythonScript = `
import whisper
import json
import sys
model = whisper.load_model("${model}")
result = model.transcribe("${audioPath}", language="${language}")
print(json.dumps({"text": result["text"], "language": result.get("language", "${language}")}))
`.trim();
const python = spawn('python3', ['-c', pythonScript]);
let stdout = '';
let stderr = '';
python.stdout.on('data', (data) => {
stdout += data.toString();
});
python.stderr.on('data', (data) => {
stderr += data.toString();
// Whisper outputs progress to stderr, log it
this.logger.debug(data.toString());
});
python.on('close', (code) => {
const duration = (Date.now() - startTime) / 1000;
if (code !== 0) {
this.logger.error(`Local Whisper error: ${stderr}`);
reject(new Error(`Transcription failed: ${stderr}`));
return;
}
try {
const result = JSON.parse(stdout.trim());
resolve({
text: result.text,
language: result.language,
duration,
provider: 'local',
});
} catch (e) {
reject(new Error('Failed to parse transcription result'));
}
});
});
}
getAvailableModels(): WhisperModelInfo[] {
const models: WhisperModelInfo[] = [];
// Groq models (cloud, ultra-fast)
if (this.groqClient) {
models.push(
{
name: 'whisper-large-v3-turbo',
provider: 'groq',
speed: '~300x realtime',
accuracy: '95%',
cost: '$0.04/hour',
},
{
name: 'whisper-large-v3',
provider: 'groq',
speed: '~250x realtime',
accuracy: '97%',
cost: '$0.111/hour',
}
);
}
// Local models
models.push(
{ name: 'tiny', provider: 'local', speed: '~10x realtime', accuracy: '75%' },
{ name: 'base', provider: 'local', speed: '~7x realtime', accuracy: '85%' },
{ name: 'small', provider: 'local', speed: '~4x realtime', accuracy: '91%' },
{ name: 'medium', provider: 'local', speed: '~2x realtime', accuracy: '94%' },
{ name: 'large', provider: 'local', speed: '~1x realtime', accuracy: '96-98%' }
);
return models;
}
isGroqAvailable(): boolean {
return this.groqClient !== null;
}
getDefaultProvider(): WhisperProvider {
return this.defaultProvider;
}
getDefaultModel(): WhisperModel {
return this.defaultModel;
}
}

View file

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { YoutubeService } from './youtube.service';
@Module({
providers: [YoutubeService],
exports: [YoutubeService],
})
export class YoutubeModule {}

View file

@ -1,163 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
export interface VideoInfo {
id: string;
title: string;
description: string;
duration: number;
channel: string;
channelId: string;
thumbnail: string;
uploadDate: string;
}
export interface DownloadProgress {
percent: number;
speed: string;
eta: string;
}
@Injectable()
export class YoutubeService {
private readonly logger = new Logger(YoutubeService.name);
private readonly tempDir: string;
constructor(private configService: ConfigService) {
this.tempDir = this.configService.get<string>('TEMP_AUDIO_DIR') || './temp_audio';
// Ensure temp directory exists
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
async getVideoInfo(url: string): Promise<VideoInfo> {
return new Promise((resolve, reject) => {
const ytdlp = spawn('yt-dlp', ['--dump-json', '--no-download', url]);
let stdout = '';
let stderr = '';
ytdlp.stdout.on('data', (data) => {
stdout += data.toString();
});
ytdlp.stderr.on('data', (data) => {
stderr += data.toString();
});
ytdlp.on('close', (code) => {
if (code !== 0) {
this.logger.error(`yt-dlp info error: ${stderr}`);
reject(new Error(`Failed to get video info: ${stderr}`));
return;
}
try {
const info = JSON.parse(stdout);
resolve({
id: info.id,
title: info.title,
description: info.description || '',
duration: info.duration,
channel: info.channel || info.uploader,
channelId: info.channel_id || info.uploader_id,
thumbnail: info.thumbnail,
uploadDate: info.upload_date,
});
} catch (e) {
reject(new Error('Failed to parse video info'));
}
});
});
}
async downloadAudio(
url: string,
onProgress?: (progress: DownloadProgress) => void
): Promise<string> {
const outputId = uuidv4();
const outputPath = path.join(this.tempDir, `${outputId}.mp3`);
return new Promise((resolve, reject) => {
const ytdlp = spawn('yt-dlp', [
'-x',
'--audio-format',
'mp3',
'--audio-quality',
'0',
'-o',
outputPath.replace('.mp3', '.%(ext)s'),
'--newline',
url,
]);
let stderr = '';
ytdlp.stdout.on('data', (data) => {
const line = data.toString();
// Parse download progress
const progressMatch = line.match(/(\d+\.?\d*)%.*?(\d+\.?\d*\w+\/s).*?ETA\s+(\d+:\d+)/);
if (progressMatch && onProgress) {
onProgress({
percent: parseFloat(progressMatch[1]),
speed: progressMatch[2],
eta: progressMatch[3],
});
}
});
ytdlp.stderr.on('data', (data) => {
stderr += data.toString();
});
ytdlp.on('close', (code) => {
if (code !== 0) {
this.logger.error(`yt-dlp download error: ${stderr}`);
reject(new Error(`Download failed: ${stderr}`));
return;
}
// Find the actual output file (might have different extension initially)
const files = fs.readdirSync(this.tempDir);
const outputFile = files.find((f) => f.startsWith(outputId));
if (!outputFile) {
reject(new Error('Output file not found'));
return;
}
const actualPath = path.join(this.tempDir, outputFile);
this.logger.log(`Downloaded audio to: ${actualPath}`);
resolve(actualPath);
});
});
}
async cleanupFile(filePath: string): Promise<void> {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
this.logger.log(`Cleaned up: ${filePath}`);
}
} catch (e) {
this.logger.warn(`Failed to cleanup file: ${filePath}`);
}
}
isValidYoutubeUrl(url: string): boolean {
const patterns = [
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//,
/^(https?:\/\/)?(www\.)?youtube\.com\/watch\?v=/,
/^(https?:\/\/)?youtu\.be\//,
];
return patterns.some((pattern) => pattern.test(url));
}
}

View file

@ -1,26 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,235 +0,0 @@
import { createSignal, createEffect, onMount, For } from 'solid-js';
interface Job {
id: string;
url: string;
status: string;
progress: number;
created_at: string;
video_info: any;
}
interface Stats {
total_transcripts: number;
total_size_mb: number;
active_jobs: number;
completed_jobs: number;
failed_jobs: number;
}
const API_URL = 'http://localhost:8000';
export default function Dashboard() {
const [jobs, setJobs] = createSignal<Job[]>([]);
const [stats, setStats] = createSignal<Stats | null>(null);
const [newUrl, setNewUrl] = createSignal('');
const [selectedModel, setSelectedModel] = createSignal('base');
const [isLoading, setIsLoading] = createSignal(false);
const [ws, setWs] = createSignal<WebSocket | null>(null);
onMount(() => {
fetchJobs();
fetchStats();
connectWebSocket();
});
const connectWebSocket = () => {
const websocket = new WebSocket(`ws://localhost:8000/ws/progress`);
websocket.onopen = () => {
console.log('WebSocket connected');
};
websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'job_update' || data.type === 'job_complete') {
fetchJobs();
fetchStats();
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
setWs(websocket);
};
const fetchJobs = async () => {
try {
const response = await fetch(`${API_URL}/api/jobs`);
const data = await response.json();
setJobs(data);
} catch (error) {
console.error('Error fetching jobs:', error);
}
};
const fetchStats = async () => {
try {
const response = await fetch(`${API_URL}/api/stats`);
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Error fetching stats:', error);
}
};
const startTranscription = async () => {
if (!newUrl()) return;
setIsLoading(true);
try {
const response = await fetch(`${API_URL}/api/transcribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: newUrl(),
model: selectedModel(),
language: 'de',
}),
});
if (response.ok) {
setNewUrl('');
fetchJobs();
fetchStats();
}
} catch (error) {
console.error('Error starting transcription:', error);
}
setIsLoading(false);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'text-yellow-400';
case 'downloading':
return 'text-blue-400';
case 'transcribing':
return 'text-purple-400';
case 'completed':
return 'text-green-400';
case 'failed':
return 'text-red-400';
default:
return 'text-gray-400';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return '⏳';
case 'downloading':
return '⬇️';
case 'transcribing':
return '🎙️';
case 'completed':
return '✅';
case 'failed':
return '❌';
default:
return '❓';
}
};
return (
<div class="space-y-6">
{/* Stats Cards */}
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="bg-gray-800 p-4 rounded-lg">
<div class="text-2xl font-bold text-white">{stats()?.total_transcripts || 0}</div>
<div class="text-sm text-gray-400">Transkripte</div>
</div>
<div class="bg-gray-800 p-4 rounded-lg">
<div class="text-2xl font-bold text-white">{stats()?.total_size_mb || 0} MB</div>
<div class="text-sm text-gray-400">Speicher</div>
</div>
<div class="bg-gray-800 p-4 rounded-lg">
<div class="text-2xl font-bold text-yellow-400">{stats()?.active_jobs || 0}</div>
<div class="text-sm text-gray-400">Aktiv</div>
</div>
<div class="bg-gray-800 p-4 rounded-lg">
<div class="text-2xl font-bold text-green-400">{stats()?.completed_jobs || 0}</div>
<div class="text-sm text-gray-400">Fertig</div>
</div>
<div class="bg-gray-800 p-4 rounded-lg">
<div class="text-2xl font-bold text-red-400">{stats()?.failed_jobs || 0}</div>
<div class="text-sm text-gray-400">Fehler</div>
</div>
</div>
{/* New Transcription Form */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Neue Transkription</h2>
<div class="flex gap-4">
<input
type="text"
value={newUrl()}
onInput={(e) => setNewUrl(e.currentTarget.value)}
placeholder="YouTube URL eingeben..."
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<select
value={selectedModel()}
onChange={(e) => setSelectedModel(e.currentTarget.value)}
class="px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="tiny">Tiny (Schnell)</option>
<option value="base">Base</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large (Beste Qualität)</option>
</select>
<button
onClick={startTranscription}
disabled={isLoading() || !newUrl()}
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading() ? 'Lädt...' : 'Starten'}
</button>
</div>
</div>
{/* Active Jobs */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Aktive Jobs</h2>
<div class="space-y-4">
<For each={jobs()}>
{(job) => (
<div class="bg-gray-700 p-4 rounded-lg">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="text-xl">{getStatusIcon(job.status)}</span>
<span class={`font-semibold ${getStatusColor(job.status)}`}>
{job.status.toUpperCase()}
</span>
</div>
<div class="text-sm text-gray-400">
{new Date(job.created_at).toLocaleString('de-DE')}
</div>
</div>
<div class="text-sm text-gray-300 mb-2 truncate">{job.url}</div>
{job.status !== 'completed' && job.status !== 'failed' && (
<div class="w-full bg-gray-600 rounded-full h-2">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={`width: ${job.progress}%`}
/>
</div>
)}
</div>
)}
</For>
{jobs().length === 0 && (
<div class="text-center text-gray-400 py-8">Keine aktiven Jobs</div>
)}
</div>
</div>
</div>
);
}

View file

@ -1,263 +0,0 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
interface Playlist {
category: string;
name: string;
path: string;
url_count: number;
urls: string[];
}
const API_URL = 'http://localhost:8000';
export default function PlaylistManager() {
const [playlists, setPlaylists] = createSignal<Playlist[]>([]);
const [selectedPlaylist, setSelectedPlaylist] = createSignal<Playlist | null>(null);
const [newPlaylistName, setNewPlaylistName] = createSignal('');
const [newPlaylistCategory, setNewPlaylistCategory] = createSignal('general');
const [newUrls, setNewUrls] = createSignal('');
const [isCreating, setIsCreating] = createSignal(false);
const [isProcessing, setIsProcessing] = createSignal(false);
onMount(() => {
fetchPlaylists();
});
const fetchPlaylists = async () => {
try {
const response = await fetch(`${API_URL}/api/playlists`);
const data = await response.json();
setPlaylists(data);
} catch (error) {
console.error('Error fetching playlists:', error);
}
};
const createPlaylist = async () => {
if (!newPlaylistName() || !newUrls()) return;
try {
const urls = newUrls()
.split('\n')
.filter((url) => url.trim());
const name =
newPlaylistCategory() === 'general'
? newPlaylistName()
: `${newPlaylistCategory()}/${newPlaylistName()}`;
const response = await fetch(`${API_URL}/api/playlists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
urls: urls,
}),
});
if (response.ok) {
setNewPlaylistName('');
setNewUrls('');
setIsCreating(false);
fetchPlaylists();
}
} catch (error) {
console.error('Error creating playlist:', error);
}
};
const processPlaylist = async (playlist: Playlist) => {
setIsProcessing(true);
try {
// Process each URL in the playlist
for (const url of playlist.urls) {
await fetch(`${API_URL}/api/transcribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url,
model: 'large',
language: 'de',
}),
});
}
alert(`Started processing ${playlist.url_count} videos from ${playlist.name}`);
} catch (error) {
console.error('Error processing playlist:', error);
}
setIsProcessing(false);
};
const getCategoryColor = (category: string) => {
const colors: { [key: string]: string } = {
tech: 'bg-blue-900',
people: 'bg-purple-900',
musik: 'bg-pink-900',
gaming: 'bg-green-900',
general: 'bg-gray-800',
};
return colors[category] || 'bg-gray-800';
};
const getCategoryIcon = (category: string) => {
const icons: { [key: string]: string } = {
tech: '💻',
people: '👥',
musik: '🎵',
gaming: '🎮',
general: '📁',
};
return icons[category] || '📁';
};
return (
<div class="space-y-6">
{/* Header with Create Button */}
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Playlists</h1>
<button
onClick={() => setIsCreating(true)}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
+ Neue Playlist
</button>
</div>
{/* Create New Playlist Form */}
<Show when={isCreating()}>
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Neue Playlist erstellen</h2>
<div class="space-y-4">
<div class="flex gap-4">
<select
value={newPlaylistCategory()}
onChange={(e) => setNewPlaylistCategory(e.currentTarget.value)}
class="px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="general">General</option>
<option value="tech">Tech</option>
<option value="people">People</option>
<option value="musik">Musik</option>
<option value="gaming">Gaming</option>
</select>
<input
type="text"
value={newPlaylistName()}
onInput={(e) => setNewPlaylistName(e.currentTarget.value)}
placeholder="Playlist Name..."
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<textarea
value={newUrls()}
onInput={(e) => setNewUrls(e.currentTarget.value)}
placeholder="YouTube URLs (eine pro Zeile)..."
rows={6}
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div class="flex gap-4">
<button
onClick={createPlaylist}
disabled={!newPlaylistName() || !newUrls()}
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
>
Erstellen
</button>
<button
onClick={() => {
setIsCreating(false);
setNewPlaylistName('');
setNewUrls('');
}}
class="px-6 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
Abbrechen
</button>
</div>
</div>
</div>
</Show>
{/* Playlists Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<For each={playlists()}>
{(playlist) => (
<div
class={`${getCategoryColor(playlist.category)} p-6 rounded-lg cursor-pointer hover:opacity-90 transition-opacity`}
onClick={() => setSelectedPlaylist(playlist)}
>
<div class="flex items-start justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="text-2xl">{getCategoryIcon(playlist.category)}</span>
<div>
<h3 class="font-bold text-lg">{playlist.name}</h3>
<p class="text-sm text-gray-400">{playlist.category}</p>
</div>
</div>
<span class="bg-gray-700 px-2 py-1 rounded text-sm">
{playlist.url_count} Videos
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
processPlaylist(playlist);
}}
disabled={isProcessing()}
class="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isProcessing() ? 'Verarbeite...' : 'Alle transkribieren'}
</button>
</div>
)}
</For>
</div>
{/* Selected Playlist Details */}
<Show when={selectedPlaylist()}>
<div class="bg-gray-800 p-6 rounded-lg">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">{selectedPlaylist()!.name} - URLs</h2>
<button
onClick={() => setSelectedPlaylist(null)}
class="text-gray-400 hover:text-white"
>
</button>
</div>
<div class="space-y-2 max-h-96 overflow-y-auto">
<For each={selectedPlaylist()!.urls}>
{(url, index) => (
<div class="flex items-center space-x-2 p-2 bg-gray-700 rounded">
<span class="text-gray-400 text-sm">{index() + 1}.</span>
<a
href={url}
target="_blank"
class="text-blue-400 hover:underline text-sm truncate flex-1"
>
{url}
</a>
</div>
)}
</For>
</div>
</div>
</Show>
{playlists().length === 0 && !isCreating() && (
<div class="text-center text-gray-400 py-12">
<p class="text-xl mb-4">Keine Playlists vorhanden</p>
<button
onClick={() => setIsCreating(true)}
class="px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Erste Playlist erstellen
</button>
</div>
)}
</div>
);
}

View file

@ -1,257 +0,0 @@
import { createSignal, onMount, For } from 'solid-js';
interface Model {
name: string;
size: string;
speed: string;
accuracy: string;
}
const API_URL = 'http://localhost:8000';
export default function Settings() {
const [models, setModels] = createSignal<Model[]>([]);
const [selectedModel, setSelectedModel] = createSignal('base');
const [selectedLanguage, setSelectedLanguage] = createSignal('de');
const [maxParallelDownloads, setMaxParallelDownloads] = createSignal(3);
const [maxParallelTranscriptions, setMaxParallelTranscriptions] = createSignal(2);
const [isSaving, setIsSaving] = createSignal(false);
onMount(() => {
fetchModels();
loadSettings();
});
const fetchModels = async () => {
try {
const response = await fetch(`${API_URL}/api/models`);
const data = await response.json();
setModels(data.models);
} catch (error) {
console.error('Error fetching models:', error);
}
};
const loadSettings = () => {
// Load from localStorage
const saved = localStorage.getItem('transcriber-settings');
if (saved) {
const settings = JSON.parse(saved);
setSelectedModel(settings.model || 'base');
setSelectedLanguage(settings.language || 'de');
setMaxParallelDownloads(settings.maxDownloads || 3);
setMaxParallelTranscriptions(settings.maxTranscriptions || 2);
}
};
const saveSettings = () => {
setIsSaving(true);
const settings = {
model: selectedModel(),
language: selectedLanguage(),
maxDownloads: maxParallelDownloads(),
maxTranscriptions: maxParallelTranscriptions(),
};
localStorage.setItem('transcriber-settings', JSON.stringify(settings));
setTimeout(() => {
setIsSaving(false);
alert('Einstellungen gespeichert!');
}, 500);
};
const getModelColor = (name: string) => {
switch (name) {
case 'tiny':
return 'text-green-400';
case 'base':
return 'text-blue-400';
case 'small':
return 'text-yellow-400';
case 'medium':
return 'text-orange-400';
case 'large':
return 'text-red-400';
default:
return 'text-gray-400';
}
};
return (
<div class="max-w-4xl mx-auto space-y-6">
<h1 class="text-2xl font-bold mb-6">Einstellungen</h1>
{/* Model Selection */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Whisper Modell</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<For each={models()}>
{(model) => (
<div
class={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
selectedModel() === model.name
? 'border-blue-500 bg-blue-900/30'
: 'border-gray-700 hover:border-gray-600'
}`}
onClick={() => setSelectedModel(model.name)}
>
<h3 class={`font-bold text-lg mb-2 ${getModelColor(model.name)}`}>
{model.name.toUpperCase()}
</h3>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Größe:</span>
<span class="text-white">{model.size}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Speed:</span>
<span class="text-white">{model.speed}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Genauigkeit:</span>
<span class="text-white">{model.accuracy}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
{/* Language Selection */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Sprache</h2>
<select
value={selectedLanguage()}
onChange={(e) => setSelectedLanguage(e.currentTarget.value)}
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="pt">Português</option>
<option value="nl">Nederlands</option>
<option value="pl">Polski</option>
<option value="ru">Русский</option>
<option value="zh"></option>
<option value="ja"></option>
<option value="ko"></option>
</select>
</div>
{/* Parallel Processing Settings */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Parallel-Verarbeitung</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">
Max. parallele Downloads: {maxParallelDownloads()}
</label>
<input
type="range"
min="1"
max="5"
value={maxParallelDownloads()}
onInput={(e) => setMaxParallelDownloads(parseInt(e.currentTarget.value))}
class="w-full"
/>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>1 (Langsam)</span>
<span>3 (Standard)</span>
<span>5 (Schnell)</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">
Max. parallele Transkriptionen: {maxParallelTranscriptions()}
</label>
<input
type="range"
min="1"
max="4"
value={maxParallelTranscriptions()}
onInput={(e) => setMaxParallelTranscriptions(parseInt(e.currentTarget.value))}
class="w-full"
/>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>1 (Wenig RAM)</span>
<span>2 (Standard)</span>
<span>4 (Viel RAM)</span>
</div>
</div>
</div>
</div>
{/* Performance Tips */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4"> Performance-Tipps</h2>
<ul class="space-y-2 text-sm text-gray-300">
<li class="flex items-start">
<span class="mr-2"></span>
<span>
<strong>Tiny:</strong> Perfekt für schnelle Tests und Previews
</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span>
<strong>Base/Small:</strong> Guter Kompromiss für die meisten Videos
</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span>
<strong>Large:</strong> Beste Qualität für wichtige Transkriptionen
</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span>Mehr parallele Downloads = Schneller, aber mehr Bandbreite</span>
</li>
<li class="flex items-start">
<span class="mr-2"></span>
<span>Mehr parallele Transkriptionen = Schneller, aber mehr RAM-Verbrauch</span>
</li>
</ul>
</div>
{/* Save Button */}
<div class="flex justify-end">
<button
onClick={saveSettings}
disabled={isSaving()}
class="px-8 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-semibold"
>
{isSaving() ? 'Speichere...' : 'Einstellungen speichern'}
</button>
</div>
{/* System Info */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">System-Info</h2>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-400">API Server:</span>
<span class="ml-2 text-green-400">Online</span>
</div>
<div>
<span class="text-gray-400">Version:</span>
<span class="ml-2 text-white">4.0 Parallel</span>
</div>
<div>
<span class="text-gray-400">Platform:</span>
<span class="ml-2 text-white">macOS (Apple Silicon)</span>
</div>
<div>
<span class="text-gray-400">API Endpoint:</span>
<span class="ml-2 text-white">{API_URL}</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,235 +0,0 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
interface Transcript {
playlist: string;
channel: string;
filename: string;
path: string;
size: number;
modified: string;
}
const API_URL = 'http://localhost:8000';
export default function TranscriptViewer() {
const [transcripts, setTranscripts] = createSignal<Transcript[]>([]);
const [selectedTranscript, setSelectedTranscript] = createSignal<Transcript | null>(null);
const [transcriptContent, setTranscriptContent] = createSignal<string>('');
const [searchQuery, setSearchQuery] = createSignal('');
const [filteredTranscripts, setFilteredTranscripts] = createSignal<Transcript[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
onMount(() => {
fetchTranscripts();
});
createEffect(() => {
const query = searchQuery().toLowerCase();
if (query) {
setFilteredTranscripts(
transcripts().filter(
(t) =>
t.filename.toLowerCase().includes(query) ||
t.channel.toLowerCase().includes(query) ||
t.playlist.toLowerCase().includes(query)
)
);
} else {
setFilteredTranscripts(transcripts());
}
});
const fetchTranscripts = async () => {
try {
const response = await fetch(`${API_URL}/api/transcripts`);
const data = await response.json();
setTranscripts(data);
setFilteredTranscripts(data);
} catch (error) {
console.error('Error fetching transcripts:', error);
}
};
const loadTranscript = async (transcript: Transcript) => {
setIsLoading(true);
setSelectedTranscript(transcript);
try {
const response = await fetch(`${API_URL}/api/transcript/${transcript.path}`);
const content = await response.text();
setTranscriptContent(content);
} catch (error) {
console.error('Error loading transcript:', error);
setTranscriptContent('Fehler beim Laden des Transkripts');
}
setIsLoading(false);
};
const downloadTranscript = (transcript: Transcript) => {
const link = document.createElement('a');
link.href = `${API_URL}/api/transcript/${transcript.path}`;
link.download = transcript.filename;
link.click();
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getPlaylistIcon = (playlist: string) => {
if (playlist.includes('tech')) return '💻';
if (playlist.includes('people')) return '👥';
if (playlist.includes('musik')) return '🎵';
if (playlist.includes('gaming')) return '🎮';
return '📁';
};
return (
<div class="space-y-6">
{/* Search Bar */}
<div class="bg-gray-800 p-4 rounded-lg">
<input
type="text"
value={searchQuery()}
onInput={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Transkripte durchsuchen..."
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Transcript List */}
<div class="bg-gray-800 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Transkripte ({filteredTranscripts().length})</h2>
<div class="space-y-2 max-h-[600px] overflow-y-auto">
<For each={filteredTranscripts()}>
{(transcript) => (
<div
class={`p-4 rounded-lg cursor-pointer transition-colors ${
selectedTranscript()?.path === transcript.path
? 'bg-blue-900'
: 'bg-gray-700 hover:bg-gray-600'
}`}
onClick={() => loadTranscript(transcript)}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-1">
<span class="text-lg">{getPlaylistIcon(transcript.playlist)}</span>
<span class="text-sm text-gray-400">{transcript.playlist}</span>
</div>
<h3 class="font-semibold text-sm mb-1 line-clamp-2">
{transcript.filename.replace(/_/g, ' ').replace('.txt', '')}
</h3>
<div class="flex items-center space-x-4 text-xs text-gray-400">
<span>{transcript.channel}</span>
<span>{formatFileSize(transcript.size)}</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
downloadTranscript(transcript);
}}
class="ml-2 p-2 text-gray-400 hover:text-white"
title="Download"
>
</button>
</div>
<div class="text-xs text-gray-500 mt-2">{formatDate(transcript.modified)}</div>
</div>
)}
</For>
{filteredTranscripts().length === 0 && (
<div class="text-center text-gray-400 py-8">
{searchQuery() ? 'Keine Transkripte gefunden' : 'Noch keine Transkripte vorhanden'}
</div>
)}
</div>
</div>
{/* Transcript Content Viewer */}
<div class="bg-gray-800 p-6 rounded-lg">
<Show when={selectedTranscript()}>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Transkript-Inhalt</h2>
<div class="flex space-x-2">
<button
onClick={() => {
navigator.clipboard.writeText(transcriptContent());
alert('In Zwischenablage kopiert!');
}}
class="px-3 py-1 bg-gray-700 text-white rounded hover:bg-gray-600"
>
📋 Kopieren
</button>
<button
onClick={() => downloadTranscript(selectedTranscript()!)}
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Download
</button>
</div>
</div>
</Show>
<Show when={isLoading()}>
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
<p class="mt-4 text-gray-400">Lade Transkript...</p>
</div>
</Show>
<Show when={!isLoading() && selectedTranscript()}>
<div class="bg-gray-900 p-4 rounded-lg max-h-[500px] overflow-y-auto">
<pre class="text-sm text-gray-300 whitespace-pre-wrap font-mono">
{transcriptContent()}
</pre>
</div>
</Show>
<Show when={!selectedTranscript() && !isLoading()}>
<div class="text-center text-gray-400 py-12">
<p class="text-xl mb-2">Kein Transkript ausgewählt</p>
<p class="text-sm">Wähle ein Transkript aus der Liste aus</p>
</div>
</Show>
</div>
</div>
{/* Statistics */}
<div class="bg-gray-800 p-4 rounded-lg">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-2xl font-bold text-white">{transcripts().length}</div>
<div class="text-sm text-gray-400">Gesamt</div>
</div>
<div>
<div class="text-2xl font-bold text-white">
{formatFileSize(transcripts().reduce((sum, t) => sum + t.size, 0))}
</div>
<div class="text-sm text-gray-400">Speicher</div>
</div>
<div>
<div class="text-2xl font-bold text-white">
{[...new Set(transcripts().map((t) => t.channel))].length}
</div>
<div class="text-sm text-gray-400">Kanäle</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,109 +0,0 @@
---
export interface Quote {
text: string;
talk: string;
context?: string;
timestamp?: string;
}
export interface Props {
quotes: Quote[];
speakerName: string;
}
const { quotes, speakerName } = Astro.props;
---
<section class="py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-theme-text mb-8">
Wichtige Zitate & Einsichten
</h2>
<div class="grid md:grid-cols-2 gap-6">
{quotes.map((quote, index) => (
<div
class="bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 group"
>
<!-- Quote Icon -->
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-theme-primary/30 group-hover:text-theme-primary/50 transition-colors"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
</svg>
</div>
<div class="flex-1">
<!-- Quote Text -->
<blockquote class="text-theme-text text-lg leading-relaxed mb-4">
"{quote.text}"
</blockquote>
<!-- Context if available -->
{quote.context && (
<p class="text-theme-text-muted text-sm mb-3 italic">
Kontext: {quote.context}
</p>
)}
<!-- Source -->
<div class="flex items-center justify-between">
<cite class="text-theme-primary text-sm not-italic font-medium">
— {quote.talk}
</cite>
{quote.timestamp && (
<span class="text-theme-text-muted text-xs">
{quote.timestamp}
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
<!-- Load More Button -->
{quotes.length > 6 && (
<div class="text-center mt-8">
<button
id="loadMoreQuotes"
class="bg-theme-primary/10 text-theme-primary px-6 py-3 rounded-lg hover:bg-theme-primary/20 transition-colors"
>
Mehr Zitate laden
</button>
</div>
)}
</div>
</section>
<script>
// Initially hide quotes beyond the first 6
const quoteCards = document.querySelectorAll('.grid > div');
quoteCards.forEach((card, index) => {
if (index >= 6) {
(card as HTMLElement).style.display = 'none';
}
});
// Load more functionality
const loadMoreBtn = document.getElementById('loadMoreQuotes');
let visibleQuotes = 6;
loadMoreBtn?.addEventListener('click', () => {
const hiddenQuotes = Array.from(quoteCards).slice(visibleQuotes, visibleQuotes + 4);
hiddenQuotes.forEach(card => {
(card as HTMLElement).style.display = 'block';
});
visibleQuotes += 4;
if (visibleQuotes >= quoteCards.length) {
loadMoreBtn.style.display = 'none';
}
});
</script>

View file

@ -1,128 +0,0 @@
---
export interface Talk {
id: string;
title: string;
date: string;
duration: string;
thumbnail?: string;
description: string;
tags: string[];
url: string;
views?: string;
}
export interface Props {
talks: Talk[];
showFilters?: boolean;
}
const { talks, showFilters = true } = Astro.props;
---
<section class="py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
<h2 class="text-3xl font-bold text-theme-text mb-4 md:mb-0">
Alle Vorträge
</h2>
{showFilters && (
<div class="flex gap-4">
<select class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2">
<option>Neueste zuerst</option>
<option>Älteste zuerst</option>
<option>Beliebteste</option>
<option>Längste Dauer</option>
</select>
<select class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2">
<option>Alle Themen</option>
<option>Behavioral Economics</option>
<option>Marketing</option>
<option>Psychology</option>
<option>Innovation</option>
</select>
</div>
)}
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{talks.map(talk => (
<article class="bg-theme-card rounded-xl overflow-hidden border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 group">
<!-- Thumbnail -->
{talk.thumbnail ? (
<div class="relative aspect-video overflow-hidden bg-theme-background">
<img
src={talk.thumbnail}
alt={talk.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
{talk.duration}
</div>
</div>
) : (
<div class="relative aspect-video bg-gradient-to-br from-theme-primary/20 to-theme-secondary/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-theme-primary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
{talk.duration}
</div>
</div>
)}
<!-- Content -->
<div class="p-6">
<h3 class="text-xl font-semibold text-theme-text mb-2 line-clamp-2 group-hover:text-theme-primary transition-colors">
<a href={talk.url} class="hover:underline">
{talk.title}
</a>
</h3>
<p class="text-theme-text-muted text-sm mb-4 line-clamp-3">
{talk.description}
</p>
<!-- Meta Info -->
<div class="flex items-center justify-between text-xs text-theme-text-muted mb-4">
<span>{talk.date}</span>
{talk.views && <span>{talk.views} Aufrufe</span>}
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2">
{talk.tags.slice(0, 3).map(tag => (
<span class="inline-block bg-theme-primary/10 text-theme-primary text-xs px-2 py-1 rounded-full">
{tag}
</span>
))}
{talk.tags.length > 3 && (
<span class="inline-block bg-theme-background text-theme-text-muted text-xs px-2 py-1 rounded-full">
+{talk.tags.length - 3}
</span>
)}
</div>
</div>
</article>
))}
</div>
</div>
</section>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -1,66 +0,0 @@
---
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="description" content="YouTube Transcriber Admin" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-gray-900 text-gray-100">
<nav class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
<a href="/admin" class="flex items-center space-x-2">
<span class="text-2xl">🎥</span>
<span class="text-xl font-bold text-white">Admin Panel</span>
</a>
<div class="ml-10 flex items-baseline space-x-4">
<a
href="/admin"
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
>Dashboard</a
>
<a
href="/admin/playlists"
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
>Playlists</a
>
<a
href="/admin/transcripts"
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
>Transkripte</a
>
<a
href="/admin/settings"
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
>Einstellungen</a
>
</div>
</div>
<div class="flex items-center space-x-4">
<a href="/" class="text-blue-400 hover:text-blue-300 text-sm"
>→ Zur öffentlichen Seite</a
>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<slot />
</main>
</body>
</html>
<style is:global>
@import '../styles/global.css';
</style>

View file

@ -1,189 +0,0 @@
---
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import '../../styles/themes.css';
const currentPath = Astro.url.pathname;
---
<!doctype html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Dashboard - YouTube Transcriber</title>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 30px 0;
}
.stat-card {
background: rgb(var(--theme-card));
padding: 20px;
border-radius: 8px;
border: 1px solid rgba(var(--theme-border), 0.2);
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: rgb(var(--theme-primary));
}
.stat-label {
color: rgb(var(--theme-text-muted));
margin-top: 5px;
}
.quick-actions {
background: rgb(var(--theme-card));
padding: 20px;
border-radius: 8px;
border: 1px solid rgba(var(--theme-border), 0.2);
}
.input-field {
width: 70%;
padding: 10px;
background: rgb(var(--theme-background));
color: rgb(var(--theme-text));
border: 1px solid rgba(var(--theme-border), 0.3);
border-radius: 4px;
}
.btn-primary {
padding: 10px 20px;
background: rgb(var(--theme-primary));
color: white;
border: none;
border-radius: 4px;
margin-left: 10px;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
</style>
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<div class="container">
<!-- Admin sub-navigation -->
<div class="bg-theme-card border border-theme-border/20 rounded-lg p-4 mb-8">
<div class="flex flex-wrap gap-4">
<a href="/admin" class="text-theme-primary font-semibold">Dashboard</a>
<a
href="/admin/playlists"
class="text-theme-text-muted hover:text-theme-primary transition-colors">Playlists</a
>
<a
href="/admin/transcripts"
class="text-theme-text-muted hover:text-theme-primary transition-colors">Transkripte</a
>
<a
href="/admin/settings"
class="text-theme-text-muted hover:text-theme-primary transition-colors"
>Einstellungen</a
>
</div>
</div>
<h1 class="text-4xl font-bold text-theme-primary mb-8">🎥 Admin Dashboard</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-value" id="transcripts">-</div>
<div class="stat-label">Transkripte</div>
</div>
<div class="stat-card">
<div class="stat-value" id="active">-</div>
<div class="stat-label">Aktive Jobs</div>
</div>
<div class="stat-card">
<div class="stat-value" id="playlists">-</div>
<div class="stat-label">Playlists</div>
</div>
<div class="stat-card">
<div class="stat-value" id="size">-</div>
<div class="stat-label">Speicher</div>
</div>
</div>
<h2 class="text-2xl font-semibold text-theme-text mb-4">Quick Actions</h2>
<div class="quick-actions">
<input type="text" id="url" placeholder="YouTube URL eingeben..." class="input-field" />
<button onclick="startTranscription()" class="btn-primary">Transkribieren</button>
</div>
</div>
<Footer />
<script>
// Load stats from API
async function loadStats() {
try {
const response = await fetch('http://localhost:8000/api/stats');
const data = await response.json();
document.getElementById('transcripts').textContent = data.total_transcripts;
document.getElementById('active').textContent = data.active_jobs;
document.getElementById('size').textContent = data.total_size_mb + ' MB';
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Load playlists count
async function loadPlaylists() {
try {
const response = await fetch('http://localhost:8000/api/playlists');
const data = await response.json();
document.getElementById('playlists').textContent = data.length;
} catch (error) {
console.error('Error loading playlists:', error);
}
}
// Start transcription
async function startTranscription() {
const url = document.getElementById('url').value;
if (!url) return;
try {
const response = await fetch('http://localhost:8000/api/transcribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url,
model: 'base',
language: 'de',
}),
});
if (response.ok) {
alert('Transkription gestartet!');
document.getElementById('url').value = '';
loadStats();
}
} catch (error) {
console.error('Error:', error);
alert('Fehler beim Starten der Transkription');
}
}
// Load data on page load
loadStats();
loadPlaylists();
// Refresh every 5 seconds
setInterval(loadStats, 5000);
</script>
</body>
</html>

View file

@ -1,289 +0,0 @@
---
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import '../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Sample speakers data - would come from API/database
const speakers = [
{
id: "rory-sutherland",
name: "Rory Sutherland",
title: "Vice Chairman, Ogilvy UK",
bio: "Behavioral Economics & Marketing Psychology Expert",
talkCount: 12,
totalViews: "15M+",
topics: ["Behavioral Economics", "Marketing", "Psychology"],
imageUrl: null,
featured: true
},
{
id: "simon-sinek",
name: "Simon Sinek",
title: "Leadership Expert & Author",
bio: "Leadership Expert und Autor von 'Start With Why' - mit über 200M+ Views",
talkCount: 4,
totalViews: "200M+",
topics: ["Leadership", "Purpose", "Trust", "Team Building"],
imageUrl: null,
featured: true
},
{
id: "brene-brown",
name: "Brené Brown",
title: "Research Professor",
bio: "Vulnerability, Courage, and Leadership Researcher",
talkCount: 5,
totalViews: "35M+",
topics: ["Vulnerability", "Leadership", "Courage"],
imageUrl: null,
featured: true
},
{
id: "daniel-kahneman",
name: "Daniel Kahneman",
title: "Nobel Laureate Psychologist",
bio: "Autor von 'Thinking, Fast and Slow'",
talkCount: 6,
totalViews: "8M+",
topics: ["Psychology", "Decision Making", "Economics"],
imageUrl: null
},
{
id: "yuval-noah-harari",
name: "Yuval Noah Harari",
title: "Historian & Author",
bio: "Autor von 'Sapiens' und 'Homo Deus'",
talkCount: 9,
totalViews: "20M+",
topics: ["History", "Future", "Technology"],
imageUrl: null
},
{
id: "malcolm-gladwell",
name: "Malcolm Gladwell",
title: "Author & Journalist",
bio: "Bestselling Author und New Yorker Staff Writer",
talkCount: 7,
totalViews: "12M+",
topics: ["Social Science", "Psychology", "Success"],
imageUrl: null
}
];
const featuredSpeakers = speakers.filter(s => s.featured);
const allSpeakers = speakers.sort((a, b) => a.name.localeCompare(b.name));
---
<!DOCTYPE html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sprecher | YouTube Wisdom Library</title>
<meta name="description" content="Entdecken Sie führende Denker und ihre inspirierenden Vorträge in unserer kuratierten Sammlung.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Hero Section -->
<section class="bg-gradient-to-br from-theme-primary/10 to-theme-secondary/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Führende Denker & Sprecher
</h1>
<p class="text-xl text-theme-text-muted max-w-3xl mx-auto">
Entdecken Sie die brillantesten Köpfe unserer Zeit und ihre revolutionären Ideen,
die die Art und Weise verändern, wie wir denken und handeln.
</p>
</div>
</section>
<!-- Featured Speakers -->
<section class="py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold text-theme-text mb-8">
Featured Speakers
</h2>
<div class="grid md:grid-cols-3 gap-8">
{featuredSpeakers.map(speaker => (
<a
href={`/speakers/${speaker.id}`}
class="group bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 hover:shadow-xl"
>
<div class="flex items-start gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
{speaker.imageUrl ? (
<img
src={speaker.imageUrl}
alt={speaker.name}
class="w-20 h-20 rounded-full object-cover"
/>
) : (
<div class="w-20 h-20 rounded-full bg-gradient-to-br from-theme-primary to-theme-secondary flex items-center justify-center">
<span class="text-2xl text-white font-bold">
{speaker.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
)}
</div>
<!-- Info -->
<div class="flex-1">
<h3 class="text-xl font-semibold text-theme-text group-hover:text-theme-primary transition-colors mb-1">
{speaker.name}
</h3>
<p class="text-sm text-theme-primary mb-2">
{speaker.title}
</p>
<p class="text-theme-text-muted text-sm mb-3">
{speaker.bio}
</p>
<!-- Stats -->
<div class="flex items-center gap-4 text-xs text-theme-text-muted mb-3">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{speaker.talkCount} Talks
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{speaker.totalViews}
</span>
</div>
<!-- Topics -->
<div class="flex flex-wrap gap-1">
{speaker.topics.map(topic => (
<span class="inline-block bg-theme-primary/10 text-theme-primary text-xs px-2 py-1 rounded-full">
{topic}
</span>
))}
</div>
</div>
</div>
</a>
))}
</div>
</div>
</section>
<!-- All Speakers Grid -->
<section class="py-12 bg-theme-card/30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold text-theme-text">
Alle Sprecher
</h2>
<!-- Search/Filter -->
<div class="flex gap-4">
<input
type="text"
placeholder="Sprecher suchen..."
class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2 w-64"
/>
<select class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2">
<option>Alle Themen</option>
<option>Psychology</option>
<option>Leadership</option>
<option>Technology</option>
<option>Economics</option>
<option>Innovation</option>
</select>
</div>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{allSpeakers.map(speaker => (
<a
href={`/speakers/${speaker.id}`}
class="group bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300"
>
<div class="flex items-center gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
{speaker.imageUrl ? (
<img
src={speaker.imageUrl}
alt={speaker.name}
class="w-16 h-16 rounded-full object-cover"
/>
) : (
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-theme-primary/50 to-theme-secondary/50 flex items-center justify-center">
<span class="text-xl text-white font-bold">
{speaker.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
)}
</div>
<!-- Info -->
<div class="flex-1">
<h3 class="text-lg font-semibold text-theme-text group-hover:text-theme-primary transition-colors">
{speaker.name}
</h3>
<p class="text-sm text-theme-text-muted mb-2">
{speaker.bio}
</p>
<div class="flex items-center gap-3 text-xs text-theme-text-muted">
<span>{speaker.talkCount} Talks</span>
<span>•</span>
<span>{speaker.totalViews} Views</span>
</div>
</div>
</div>
</a>
))}
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl font-bold text-theme-text mb-4">
Sprecher vorschlagen
</h2>
<p class="text-theme-text-muted mb-8">
Vermissen Sie einen wichtigen Denker in unserer Sammlung?
Lassen Sie es uns wissen!
</p>
<button class="bg-theme-primary text-white px-8 py-3 rounded-lg hover:opacity-90 transition-opacity">
Sprecher vorschlagen
</button>
</div>
</section>
<Footer />
<script>
// Simple search functionality
const searchInput = document.querySelector('input[type="text"]');
const speakerCards = document.querySelectorAll('.grid a');
searchInput?.addEventListener('input', (e) => {
const searchTerm = (e.target as HTMLInputElement).value.toLowerCase();
speakerCards.forEach(card => {
const text = card.textContent?.toLowerCase() || '';
if (text.includes(searchTerm)) {
(card as HTMLElement).style.display = 'block';
} else {
(card as HTMLElement).style.display = 'none';
}
});
});
</script>
</body>
</html>

View file

@ -1,658 +0,0 @@
---
import Navigation from '../../../components/Navigation.astro';
import Footer from '../../../components/Footer.astro';
import ThemeSwitcher from '../../../components/ThemeSwitcher.astro';
import '../../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Get all talks for this speaker from content collection
const allTalks = await Astro.glob('../../../content/talks/*.md');
const speakerTalks = allTalks.filter(talk =>
talk.frontmatter.speakerId === 'rory-sutherland'
);
const speakerData = {
name: "Rory Sutherland",
title: "Vice Chairman",
company: "Ogilvy UK"
};
// Process talks data combining transcript and analysis
const combinedData = speakerTalks.map(talk => {
const content = talk.compiledContent();
// Split content at transcript marker
const transcriptMarker = '## 📜 Full Transcript';
const [analysisContent, transcriptContent] = content.split(transcriptMarker);
return {
id: talk.frontmatter.speakerId + '-' + talk.frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
title: talk.frontmatter.title,
date: talk.frontmatter.date,
category: talk.frontmatter.category,
tags: talk.frontmatter.tags || [],
venue: talk.frontmatter.venue,
duration: talk.frontmatter.duration,
summary: talk.frontmatter.summary,
year: new Date(talk.frontmatter.date).getFullYear(),
readingTime: talk.frontmatter.readingTime || 0,
analysisContent: analysisContent.trim(),
transcriptContent: transcriptContent ? transcriptContent.trim() : '',
fullContent: content
};
});
// Get unique filter options
const categories = [...new Set(combinedData.map(talk => talk.category))];
const years = [...new Set(combinedData.map(talk => talk.year))].sort((a, b) => b - a);
const venues = [...new Set(combinedData.map(talk => talk.venue))];
const allTags = [...new Set(combinedData.flatMap(talk => talk.tags))];
---
<!DOCTYPE html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{speakerData.name} - Komplette Sammlung | YouTube Wisdom Library</title>
<meta name="description" content="Alle Inhalte von {speakerData.name} auf einer Seite - Transkripte, Analysen und Insights kombiniert.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Header -->
<div class="bg-gradient-to-br from-theme-accent/10 to-theme-primary/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Komplette {speakerData.name} Sammlung
</h1>
<p class="text-xl text-theme-text-muted mb-8">
{combinedData.length} Vorträge • Analysen + Transkripte kombiniert
</p>
<!-- Content Type Toggle -->
<div class="flex flex-wrap justify-center gap-2 mb-8">
<button class="content-toggle active bg-theme-accent text-white px-6 py-3 rounded-lg transition-all font-semibold" data-content="both">
🔄 Analyse + Transkript
</button>
<button class="content-toggle bg-theme-card border border-theme-border/30 px-6 py-3 rounded-lg hover:bg-theme-primary/10 transition-all font-semibold" data-content="analysis">
📊 Nur Analysen
</button>
<button class="content-toggle bg-theme-card border border-theme-border/30 px-6 py-3 rounded-lg hover:bg-theme-primary/10 transition-all font-semibold" data-content="transcript">
📜 Nur Transkripte
</button>
</div>
<!-- Bulk Actions -->
<div class="flex flex-wrap justify-center gap-4">
<button
id="copyAllContent"
class="bg-theme-accent text-white px-6 py-3 rounded-lg hover:bg-theme-accent-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📋</span> Alles kopieren
</button>
<button
id="copyFilteredContent"
class="bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold flex items-center gap-2"
style="display: none;"
>
<span>🎯</span> Gefiltert kopieren
</button>
<div class="text-sm text-theme-text-muted">
Aktuelle Auswahl: <span id="currentSelection">Analyse + Transkript</span>
• <span id="totalWords">{combinedData.reduce((sum, talk) => sum + talk.readingTime * 250, 0).toLocaleString()}</span> Wörter geschätzt
</div>
</div>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-theme-card/30 py-8 sticky top-0 z-40 backdrop-blur-sm border-b border-theme-border/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
<h2 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<span>🔍</span> Filter & Suche
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Volltext-Suche</label>
<input
type="text"
id="searchInput"
placeholder="Suche in allen Inhalten..."
class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50 focus:border-transparent"
>
</div>
<!-- Year Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Jahr</label>
<select id="yearFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Jahre</option>
{years.map(year => (
<option value={year}>{year}</option>
))}
</select>
</div>
<!-- Category Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Kategorie</label>
<select id="categoryFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Kategorien</option>
{categories.map(category => (
<option value={category}>{category}</option>
))}
</select>
</div>
<!-- Venue Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Venue</label>
<select id="venueFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Venues</option>
{venues.map(venue => (
<option value={venue}>{venue}</option>
))}
</select>
</div>
</div>
<!-- Tags Filter -->
<div class="mb-4">
<label class="block text-sm font-medium text-theme-text-muted mb-2">Tags</label>
<div class="flex flex-wrap gap-2" id="tagsContainer">
{allTags.map(tag => (
<button
class="tag-filter px-3 py-1 text-sm bg-theme-background border border-theme-border/30 rounded-full hover:bg-theme-primary/10 hover:border-theme-primary/50 transition-all"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap gap-2">
<button class="quick-filter px-4 py-2 text-sm bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all" data-preset="recent">
🕒 Letzte 2 Jahre
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all" data-preset="long">
📖 Lange Vorträge (>15 Min)
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all" data-preset="psychology">
🧠 Psychology Tags
</button>
<button id="clearFilters" class="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-all">
❌ Filter zurücksetzen
</button>
</div>
<!-- Results Counter -->
<div class="mt-4 pt-4 border-t border-theme-border/20">
<div class="text-sm text-theme-text-muted">
<span id="resultsCount">{combinedData.length}</span> von {combinedData.length} Vorträgen angezeigt
• <span id="filteredWords">-</span> Wörter geschätzt
</div>
</div>
</div>
</div>
</div>
<!-- Combined Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="contentContainer" class="space-y-12">
{combinedData.map(item => (
<article
class="content-item bg-theme-card rounded-xl p-8 border border-theme-border/20 shadow-sm"
data-title={item.title.toLowerCase()}
data-year={item.year}
data-category={item.category}
data-venue={item.venue}
data-tags={JSON.stringify(item.tags)}
data-reading-time={item.readingTime}
>
<!-- Talk Header -->
<div class="border-b border-theme-border/20 pb-6 mb-8">
<div class="flex justify-between items-start mb-4">
<h2 class="text-3xl font-bold text-theme-text">
{item.title}
</h2>
<button
class="copy-single-content bg-theme-accent/10 text-theme-accent px-4 py-2 rounded-lg hover:bg-theme-accent/20 transition-all flex items-center gap-2 text-sm font-medium"
data-content-id={item.id}
>
<span>📋</span> Kopieren
</button>
</div>
<div class="flex flex-wrap gap-4 text-sm text-theme-text-muted mb-4">
<span class="flex items-center gap-1">
<span>📅</span> {new Date(item.date).toLocaleDateString('de-DE')}
</span>
<span class="flex items-center gap-1">
<span>🏛️</span> {item.venue}
</span>
<span class="flex items-center gap-1">
<span>⏱️</span> {item.duration}
</span>
<span class="flex items-center gap-1">
<span>📖</span> {item.readingTime} Min Lesezeit
</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
{item.tags.map(tag => (
<span class="px-2 py-1 text-xs bg-theme-primary/10 text-theme-primary rounded-full">
{tag}
</span>
))}
</div>
<!-- Summary Preview -->
<div class="bg-theme-background/50 rounded-lg p-4">
<p class="text-theme-text-muted italic">
{item.summary}
</p>
</div>
</div>
<!-- Content Sections -->
<div class="content-sections">
<!-- Both Analysis and Transcript (default) -->
<div class="content-type both-content">
<div class="grid lg:grid-cols-2 gap-8">
<!-- Analysis Column -->
<div class="analysis-column">
<div class="sticky top-32">
<div class="bg-theme-secondary/5 rounded-lg p-6 border border-theme-secondary/20">
<h3 class="text-xl font-bold text-theme-text mb-4 flex items-center gap-2">
<span>📊</span> Analyse & Insights
</h3>
<div class="prose prose-sm max-w-none text-theme-text" set:html={item.analysisContent} />
</div>
</div>
</div>
<!-- Transcript Column -->
<div class="transcript-column">
<div class="bg-theme-primary/5 rounded-lg p-6 border border-theme-primary/20">
<h3 class="text-xl font-bold text-theme-text mb-4 flex items-center gap-2">
<span>📜</span> Vollständiges Transkript
</h3>
<div class="prose prose-sm max-w-none text-theme-text" set:html={item.transcriptContent} />
</div>
</div>
</div>
</div>
<!-- Analysis Only -->
<div class="content-type analysis-only" style="display: none;">
<div class="bg-theme-secondary/5 rounded-lg p-6 border border-theme-secondary/20">
<h3 class="text-xl font-bold text-theme-text mb-6 flex items-center gap-2">
<span>📊</span> Analyse & Insights
</h3>
<div class="prose prose-lg max-w-none text-theme-text" set:html={item.analysisContent} />
</div>
</div>
<!-- Transcript Only -->
<div class="content-type transcript-only" style="display: none;">
<div class="bg-theme-primary/5 rounded-lg p-6 border border-theme-primary/20">
<h3 class="text-xl font-bold text-theme-text mb-6 flex items-center gap-2">
<span>📜</span> Vollständiges Transkript
</h3>
<div class="prose prose-lg max-w-none text-theme-text" set:html={item.transcriptContent} />
</div>
</div>
</div>
</article>
))}
</div>
<!-- No Results -->
<div id="noResults" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">🔍</div>
<h3 class="text-xl font-semibold text-theme-text mb-2">Keine Ergebnisse gefunden</h3>
<p class="text-theme-text-muted">Versuche andere Filter oder Suchbegriffe</p>
</div>
</div>
<Footer />
<script>
// Store all content data for filtering
const contentData = {JSON.stringify(combinedData)};
let filteredContent = [...contentData];
let activeContentType = 'both';
let activeFilters = {
search: '',
year: '',
category: '',
venue: '',
tags: []
};
// DOM Elements
const searchInput = document.getElementById('searchInput');
const yearFilter = document.getElementById('yearFilter');
const categoryFilter = document.getElementById('categoryFilter');
const venueFilter = document.getElementById('venueFilter');
const tagsContainer = document.getElementById('tagsContainer');
const contentContainer = document.getElementById('contentContainer');
const resultsCount = document.getElementById('resultsCount');
const filteredWords = document.getElementById('filteredWords');
const totalWords = document.getElementById('totalWords');
const currentSelection = document.getElementById('currentSelection');
const noResults = document.getElementById('noResults');
const copyAllBtn = document.getElementById('copyAllContent');
const copyFilteredBtn = document.getElementById('copyFilteredContent');
const clearFiltersBtn = document.getElementById('clearFilters');
// Content Type Management
function switchContentType(contentType) {
activeContentType = contentType;
// Update toggle appearance
document.querySelectorAll('.content-toggle').forEach(toggle => {
if (toggle.dataset.content === contentType) {
toggle.classList.add('active', 'bg-theme-accent', 'text-white');
toggle.classList.remove('bg-theme-card', 'border');
} else {
toggle.classList.remove('active', 'bg-theme-accent', 'text-white');
toggle.classList.add('bg-theme-card', 'border', 'border-theme-border/30');
}
});
// Show/hide content sections
document.querySelectorAll('.content-type').forEach(section => {
if ((contentType === 'both' && section.classList.contains('both-content')) ||
(contentType === 'analysis' && section.classList.contains('analysis-only')) ||
(contentType === 'transcript' && section.classList.contains('transcript-only'))) {
section.style.display = 'block';
} else {
section.style.display = 'none';
}
});
// Update selection text
const selectionNames = {
'both': 'Analyse + Transkript',
'analysis': 'Nur Analysen',
'transcript': 'Nur Transkripte'
};
currentSelection.textContent = selectionNames[contentType];
updateWordCount();
}
function updateWordCount() {
const visibleContent = filteredContent.length > 0 ? filteredContent : contentData;
let wordCount = 0;
visibleContent.forEach(item => {
switch(activeContentType) {
case 'both':
wordCount += (item.readingTime * 250); // Estimate words for full content
break;
case 'analysis':
wordCount += (item.readingTime * 100); // Estimate words for analysis only
break;
case 'transcript':
wordCount += (item.readingTime * 150); // Estimate words for transcript only
break;
}
});
filteredWords.textContent = wordCount.toLocaleString();
}
// Filter Functions
function applyFilters() {
const contentItems = document.querySelectorAll('.content-item');
let visibleCount = 0;
filteredContent = contentData.filter(item => {
// Search filter
if (activeFilters.search) {
const searchLower = activeFilters.search.toLowerCase();
const matchesTitle = item.title.toLowerCase().includes(searchLower);
const matchesAnalysis = item.analysisContent.toLowerCase().includes(searchLower);
const matchesTranscript = item.transcriptContent.toLowerCase().includes(searchLower);
const matchesSummary = item.summary.toLowerCase().includes(searchLower);
if (!matchesTitle && !matchesAnalysis && !matchesTranscript && !matchesSummary) return false;
}
// Year filter
if (activeFilters.year && item.year != activeFilters.year) return false;
// Category filter
if (activeFilters.category && item.category !== activeFilters.category) return false;
// Venue filter
if (activeFilters.venue && item.venue !== activeFilters.venue) return false;
// Tags filter
if (activeFilters.tags.length > 0) {
const hasTag = activeFilters.tags.some(tag => item.tags.includes(tag));
if (!hasTag) return false;
}
return true;
});
// Show/hide content items
contentItems.forEach(item => {
const contentId = item.querySelector('.copy-single-content').dataset.contentId;
const isVisible = filteredContent.some(content => content.id === contentId);
if (isVisible) {
item.style.display = 'block';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update counters
resultsCount.textContent = visibleCount;
updateWordCount();
// Show/hide no results
if (visibleCount === 0) {
noResults.style.display = 'block';
contentContainer.style.display = 'none';
} else {
noResults.style.display = 'none';
contentContainer.style.display = 'block';
}
// Show/hide filtered copy button
const hasActiveFilters = Object.values(activeFilters).some(filter =>
Array.isArray(filter) ? filter.length > 0 : filter !== ''
);
copyFilteredBtn.style.display = hasActiveFilters ? 'inline-flex' : 'none';
}
// Event Listeners
searchInput.addEventListener('input', (e) => {
activeFilters.search = e.target.value;
applyFilters();
});
yearFilter.addEventListener('change', (e) => {
activeFilters.year = e.target.value;
applyFilters();
});
categoryFilter.addEventListener('change', (e) => {
activeFilters.category = e.target.value;
applyFilters();
});
venueFilter.addEventListener('change', (e) => {
activeFilters.venue = e.target.value;
applyFilters();
});
// Content type toggles
document.querySelectorAll('.content-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
switchContentType(e.target.dataset.content);
});
});
// Tag filters
tagsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-filter')) {
const tag = e.target.dataset.tag;
if (activeFilters.tags.includes(tag)) {
activeFilters.tags = activeFilters.tags.filter(t => t !== tag);
e.target.classList.remove('active');
} else {
activeFilters.tags.push(tag);
e.target.classList.add('active');
}
applyFilters();
}
});
// Quick filters
document.querySelectorAll('.quick-filter').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.dataset.preset;
switch(preset) {
case 'recent':
clearAllFilters();
activeFilters.year = new Date().getFullYear();
yearFilter.value = activeFilters.year;
applyFilters();
break;
case 'long':
clearAllFilters();
// Filter for talks longer than 15 minutes reading time
filteredContent = contentData.filter(item => item.readingTime > 15);
applyFilters();
break;
case 'psychology':
clearAllFilters();
activeFilters.tags = ['psychology'];
document.querySelector('[data-tag="psychology"]')?.classList.add('active');
applyFilters();
break;
}
});
});
// Clear filters
clearFiltersBtn.addEventListener('click', clearAllFilters);
function clearAllFilters() {
activeFilters = { search: '', year: '', category: '', venue: '', tags: [] };
searchInput.value = '';
yearFilter.value = '';
categoryFilter.value = '';
venueFilter.value = '';
document.querySelectorAll('.tag-filter.active').forEach(tag => {
tag.classList.remove('active');
});
applyFilters();
}
// Copy functions
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
function showCopyFeedback(button, success = true) {
const originalText = button.innerHTML;
button.innerHTML = success ? '<span>✅</span> Kopiert!' : '<span>❌</span> Fehler';
button.disabled = true;
setTimeout(() => {
button.innerHTML = originalText;
button.disabled = false;
}, 2000);
}
function getContentByType(item) {
switch(activeContentType) {
case 'analysis':
return `# ${item.title}\\n\\nDatum: ${new Date(item.date).toLocaleDateString('de-DE')}\\nVenue: ${item.venue}\\nDauer: ${item.duration}\\n\\n${item.analysisContent}`;
case 'transcript':
return `# ${item.title}\\n\\nDatum: ${new Date(item.date).toLocaleDateString('de-DE')}\\nVenue: ${item.venue}\\nDauer: ${item.duration}\\n\\n${item.transcriptContent}`;
default:
return `# ${item.title}\\n\\nDatum: ${new Date(item.date).toLocaleDateString('de-DE')}\\nVenue: ${item.venue}\\nDauer: ${item.duration}\\n\\n${item.fullContent}`;
}
}
// Copy all content
copyAllBtn.addEventListener('click', async (e) => {
const allContent = contentData.map(item =>
`${getContentByType(item)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(allContent);
showCopyFeedback(e.target, success);
});
// Copy filtered content
copyFilteredBtn.addEventListener('click', async (e) => {
const filteredContentText = filteredContent.map(item =>
`${getContentByType(item)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(filteredContentText);
showCopyFeedback(e.target, success);
});
// Copy single content
document.addEventListener('click', async (e) => {
if (e.target.closest('.copy-single-content')) {
const button = e.target.closest('.copy-single-content');
const contentId = button.dataset.contentId;
const item = contentData.find(c => c.id === contentId);
if (item) {
const content = getContentByType(item);
const success = await copyToClipboard(content);
showCopyFeedback(button, success);
}
}
});
// Add active styles
const style = document.createElement('style');
style.textContent = `
.tag-filter.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
.content-toggle.active {
background-color: var(--theme-accent) !important;
color: white !important;
border-color: var(--theme-accent) !important;
}
`;
document.head.appendChild(style);
// Initialize word count
updateWordCount();
</script>
</body>
</html>

View file

@ -1,650 +0,0 @@
---
import Navigation from '../../../components/Navigation.astro';
import Footer from '../../../components/Footer.astro';
import ThemeSwitcher from '../../../components/ThemeSwitcher.astro';
import '../../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Get all talks for this speaker from content collection
const allTalks = await Astro.glob('../../../content/talks/*.md');
const speakerTalks = allTalks.filter(talk =>
talk.frontmatter.speakerId === 'rory-sutherland'
);
const speakerData = {
name: "Rory Sutherland",
title: "Vice Chairman",
company: "Ogilvy UK"
};
// Extract analysis content (everything except transcript)
const analysesData = speakerTalks.map(talk => {
const content = talk.compiledContent();
// Split content at transcript marker
const transcriptMarker = '## 📜 Full Transcript';
const [analysisContent] = content.split(transcriptMarker);
// Extract different sections
const sections = {
summary: extractSection(analysisContent, '## Executive Summary'),
insights: extractSection(analysisContent, '## 🎯 Key Insights'),
quotes: extractSection(analysisContent, '## 💡 Memorable Quotes'),
concepts: extractSection(analysisContent, '## 📚 Core Concepts'),
chapters: extractSection(analysisContent, '## 🎬 Chapter Breakdown'),
takeaways: extractSection(analysisContent, '## 🚀 Practical Takeaways'),
related: extractSection(analysisContent, '## 🔗 Related Ideas'),
reflection: extractSection(analysisContent, '## 💭 Reflection Questions')
};
return {
id: talk.frontmatter.speakerId + '-' + talk.frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
title: talk.frontmatter.title,
date: talk.frontmatter.date,
category: talk.frontmatter.category,
tags: talk.frontmatter.tags || [],
venue: talk.frontmatter.venue,
duration: talk.frontmatter.duration,
summary: talk.frontmatter.summary,
year: new Date(talk.frontmatter.date).getFullYear(),
readingTime: talk.frontmatter.readingTime || 0,
fullAnalysis: analysisContent,
sections
};
});
// Helper function to extract sections
function extractSection(content, marker) {
const startIndex = content.indexOf(marker);
if (startIndex === -1) return '';
const afterMarker = content.substring(startIndex + marker.length);
const nextMarkerIndex = afterMarker.search(/##\s+[🎯💡📚🎬🚀🔗💭]/);
if (nextMarkerIndex === -1) {
return afterMarker.trim();
} else {
return afterMarker.substring(0, nextMarkerIndex).trim();
}
}
// Get unique filter options
const categories = [...new Set(analysesData.map(talk => talk.category))];
const years = [...new Set(analysesData.map(talk => talk.year))].sort((a, b) => b - a);
const venues = [...new Set(analysesData.map(talk => talk.venue))];
const allTags = [...new Set(analysesData.flatMap(talk => talk.tags))];
---
<!DOCTYPE html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{speakerData.name} - Alle Analysen & Insights | YouTube Wisdom Library</title>
<meta name="description" content="Alle Analysen, Insights und Zusammenfassungen von {speakerData.name} - kompakt und kopierbar.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Header -->
<div class="bg-gradient-to-br from-theme-secondary/10 to-theme-accent/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Alle Analysen von {speakerData.name}
</h1>
<p class="text-xl text-theme-text-muted mb-8">
{analysesData.length} Vorträge • Insights, Quotes & Zusammenfassungen
</p>
<!-- Content Type Tabs -->
<div class="flex flex-wrap justify-center gap-2 mb-8">
<button class="content-tab active bg-theme-primary text-white px-4 py-2 rounded-lg transition-all" data-content="all">
📊 Alle Inhalte
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="summary">
📝 Zusammenfassungen
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="insights">
💡 Key Insights
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="quotes">
💬 Zitate
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="takeaways">
🚀 Takeaways
</button>
</div>
<!-- Bulk Actions -->
<div class="flex flex-wrap justify-center gap-4">
<button
id="copyAllAnalyses"
class="bg-theme-secondary text-white px-6 py-3 rounded-lg hover:bg-theme-secondary-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📋</span> Alle Analysen kopieren
</button>
<button
id="copyFilteredAnalyses"
class="bg-theme-accent text-white px-6 py-3 rounded-lg hover:bg-theme-accent-dark transition-colors font-semibold flex items-center gap-2"
style="display: none;"
>
<span>🎯</span> Gefilterte Analysen kopieren
</button>
<button
id="copyCurrentContent"
class="bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📑</span> Aktuelle Auswahl kopieren
</button>
</div>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-theme-card/30 py-8 sticky top-0 z-40 backdrop-blur-sm border-b border-theme-border/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
<h2 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<span>🔍</span> Filter & Suche
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Volltext-Suche</label>
<input
type="text"
id="searchInput"
placeholder="Suche in allen Analysen..."
class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50 focus:border-transparent"
>
</div>
<!-- Year Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Jahr</label>
<select id="yearFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Jahre</option>
{years.map(year => (
<option value={year}>{year}</option>
))}
</select>
</div>
<!-- Category Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Kategorie</label>
<select id="categoryFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Kategorien</option>
{categories.map(category => (
<option value={category}>{category}</option>
))}
</select>
</div>
<!-- Venue Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Venue</label>
<select id="venueFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Venues</option>
{venues.map(venue => (
<option value={venue}>{venue}</option>
))}
</select>
</div>
</div>
<!-- Tags Filter -->
<div class="mb-4">
<label class="block text-sm font-medium text-theme-text-muted mb-2">Tags</label>
<div class="flex flex-wrap gap-2" id="tagsContainer">
{allTags.map(tag => (
<button
class="tag-filter px-3 py-1 text-sm bg-theme-background border border-theme-border/30 rounded-full hover:bg-theme-primary/10 hover:border-theme-primary/50 transition-all"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap gap-2">
<button class="quick-filter px-4 py-2 text-sm bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all" data-preset="recent">
🕒 Letzte 2 Jahre
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all" data-preset="insights">
💡 Nur Key Insights
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all" data-preset="quotes">
💬 Nur Zitate
</button>
<button id="clearFilters" class="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-all">
❌ Filter zurücksetzen
</button>
</div>
<!-- Results Counter -->
<div class="mt-4 pt-4 border-t border-theme-border/20">
<div class="text-sm text-theme-text-muted">
<span id="resultsCount">{analysesData.length}</span> von {analysesData.length} Analysen angezeigt
• Aktuelle Auswahl: <span id="currentContentType">Alle Inhalte</span>
</div>
</div>
</div>
</div>
</div>
<!-- Analyses Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="analysesContainer" class="space-y-8">
{analysesData.map(analysis => (
<article
class="analysis-item bg-theme-card rounded-xl p-8 border border-theme-border/20 shadow-sm"
data-title={analysis.title.toLowerCase()}
data-year={analysis.year}
data-category={analysis.category}
data-venue={analysis.venue}
data-tags={JSON.stringify(analysis.tags)}
data-reading-time={analysis.readingTime}
>
<!-- Talk Header -->
<div class="border-b border-theme-border/20 pb-6 mb-6">
<div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-bold text-theme-text">
{analysis.title}
</h2>
<button
class="copy-single-analysis bg-theme-secondary/10 text-theme-secondary px-4 py-2 rounded-lg hover:bg-theme-secondary/20 transition-all flex items-center gap-2 text-sm font-medium"
data-analysis-id={analysis.id}
>
<span>📋</span> Kopieren
</button>
</div>
<div class="flex flex-wrap gap-4 text-sm text-theme-text-muted mb-4">
<span class="flex items-center gap-1">
<span>📅</span> {new Date(analysis.date).toLocaleDateString('de-DE')}
</span>
<span class="flex items-center gap-1">
<span>🏛️</span> {analysis.venue}
</span>
<span class="flex items-center gap-1">
<span>⏱️</span> {analysis.duration}
</span>
<span class="flex items-center gap-1">
<span>📖</span> {analysis.readingTime} Min Lesezeit
</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
{analysis.tags.map(tag => (
<span class="px-2 py-1 text-xs bg-theme-primary/10 text-theme-primary rounded-full">
{tag}
</span>
))}
</div>
<!-- Summary Preview -->
<div class="bg-theme-background/50 rounded-lg p-4">
<p class="text-theme-text-muted italic">
{analysis.summary}
</p>
</div>
</div>
<!-- Analysis Content -->
<div class="analysis-content">
<!-- All Content (default) -->
<div class="content-section" data-section="all">
<div class="prose prose-lg max-w-none" set:html={analysis.fullAnalysis} />
</div>
<!-- Summary Only -->
<div class="content-section" data-section="summary" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.summary} />
</div>
<!-- Insights Only -->
<div class="content-section" data-section="insights" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.insights} />
</div>
<!-- Quotes Only -->
<div class="content-section" data-section="quotes" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.quotes} />
</div>
<!-- Takeaways Only -->
<div class="content-section" data-section="takeaways" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.takeaways} />
</div>
</div>
</article>
))}
</div>
<!-- No Results -->
<div id="noResults" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">🔍</div>
<h3 class="text-xl font-semibold text-theme-text mb-2">Keine Ergebnisse gefunden</h3>
<p class="text-theme-text-muted">Versuche andere Filter oder Suchbegriffe</p>
</div>
</div>
<Footer />
<script>
// Store all analyses data for filtering
const analysesData = {JSON.stringify(analysesData)};
let filteredAnalyses = [...analysesData];
let activeContentType = 'all';
let activeFilters = {
search: '',
year: '',
category: '',
venue: '',
tags: []
};
// DOM Elements
const searchInput = document.getElementById('searchInput');
const yearFilter = document.getElementById('yearFilter');
const categoryFilter = document.getElementById('categoryFilter');
const venueFilter = document.getElementById('venueFilter');
const tagsContainer = document.getElementById('tagsContainer');
const analysesContainer = document.getElementById('analysesContainer');
const resultsCount = document.getElementById('resultsCount');
const currentContentType = document.getElementById('currentContentType');
const noResults = document.getElementById('noResults');
const copyAllBtn = document.getElementById('copyAllAnalyses');
const copyFilteredBtn = document.getElementById('copyFilteredAnalyses');
const copyCurrentBtn = document.getElementById('copyCurrentContent');
const clearFiltersBtn = document.getElementById('clearFilters');
// Content Type Management
function switchContentType(contentType) {
activeContentType = contentType;
// Update tab appearance
document.querySelectorAll('.content-tab').forEach(tab => {
if (tab.dataset.content === contentType) {
tab.classList.add('active', 'bg-theme-primary', 'text-white');
tab.classList.remove('bg-theme-card', 'border');
} else {
tab.classList.remove('active', 'bg-theme-primary', 'text-white');
tab.classList.add('bg-theme-card', 'border', 'border-theme-border/30');
}
});
// Show/hide content sections
document.querySelectorAll('.content-section').forEach(section => {
if (section.dataset.section === contentType) {
section.style.display = 'block';
} else {
section.style.display = 'none';
}
});
// Update counter text
const contentTypeNames = {
'all': 'Alle Inhalte',
'summary': 'Zusammenfassungen',
'insights': 'Key Insights',
'quotes': 'Zitate',
'takeaways': 'Takeaways'
};
currentContentType.textContent = contentTypeNames[contentType];
}
// Filter Functions
function applyFilters() {
const analysisItems = document.querySelectorAll('.analysis-item');
let visibleCount = 0;
filteredAnalyses = analysesData.filter(analysis => {
// Search filter
if (activeFilters.search) {
const searchLower = activeFilters.search.toLowerCase();
const matchesTitle = analysis.title.toLowerCase().includes(searchLower);
const matchesAnalysis = analysis.fullAnalysis.toLowerCase().includes(searchLower);
const matchesSummary = analysis.summary.toLowerCase().includes(searchLower);
if (!matchesTitle && !matchesAnalysis && !matchesSummary) return false;
}
// Year filter
if (activeFilters.year && analysis.year != activeFilters.year) return false;
// Category filter
if (activeFilters.category && analysis.category !== activeFilters.category) return false;
// Venue filter
if (activeFilters.venue && analysis.venue !== activeFilters.venue) return false;
// Tags filter
if (activeFilters.tags.length > 0) {
const hasTag = activeFilters.tags.some(tag => analysis.tags.includes(tag));
if (!hasTag) return false;
}
return true;
});
// Show/hide analysis items
analysisItems.forEach(item => {
const analysisId = item.querySelector('.copy-single-analysis').dataset.analysisId;
const isVisible = filteredAnalyses.some(analysis => analysis.id === analysisId);
if (isVisible) {
item.style.display = 'block';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update counter
resultsCount.textContent = visibleCount;
// Show/hide no results
if (visibleCount === 0) {
noResults.style.display = 'block';
analysesContainer.style.display = 'none';
} else {
noResults.style.display = 'none';
analysesContainer.style.display = 'block';
}
// Show/hide filtered copy button
const hasActiveFilters = Object.values(activeFilters).some(filter =>
Array.isArray(filter) ? filter.length > 0 : filter !== ''
);
copyFilteredBtn.style.display = hasActiveFilters ? 'inline-flex' : 'none';
}
// Event Listeners
searchInput.addEventListener('input', (e) => {
activeFilters.search = e.target.value;
applyFilters();
});
yearFilter.addEventListener('change', (e) => {
activeFilters.year = e.target.value;
applyFilters();
});
categoryFilter.addEventListener('change', (e) => {
activeFilters.category = e.target.value;
applyFilters();
});
venueFilter.addEventListener('change', (e) => {
activeFilters.venue = e.target.value;
applyFilters();
});
// Content type tabs
document.querySelectorAll('.content-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
switchContentType(e.target.dataset.content);
});
});
// Tag filters
tagsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-filter')) {
const tag = e.target.dataset.tag;
if (activeFilters.tags.includes(tag)) {
activeFilters.tags = activeFilters.tags.filter(t => t !== tag);
e.target.classList.remove('active');
} else {
activeFilters.tags.push(tag);
e.target.classList.add('active');
}
applyFilters();
}
});
// Quick filters
document.querySelectorAll('.quick-filter').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.dataset.preset;
switch(preset) {
case 'recent':
clearAllFilters();
activeFilters.year = new Date().getFullYear();
yearFilter.value = activeFilters.year;
applyFilters();
break;
case 'insights':
switchContentType('insights');
break;
case 'quotes':
switchContentType('quotes');
break;
}
});
});
// Clear filters
clearFiltersBtn.addEventListener('click', clearAllFilters);
function clearAllFilters() {
activeFilters = { search: '', year: '', category: '', venue: '', tags: [] };
searchInput.value = '';
yearFilter.value = '';
categoryFilter.value = '';
venueFilter.value = '';
document.querySelectorAll('.tag-filter.active').forEach(tag => {
tag.classList.remove('active');
});
applyFilters();
}
// Copy functions
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
function showCopyFeedback(button, success = true) {
const originalText = button.innerHTML;
button.innerHTML = success ? '<span>✅</span> Kopiert!' : '<span>❌</span> Fehler';
button.disabled = true;
setTimeout(() => {
button.innerHTML = originalText;
button.disabled = false;
}, 2000);
}
function getContentByType(analysis, contentType) {
switch(contentType) {
case 'summary':
return `# ${analysis.title}\\n\\n${analysis.summary}\\n\\n${analysis.sections.summary}`;
case 'insights':
return `# ${analysis.title}\\n\\n${analysis.sections.insights}`;
case 'quotes':
return `# ${analysis.title}\\n\\n${analysis.sections.quotes}`;
case 'takeaways':
return `# ${analysis.title}\\n\\n${analysis.sections.takeaways}`;
default:
return `# ${analysis.title}\\n\\n${analysis.fullAnalysis}`;
}
}
// Copy all analyses
copyAllBtn.addEventListener('click', async (e) => {
const allAnalyses = analysesData.map(analysis =>
`${getContentByType(analysis, activeContentType)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(allAnalyses);
showCopyFeedback(e.target, success);
});
// Copy filtered analyses
copyFilteredBtn.addEventListener('click', async (e) => {
const filteredAnalysesContent = filteredAnalyses.map(analysis =>
`${getContentByType(analysis, activeContentType)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(filteredAnalysesContent);
showCopyFeedback(e.target, success);
});
// Copy current content selection
copyCurrentBtn.addEventListener('click', async (e) => {
const currentAnalyses = filteredAnalyses.length > 0 ? filteredAnalyses : analysesData;
const currentContent = currentAnalyses.map(analysis =>
`${getContentByType(analysis, activeContentType)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(currentContent);
showCopyFeedback(e.target, success);
});
// Copy single analysis
document.addEventListener('click', async (e) => {
if (e.target.closest('.copy-single-analysis')) {
const button = e.target.closest('.copy-single-analysis');
const analysisId = button.dataset.analysisId;
const analysis = analysesData.find(a => a.id === analysisId);
if (analysis) {
const content = getContentByType(analysis, activeContentType);
const success = await copyToClipboard(content);
showCopyFeedback(button, success);
}
}
});
// Add active styles for tag filters
const style = document.createElement('style');
style.textContent = `
.tag-filter.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
.content-tab.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View file

@ -1,488 +0,0 @@
---
import Navigation from '../../../components/Navigation.astro';
import Footer from '../../../components/Footer.astro';
import ThemeSwitcher from '../../../components/ThemeSwitcher.astro';
import '../../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Get all talks for this speaker from content collection
const allTalks = await Astro.glob('../../../content/talks/*.md');
const speakerTalks = allTalks.filter(talk =>
talk.frontmatter.speakerId === 'rory-sutherland'
);
const speakerData = {
name: "Rory Sutherland",
title: "Vice Chairman",
company: "Ogilvy UK"
};
// Process talks data for filtering
const talksData = speakerTalks.map(talk => ({
id: talk.frontmatter.speakerId + '-' + talk.frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
title: talk.frontmatter.title,
date: talk.frontmatter.date,
category: talk.frontmatter.category,
tags: talk.frontmatter.tags || [],
venue: talk.frontmatter.venue,
duration: talk.frontmatter.duration,
transcript: talk.compiledContent(),
year: new Date(talk.frontmatter.date).getFullYear(),
readingTime: talk.frontmatter.readingTime || 0
}));
// Get unique filter options
const categories = [...new Set(talksData.map(talk => talk.category))];
const years = [...new Set(talksData.map(talk => talk.year))].sort((a, b) => b - a);
const venues = [...new Set(talksData.map(talk => talk.venue))];
const allTags = [...new Set(talksData.flatMap(talk => talk.tags))];
---
<!DOCTYPE html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{speakerData.name} - Alle Transkripte | YouTube Wisdom Library</title>
<meta name="description" content="Alle Transkripte von {speakerData.name} auf einer Seite - durchsuchbar und komplett kopierbar.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Header -->
<div class="bg-gradient-to-br from-theme-primary/10 to-theme-secondary/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Alle Transkripte von {speakerData.name}
</h1>
<p class="text-xl text-theme-text-muted mb-8">
{talksData.length} Vorträge • Komplett durchsuchbar und kopierbar
</p>
<!-- Bulk Actions -->
<div class="flex flex-wrap justify-center gap-4">
<button
id="copyAllTranscripts"
class="bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📋</span> Alle Transkripte kopieren
</button>
<button
id="copyFilteredTranscripts"
class="bg-theme-secondary text-white px-6 py-3 rounded-lg hover:bg-theme-secondary-dark transition-colors font-semibold flex items-center gap-2"
style="display: none;"
>
<span>🎯</span> Gefilterte Transkripte kopieren
</button>
<div class="text-sm text-theme-text-muted mt-2">
Geschätzte Lesezeit: <span id="totalReadingTime">{talksData.reduce((sum, talk) => sum + talk.readingTime, 0)} Min</span>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-theme-card/30 py-8 sticky top-0 z-40 backdrop-blur-sm border-b border-theme-border/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
<h2 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<span>🔍</span> Filter & Suche
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Volltext-Suche</label>
<input
type="text"
id="searchInput"
placeholder="Suche in allen Transkripten..."
class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50 focus:border-transparent"
>
</div>
<!-- Year Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Jahr</label>
<select id="yearFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Jahre</option>
{years.map(year => (
<option value={year}>{year}</option>
))}
</select>
</div>
<!-- Category Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Kategorie</label>
<select id="categoryFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Kategorien</option>
{categories.map(category => (
<option value={category}>{category}</option>
))}
</select>
</div>
<!-- Venue Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Venue</label>
<select id="venueFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Venues</option>
{venues.map(venue => (
<option value={venue}>{venue}</option>
))}
</select>
</div>
</div>
<!-- Tags Filter -->
<div class="mb-4">
<label class="block text-sm font-medium text-theme-text-muted mb-2">Tags</label>
<div class="flex flex-wrap gap-2" id="tagsContainer">
{allTags.map(tag => (
<button
class="tag-filter px-3 py-1 text-sm bg-theme-background border border-theme-border/30 rounded-full hover:bg-theme-primary/10 hover:border-theme-primary/50 transition-all"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap gap-2">
<button class="quick-filter px-4 py-2 text-sm bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all" data-preset="recent">
🕒 Letzte 2 Jahre
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all" data-preset="popular">
🔥 Meist gelesen
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all" data-preset="long">
📖 Lange Vorträge
</button>
<button id="clearFilters" class="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-all">
❌ Filter zurücksetzen
</button>
</div>
<!-- Results Counter -->
<div class="mt-4 pt-4 border-t border-theme-border/20">
<div class="text-sm text-theme-text-muted">
<span id="resultsCount">{talksData.length}</span> von {talksData.length} Transkripten angezeigt
• <span id="filteredReadingTime">{talksData.reduce((sum, talk) => sum + talk.readingTime, 0)} Min</span> Lesezeit
</div>
</div>
</div>
</div>
</div>
<!-- Transcripts Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="transcriptsContainer" class="space-y-8">
{talksData.map(talk => (
<article
class="transcript-item bg-theme-card rounded-xl p-8 border border-theme-border/20 shadow-sm"
data-title={talk.title.toLowerCase()}
data-year={talk.year}
data-category={talk.category}
data-venue={talk.venue}
data-tags={JSON.stringify(talk.tags)}
data-reading-time={talk.readingTime}
>
<!-- Talk Header -->
<div class="border-b border-theme-border/20 pb-6 mb-6">
<div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-bold text-theme-text">
{talk.title}
</h2>
<button
class="copy-single-transcript bg-theme-primary/10 text-theme-primary px-4 py-2 rounded-lg hover:bg-theme-primary/20 transition-all flex items-center gap-2 text-sm font-medium"
data-transcript-id={talk.id}
>
<span>📋</span> Kopieren
</button>
</div>
<div class="flex flex-wrap gap-4 text-sm text-theme-text-muted">
<span class="flex items-center gap-1">
<span>📅</span> {new Date(talk.date).toLocaleDateString('de-DE')}
</span>
<span class="flex items-center gap-1">
<span>🏛️</span> {talk.venue}
</span>
<span class="flex items-center gap-1">
<span>⏱️</span> {talk.duration}
</span>
<span class="flex items-center gap-1">
<span>📖</span> {talk.readingTime} Min Lesezeit
</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
{talk.tags.map(tag => (
<span class="px-2 py-1 text-xs bg-theme-primary/10 text-theme-primary rounded-full">
{tag}
</span>
))}
</div>
</div>
<!-- Transcript Content -->
<div class="transcript-content prose prose-lg max-w-none" data-transcript-text={talk.transcript}>
<div set:html={talk.transcript} />
</div>
</article>
))}
</div>
<!-- No Results -->
<div id="noResults" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">🔍</div>
<h3 class="text-xl font-semibold text-theme-text mb-2">Keine Ergebnisse gefunden</h3>
<p class="text-theme-text-muted">Versuche andere Filter oder Suchbegriffe</p>
</div>
</div>
<Footer />
<script>
// Store all talks data for filtering
const talksData = {JSON.stringify(talksData)};
let filteredTalks = [...talksData];
let activeFilters = {
search: '',
year: '',
category: '',
venue: '',
tags: []
};
// DOM Elements
const searchInput = document.getElementById('searchInput');
const yearFilter = document.getElementById('yearFilter');
const categoryFilter = document.getElementById('categoryFilter');
const venueFilter = document.getElementById('venueFilter');
const tagsContainer = document.getElementById('tagsContainer');
const transcriptsContainer = document.getElementById('transcriptsContainer');
const resultsCount = document.getElementById('resultsCount');
const filteredReadingTime = document.getElementById('filteredReadingTime');
const noResults = document.getElementById('noResults');
const copyAllBtn = document.getElementById('copyAllTranscripts');
const copyFilteredBtn = document.getElementById('copyFilteredTranscripts');
const clearFiltersBtn = document.getElementById('clearFilters');
// Filter Functions
function applyFilters() {
const transcriptItems = document.querySelectorAll('.transcript-item');
let visibleCount = 0;
let totalReadingTime = 0;
filteredTalks = talksData.filter(talk => {
// Search filter
if (activeFilters.search) {
const searchLower = activeFilters.search.toLowerCase();
const matchesTitle = talk.title.toLowerCase().includes(searchLower);
const matchesTranscript = talk.transcript.toLowerCase().includes(searchLower);
if (!matchesTitle && !matchesTranscript) return false;
}
// Year filter
if (activeFilters.year && talk.year != activeFilters.year) return false;
// Category filter
if (activeFilters.category && talk.category !== activeFilters.category) return false;
// Venue filter
if (activeFilters.venue && talk.venue !== activeFilters.venue) return false;
// Tags filter
if (activeFilters.tags.length > 0) {
const hasTag = activeFilters.tags.some(tag => talk.tags.includes(tag));
if (!hasTag) return false;
}
return true;
});
// Show/hide transcript items
transcriptItems.forEach(item => {
const talkId = item.querySelector('.copy-single-transcript').dataset.transcriptId;
const isVisible = filteredTalks.some(talk => talk.id === talkId);
if (isVisible) {
item.style.display = 'block';
visibleCount++;
totalReadingTime += parseInt(item.dataset.readingTime) || 0;
} else {
item.style.display = 'none';
}
});
// Update counter
resultsCount.textContent = visibleCount;
filteredReadingTime.textContent = totalReadingTime;
// Show/hide no results
if (visibleCount === 0) {
noResults.style.display = 'block';
transcriptsContainer.style.display = 'none';
} else {
noResults.style.display = 'none';
transcriptsContainer.style.display = 'block';
}
// Show/hide filtered copy button
const hasActiveFilters = Object.values(activeFilters).some(filter =>
Array.isArray(filter) ? filter.length > 0 : filter !== ''
);
copyFilteredBtn.style.display = hasActiveFilters ? 'block' : 'none';
}
// Event Listeners
searchInput.addEventListener('input', (e) => {
activeFilters.search = e.target.value;
applyFilters();
});
yearFilter.addEventListener('change', (e) => {
activeFilters.year = e.target.value;
applyFilters();
});
categoryFilter.addEventListener('change', (e) => {
activeFilters.category = e.target.value;
applyFilters();
});
venueFilter.addEventListener('change', (e) => {
activeFilters.venue = e.target.value;
applyFilters();
});
// Tag filters
tagsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-filter')) {
const tag = e.target.dataset.tag;
if (activeFilters.tags.includes(tag)) {
activeFilters.tags = activeFilters.tags.filter(t => t !== tag);
e.target.classList.remove('active');
} else {
activeFilters.tags.push(tag);
e.target.classList.add('active');
}
applyFilters();
}
});
// Quick filters
document.querySelectorAll('.quick-filter').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.dataset.preset;
clearAllFilters();
switch(preset) {
case 'recent':
activeFilters.year = new Date().getFullYear();
yearFilter.value = activeFilters.year;
break;
case 'popular':
// Sort by reading time and show top ones
break;
case 'long':
// Filter talks longer than 15 minutes reading time
break;
}
applyFilters();
});
});
// Clear filters
clearFiltersBtn.addEventListener('click', clearAllFilters);
function clearAllFilters() {
activeFilters = { search: '', year: '', category: '', venue: '', tags: [] };
searchInput.value = '';
yearFilter.value = '';
categoryFilter.value = '';
venueFilter.value = '';
document.querySelectorAll('.tag-filter.active').forEach(tag => {
tag.classList.remove('active');
});
applyFilters();
}
// Copy functions
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
function showCopyFeedback(button, success = true) {
const originalText = button.innerHTML;
button.innerHTML = success ? '<span>✅</span> Kopiert!' : '<span>❌</span> Fehler';
button.disabled = true;
setTimeout(() => {
button.innerHTML = originalText;
button.disabled = false;
}, 2000);
}
// Copy all transcripts
copyAllBtn.addEventListener('click', async (e) => {
const allTranscripts = talksData.map(talk =>
`# ${talk.title}\nDatum: ${new Date(talk.date).toLocaleDateString('de-DE')}\nVenue: ${talk.venue}\nDauer: ${talk.duration}\n\n${talk.transcript}\n\n---\n\n`
).join('');
const success = await copyToClipboard(allTranscripts);
showCopyFeedback(e.target, success);
});
// Copy filtered transcripts
copyFilteredBtn.addEventListener('click', async (e) => {
const filteredTranscripts = filteredTalks.map(talk =>
`# ${talk.title}\nDatum: ${new Date(talk.date).toLocaleDateString('de-DE')}\nVenue: ${talk.venue}\nDauer: ${talk.duration}\n\n${talk.transcript}\n\n---\n\n`
).join('');
const success = await copyToClipboard(filteredTranscripts);
showCopyFeedback(e.target, success);
});
// Copy single transcript
document.addEventListener('click', async (e) => {
if (e.target.closest('.copy-single-transcript')) {
const button = e.target.closest('.copy-single-transcript');
const transcriptId = button.dataset.transcriptId;
const talk = talksData.find(t => t.id === transcriptId);
if (talk) {
const transcript = `# ${talk.title}\nDatum: ${new Date(talk.date).toLocaleDateString('de-DE')}\nVenue: ${talk.venue}\nDauer: ${talk.duration}\n\n${talk.transcript}`;
const success = await copyToClipboard(transcript);
showCopyFeedback(button, success);
}
}
});
// Add active styles for tag filters
const style = document.createElement('style');
style.textContent = `
.tag-filter.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View file

@ -1,658 +0,0 @@
---
import { getCollection } from 'astro:content';
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import CollapsibleSection from '../../components/CollapsibleSection.astro';
import '../../styles/themes.css';
export async function getStaticPaths() {
const talks = await getCollection('talks');
return talks.map((talk) => ({
params: { slug: talk.slug },
props: { talk },
}));
}
const { talk } = Astro.props;
const { Content } = await talk.render();
const currentPath = Astro.url.pathname;
// Parse the rendered content to extract sections
const contentHtml = await talk.render().then((result) => result.Content);
---
<!doctype html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
<style>
.article-header {
background: linear-gradient(
135deg,
rgba(var(--theme-primary), 0.1) 0%,
rgba(var(--theme-secondary), 0.05) 100%
);
padding: 4rem 0 3rem;
margin-bottom: 3rem;
position: relative;
overflow: hidden;
}
.article-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
pointer-events: none;
}
.article-container {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem;
position: relative;
z-index: 1;
}
.header-content {
text-align: center;
}
h1 {
color: rgb(var(--theme-primary));
font-size: 2.8rem;
margin-bottom: 1.5rem;
font-weight: 700;
line-height: 1.2;
}
.meta {
display: flex;
justify-content: center;
gap: 2.5rem;
color: rgb(var(--theme-text-muted));
margin-bottom: 1.5rem;
font-size: 1.1rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.speaker {
font-weight: 600;
text-decoration: none;
color: inherit;
transition: color 0.2s ease;
position: relative;
z-index: 10;
}
a.speaker {
cursor: pointer;
text-decoration: none;
}
a.speaker:hover {
color: rgb(var(--theme-primary));
text-decoration: underline;
}
.tags {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.5rem;
}
.tag {
background: rgb(var(--theme-card));
backdrop-filter: blur(10px);
padding: 0.4rem 1rem;
border-radius: 2rem;
font-size: 0.9rem;
color: rgb(var(--theme-primary));
border: 1px solid rgba(var(--theme-primary), 0.2);
transition: all 0.2s ease;
}
.tag:hover {
background: rgba(var(--theme-primary), 0.1);
transform: translateY(-2px);
}
.summary-card {
background: rgb(var(--theme-card));
padding: 2rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: 1.1rem;
line-height: 1.7;
color: rgb(var(--theme-text));
border-left: 4px solid rgb(var(--theme-primary));
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
color: rgb(var(--theme-primary));
text-decoration: none;
font-weight: 600;
transition: gap 0.2s ease;
}
.back-link:hover {
gap: 0.75rem;
}
.content-sections {
margin-top: 3rem;
}
/* Styles for content inside sections */
:global(.section-inner h3) {
color: rgb(var(--theme-primary));
margin: 1.5rem 0 1rem;
font-size: 1.3rem;
}
:global(.section-inner p) {
margin-bottom: 1.25rem;
line-height: 1.7;
color: rgb(var(--theme-text));
}
:global(.section-inner ul, .section-inner ol) {
margin: 1rem 0 1.5rem;
padding-left: 2rem;
}
:global(.section-inner li) {
margin-bottom: 0.75rem;
line-height: 1.7;
}
:global(.section-inner blockquote) {
background: rgba(var(--theme-primary), 0.05);
border-left: 4px solid rgb(var(--theme-primary));
padding: 1.25rem 1.5rem;
margin: 1.5rem 0;
border-radius: 0.5rem;
font-style: italic;
}
:global(.section-inner blockquote p) {
margin-bottom: 0;
}
:global(.section-inner strong) {
color: rgb(var(--theme-primary));
font-weight: 600;
}
:global(.section-inner hr) {
border: none;
border-top: 2px solid rgba(var(--theme-primary), 0.1);
margin: 2rem 0;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.meta {
flex-direction: column;
gap: 1rem;
}
.article-header {
padding: 3rem 0 2rem;
}
}
</style>
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<div class="article-header">
<div class="article-container">
<a href="/" class="back-link">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 12H5M5 12L12 19M5 12L12 5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
Zurück zur Übersicht
</a>
<div class="header-content">
<h1>{talk.data.title}</h1>
<div class="meta">
<div class="meta-item">
{
talk.data.speakerId ? (
<a
href={`/speakers/${talk.data.speakerId}`}
class="speaker hover:text-theme-primary transition-colors"
>
<span>🎤</span>
<span>{talk.data.speaker}</span>
</a>
) : (
<span class="speaker">
<span>🎤</span>
<span>{talk.data.speaker}</span>
</span>
)
}
</div>
<div class="meta-item">
<span>📅 {talk.data.venue}</span>
</div>
<div class="meta-item">
<span>⏱️ {talk.data.duration}</span>
</div>
</div>
<div class="tags">
{talk.data.tags.map((tag: string) => <span class="tag">{tag}</span>)}
</div>
</div>
</div>
</div>
<div class="article-container">
<div class="summary-card">
{talk.data.summary}
</div>
<div class="content-sections" id="content-wrapper">
<!-- Content will be dynamically organized into sections -->
<div style="display: none;" id="raw-content">
<Content />
</div>
</div>
</div>
<Footer />
<script>
document.addEventListener('DOMContentLoaded', () => {
const rawContent = document.getElementById('raw-content');
const wrapper = document.getElementById('content-wrapper');
if (!rawContent || !wrapper) return;
// Get all h2 elements and their content
const sections = [];
const elements = Array.from(rawContent.children);
let currentSection = null;
elements.forEach((element) => {
if (element.tagName === 'H2') {
// Start a new section
currentSection = {
title: element.textContent,
icon: getIconForSection(element.textContent),
defaultCollapsed:
element.textContent.includes('Transcript') ||
element.textContent.includes('Transkript'),
content: [],
};
sections.push(currentSection);
} else if (currentSection) {
currentSection.content.push(element.outerHTML);
}
});
// Create CollapsibleSection elements for each section
sections.forEach((section) => {
const sectionEl = document.createElement('div');
sectionEl.className = 'collapsible-section';
sectionEl.setAttribute(
'data-section-id',
section.title.toLowerCase().replace(/\s+/g, '-')
);
sectionEl.innerHTML = `
<button class="section-header" aria-expanded="${!section.defaultCollapsed}">
<span class="section-icon">${section.icon}</span>
<h2 class="section-title">${section.title}</h2>
<span class="section-arrow" data-collapsed="${section.defaultCollapsed}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</button>
<div class="section-content" data-collapsed="${section.defaultCollapsed}">
<div class="section-inner">
${section.content.join('')}
</div>
</div>
`;
wrapper.appendChild(sectionEl);
// Add click handler
const header = sectionEl.querySelector('.section-header');
const content = sectionEl.querySelector('.section-content');
const arrow = sectionEl.querySelector('.section-arrow');
const inner = content.querySelector('.section-inner');
// Set initial state first
if (section.defaultCollapsed) {
content.style.maxHeight = '0px';
content.style.overflow = 'hidden';
content.dataset.collapsed = 'true';
arrow.dataset.collapsed = 'true';
header.setAttribute('aria-expanded', 'false');
} else {
content.dataset.collapsed = 'false';
arrow.dataset.collapsed = 'false';
header.setAttribute('aria-expanded', 'true');
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
const height = inner.scrollHeight;
content.style.maxHeight = height + 'px';
}, 10);
}
header.addEventListener('click', () => {
const isCollapsed = content.dataset.collapsed === 'true';
if (isCollapsed) {
// Expanding
content.dataset.collapsed = 'false';
arrow.dataset.collapsed = 'false';
header.setAttribute('aria-expanded', 'true');
// First set to current height to enable transition
const targetHeight = inner.scrollHeight;
content.style.maxHeight = targetHeight + 'px';
content.style.overflow = 'hidden';
} else {
// Collapsing
// Get current height
const currentHeight = inner.scrollHeight;
// Set explicit height first
content.style.maxHeight = currentHeight + 'px';
content.style.overflow = 'hidden';
// Force browser to acknowledge the height
content.offsetHeight;
// Then animate to 0
requestAnimationFrame(() => {
content.dataset.collapsed = 'true';
arrow.dataset.collapsed = 'true';
header.setAttribute('aria-expanded', 'false');
content.style.maxHeight = '0px';
});
}
});
});
// Remove the raw content
rawContent.remove();
});
function getIconForSection(title) {
const lower = title.toLowerCase();
if (lower.includes('summary')) return '📋';
if (lower.includes('insight') || lower.includes('key')) return '🎯';
if (lower.includes('quote')) return '💬';
if (lower.includes('concept')) return '📚';
if (lower.includes('chapter') || lower.includes('breakdown')) return '🎬';
if (lower.includes('takeaway')) return '🚀';
if (lower.includes('related')) return '🔗';
if (lower.includes('reflection') || lower.includes('question')) return '💭';
if (lower.includes('transcript') || lower.includes('transkript')) return '📜';
return '📌';
}
</script>
<style>
.collapsible-section {
background: rgb(var(--theme-card));
border-radius: 1.2rem;
margin-bottom: 1.8rem;
overflow: hidden;
border: 1px solid rgba(var(--theme-primary), 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
position: relative;
}
.collapsible-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
rgba(var(--theme-primary), 0.3) 0%,
rgba(var(--theme-secondary), 0.2) 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
.collapsible-section:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
border-color: rgba(var(--theme-primary), 0.15);
transform: translateY(-2px);
}
.collapsible-section:hover::before {
opacity: 1;
}
.section-header {
width: 100%;
padding: 1.75rem 2rem;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 1.25rem;
text-align: left;
transition: all 0.2s ease;
position: relative;
}
.section-header::after {
content: '';
position: absolute;
bottom: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(var(--theme-primary), 0.1) 50%,
transparent 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
.section-content[data-collapsed='false'] + * .section-header::after,
.section-header:hover::after {
opacity: 1;
}
.section-header:hover {
background: linear-gradient(
135deg,
rgba(var(--theme-primary), 0.03) 0%,
rgba(var(--theme-secondary), 0.02) 100%
);
}
.section-icon {
font-size: 1.6rem;
flex-shrink: 0;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(var(--theme-primary), 0.1) 0%,
rgba(var(--theme-secondary), 0.08) 100%
);
border-radius: 0.75rem;
transition: all 0.3s ease;
}
.section-header:hover .section-icon {
transform: scale(1.1);
background: linear-gradient(
135deg,
rgba(var(--theme-primary), 0.15) 0%,
rgba(var(--theme-secondary), 0.12) 100%
);
}
.section-title {
flex: 1;
margin: 0;
font-size: 1.45rem;
font-weight: 600;
color: rgb(var(--theme-primary));
letter-spacing: -0.02em;
}
.section-arrow {
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: rgb(var(--theme-primary));
opacity: 0.7;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--theme-primary), 0.05);
border-radius: 50%;
}
.section-header:hover .section-arrow {
opacity: 1;
background: rgba(var(--theme-primary), 0.1);
}
.section-arrow[data-collapsed='true'] {
transform: rotate(-90deg);
}
.section-arrow svg {
width: 20px;
height: 20px;
}
.section-content {
overflow: hidden;
transition:
max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease;
}
.section-content[data-collapsed='false'] {
border-top: 1px solid rgba(var(--theme-primary), 0.06);
opacity: 1;
}
.section-content[data-collapsed='true'] {
max-height: 0 !important;
opacity: 0;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
}
.section-inner {
padding: 1.5rem 2rem 2rem 2rem;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Special styling for transcript section */
.collapsible-section[data-section-id*='transcript'] {
background: linear-gradient(
135deg,
rgba(var(--theme-card), 1) 0%,
rgba(var(--theme-primary), 0.02) 100%
);
}
.collapsible-section[data-section-id*='transcript'] .section-icon {
background: linear-gradient(
135deg,
rgba(var(--theme-secondary), 0.15) 0%,
rgba(var(--theme-primary), 0.1) 100%
);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.section-header {
padding: 1.5rem 1.5rem;
}
.section-inner {
padding: 1.25rem 1.5rem 1.5rem 1.5rem;
}
.section-title {
font-size: 1.25rem;
}
.section-icon {
width: 40px;
height: 40px;
font-size: 1.4rem;
}
}
</style>
</body>
</html>

View file

@ -1,286 +0,0 @@
---
import { getCollection } from 'astro:content';
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import '../../styles/themes.css';
export async function getStaticPaths() {
const talks = await getCollection('talks');
return talks.map((talk) => ({
params: { slug: talk.slug },
props: { talk },
}));
}
const { talk } = Astro.props;
const { Content } = await talk.render();
const currentPath = Astro.url.pathname;
---
<!doctype html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Alle h2 Überschriften im Content-Bereich finden
const contentDiv = document.querySelector('.content');
if (!contentDiv) return;
const headings = contentDiv.querySelectorAll('h2');
headings.forEach((heading) => {
// Toggle-Button hinzufügen
heading.style.cursor = 'pointer';
heading.style.userSelect = 'none';
heading.style.position = 'relative';
heading.style.paddingLeft = '30px';
// Pfeil-Icon hinzufügen
const arrow = document.createElement('span');
arrow.textContent = '▼';
arrow.style.position = 'absolute';
arrow.style.left = '0';
arrow.style.transition = 'transform 0.3s ease';
arrow.style.display = 'inline-block';
arrow.className = 'section-arrow';
heading.insertBefore(arrow, heading.firstChild);
// Alle Elemente zwischen dieser und der nächsten h2 sammeln
const content = [];
let sibling = heading.nextElementSibling;
while (sibling && sibling.tagName !== 'H2') {
content.push(sibling);
sibling = sibling.nextElementSibling;
}
// Container für den Inhalt erstellen
const contentWrapper = document.createElement('div');
contentWrapper.className = 'collapsible-content';
contentWrapper.style.overflow = 'hidden';
contentWrapper.style.transition = 'max-height 0.3s ease';
contentWrapper.style.maxHeight = 'none';
// Inhalt in den Wrapper verschieben
content.forEach((elem) => {
contentWrapper.appendChild(elem);
});
// Wrapper nach der Überschrift einfügen
heading.insertAdjacentElement('afterend', contentWrapper);
// Initial-Zustand: Transkript eingeklappt, andere ausgeklappt
const isTranscript =
heading.textContent.includes('Full Transcript') ||
heading.textContent.includes('Transkript');
if (isTranscript) {
contentWrapper.style.maxHeight = '0';
arrow.style.transform = 'rotate(-90deg)';
contentWrapper.dataset.collapsed = 'true';
} else {
// Höhe berechnen für ausgeklappten Zustand
contentWrapper.style.maxHeight = contentWrapper.scrollHeight + 'px';
contentWrapper.dataset.collapsed = 'false';
}
// Click-Handler
heading.addEventListener('click', () => {
const isCollapsed = contentWrapper.dataset.collapsed === 'true';
if (isCollapsed) {
contentWrapper.style.maxHeight = contentWrapper.scrollHeight + 'px';
arrow.style.transform = 'rotate(0deg)';
contentWrapper.dataset.collapsed = 'false';
} else {
contentWrapper.style.maxHeight = '0';
arrow.style.transform = 'rotate(-90deg)';
contentWrapper.dataset.collapsed = 'true';
}
});
});
});
</script>
<style>
.article-header {
background: rgb(var(--theme-card));
padding: 3rem 0;
margin-bottom: 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.article-container {
max-width: 800px;
margin: 0 auto;
padding: 0 2rem;
}
h1 {
color: rgb(var(--theme-primary));
font-size: 2.5rem;
margin-bottom: 1rem;
}
.meta {
display: flex;
gap: 2rem;
color: rgb(var(--theme-text-muted));
margin-bottom: 1rem;
}
.speaker {
font-weight: 600;
text-decoration: none;
color: inherit;
}
a.speaker:hover {
color: rgb(var(--theme-primary));
}
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.tag {
background: rgba(var(--theme-primary), 0.1);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
color: rgb(var(--theme-primary));
}
.content {
background: rgb(var(--theme-card));
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
margin-bottom: 3rem;
}
.content h2 {
color: rgb(var(--theme-primary));
margin-top: 2rem;
margin-bottom: 1rem;
font-size: 1.8rem;
transition: opacity 0.2s ease;
}
.content h2:hover {
opacity: 0.8;
}
.collapsible-content {
margin-bottom: 1.5rem;
}
.content h3 {
color: rgb(var(--theme-primary));
margin-top: 1.5rem;
margin-bottom: 0.8rem;
font-size: 1.3rem;
}
.content p {
margin-bottom: 1.5rem;
line-height: 1.7;
}
.content ul,
.content ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.content li {
margin-bottom: 0.5rem;
line-height: 1.7;
}
.content strong {
color: rgb(var(--theme-primary));
font-weight: 600;
}
.content blockquote {
background: rgba(var(--theme-primary), 0.05);
border-left: 4px solid rgb(var(--theme-primary));
padding: 1rem 2rem;
margin: 2rem 0;
font-style: italic;
}
.content blockquote p {
margin-bottom: 0;
}
.back-link {
display: inline-block;
margin-bottom: 2rem;
color: rgb(var(--theme-primary));
text-decoration: none;
font-weight: 600;
}
.back-link:hover {
text-decoration: underline;
}
.content hr {
border: none;
border-top: 2px solid rgba(var(--theme-primary), 0.1);
margin: 3rem 0;
}
.content em {
font-style: italic;
color: rgb(var(--theme-text-muted));
}
</style>
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<div class="article-header">
<div class="article-container">
<a href="/" class="back-link">← Zurück zur Übersicht</a>
<h1>{talk.data.title}</h1>
<div class="meta">
{
talk.data.speakerId ? (
<a
href={`/speakers/${talk.data.speakerId}`}
class="speaker hover:text-theme-primary transition-colors"
>
🎤 {talk.data.speaker}
</a>
) : (
<span class="speaker">🎤 {talk.data.speaker}</span>
)
}
<span>📅 {talk.data.venue}</span>
<span>⏱️ {talk.data.duration}</span>
</div>
<div class="tags">
{talk.data.tags.map((tag: string) => <span class="tag">{tag}</span>)}
</div>
</div>
</div>
<div class="article-container">
<div class="content">
<Content />
</div>
</div>
<Footer />
</body>
</html>

View file

@ -1,362 +0,0 @@
---
import { getCollection } from 'astro:content';
import TalksSidebar from '../../components/TalksSidebar.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import '../../styles/themes.css';
export async function getStaticPaths() {
const talks = await getCollection('talks');
return talks.map((talk) => ({
params: { slug: talk.slug },
props: { talk },
}));
}
const { talk } = Astro.props;
const { Content } = await talk.render();
---
<!doctype html>
<html lang="de" data-theme="ocean">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
background: rgb(var(--theme-background));
}
.main-content {
flex: 1;
margin-left: 320px;
overflow-y: auto;
position: relative;
}
.content-wrapper {
max-width: 900px;
margin: 0 auto;
padding: 3rem 4rem;
}
.content-header {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
}
.breadcrumb {
font-size: 0.85rem;
color: rgb(var(--theme-text-muted));
margin-bottom: 1rem;
}
.breadcrumb a {
color: rgb(var(--theme-primary));
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
color: rgb(var(--theme-text));
margin: 0 0 1rem 0;
line-height: 1.2;
}
.meta-info {
display: flex;
gap: 2rem;
flex-wrap: wrap;
font-size: 0.95rem;
color: rgb(var(--theme-text-muted));
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.highlight-box {
background: linear-gradient(
135deg,
rgba(var(--theme-primary), 0.08) 0%,
rgba(var(--theme-secondary), 0.05) 100%
);
border-left: 4px solid rgb(var(--theme-primary));
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 2rem;
font-size: 1.05rem;
line-height: 1.7;
color: rgb(var(--theme-text));
}
.content-body {
color: rgb(var(--theme-text));
line-height: 1.8;
}
/* Content styling */
.content-body h2 {
font-size: 1.8rem;
font-weight: 600;
color: rgb(var(--theme-primary));
margin: 3rem 0 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(var(--theme-primary), 0.1);
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
.content-body h2:hover {
color: rgb(var(--theme-secondary));
}
.content-body h3 {
font-size: 1.3rem;
font-weight: 600;
color: rgb(var(--theme-primary));
margin: 2rem 0 1rem;
}
.content-body p {
margin-bottom: 1.5rem;
}
.content-body ul,
.content-body ol {
margin: 1.5rem 0;
padding-left: 2rem;
}
.content-body li {
margin-bottom: 0.75rem;
}
.content-body blockquote {
background: rgba(var(--theme-primary), 0.05);
border-left: 4px solid rgb(var(--theme-primary));
padding: 1.25rem 1.5rem;
margin: 2rem 0;
border-radius: 0.5rem;
font-style: italic;
}
.content-body blockquote p {
margin: 0;
}
.content-body strong {
color: rgb(var(--theme-primary));
font-weight: 600;
}
.content-body em {
color: rgb(var(--theme-text-muted));
}
.content-body hr {
border: none;
border-top: 2px solid rgba(var(--theme-primary), 0.1);
margin: 3rem 0;
}
.section-wrapper {
margin-bottom: 2rem;
}
.section-collapsed {
display: none;
}
.collapse-arrow {
transition: transform 0.3s ease;
font-size: 1.2rem;
}
.collapse-arrow.collapsed {
transform: rotate(-90deg);
}
/* Mobile toggle button */
.mobile-menu-toggle {
display: none;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 101;
background: rgb(var(--theme-card));
border: 1px solid rgba(var(--theme-primary), 0.2);
border-radius: 0.5rem;
padding: 0.5rem;
cursor: pointer;
}
@media (max-width: 1024px) {
.main-content {
margin-left: 0;
}
.content-wrapper {
padding: 2rem 1.5rem;
}
.mobile-menu-toggle {
display: block;
}
h1 {
font-size: 2rem;
}
}
@media (max-width: 768px) {
.content-wrapper {
padding: 1.5rem 1rem;
}
h1 {
font-size: 1.75rem;
}
.content-body h2 {
font-size: 1.5rem;
}
}
</style>
</head>
<body class="bg-theme-background text-theme-text">
<ThemeSwitcher />
<div class="app-container">
<TalksSidebar />
<button class="mobile-menu-toggle" id="menuToggle">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 12H21M3 6H21M3 18H21"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
</button>
<main class="main-content">
<div class="content-wrapper">
<div class="content-header">
<div class="breadcrumb">
<a href="/">Home</a> / <a href="/speakers">Speakers</a> / {talk.data.speaker}
</div>
<h1>{talk.data.title}</h1>
<div class="meta-info">
<div class="meta-item">
<span>🎤</span>
<span>{talk.data.speaker}</span>
</div>
<div class="meta-item">
<span>📅</span>
<span>{talk.data.venue}</span>
</div>
<div class="meta-item">
<span>⏱️</span>
<span>{talk.data.duration}</span>
</div>
<div class="meta-item">
<span>📖</span>
<span>{talk.data.readingTime} min read</span>
</div>
</div>
</div>
<div class="highlight-box">
💡 {talk.data.summary}
</div>
<div class="content-body" id="content">
<Content />
</div>
</div>
</main>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Mobile menu toggle
const menuToggle = document.getElementById('menuToggle');
const sidebar = document.querySelector('.sidebar');
if (menuToggle && sidebar) {
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
});
}
// Collapsible sections
const content = document.getElementById('content');
if (!content) return;
const headings = content.querySelectorAll('h2');
headings.forEach((heading) => {
// Add collapse arrow
const arrow = document.createElement('span');
arrow.className = 'collapse-arrow';
arrow.textContent = '▼';
heading.appendChild(arrow);
// Collect content until next h2
const wrapper = document.createElement('div');
wrapper.className = 'section-wrapper';
let sibling = heading.nextElementSibling;
const elements = [];
while (sibling && sibling.tagName !== 'H2') {
elements.push(sibling);
sibling = sibling.nextElementSibling;
}
elements.forEach((el) => wrapper.appendChild(el));
heading.insertAdjacentElement('afterend', wrapper);
// Collapse transcript by default
if (heading.textContent.toLowerCase().includes('transcript')) {
wrapper.classList.add('section-collapsed');
arrow.classList.add('collapsed');
}
// Toggle on click
heading.addEventListener('click', () => {
wrapper.classList.toggle('section-collapsed');
arrow.classList.toggle('collapsed');
});
});
});
</script>
</body>
</html>

View file

@ -1,68 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
darkMode: 'class', // Enables class-based dark mode
theme: {
extend: {
colors: {
// Theme colors will use CSS variables for dynamic theming
'theme': {
'primary': 'rgb(var(--color-primary) / <alpha-value>)',
'primary-hover': 'rgb(var(--color-primary-hover) / <alpha-value>)',
'secondary': 'rgb(var(--color-secondary) / <alpha-value>)',
'accent': 'rgb(var(--color-accent) / <alpha-value>)',
'background': 'rgb(var(--color-background) / <alpha-value>)',
'surface': 'rgb(var(--color-surface) / <alpha-value>)',
'surface-hover': 'rgb(var(--color-surface-hover) / <alpha-value>)',
'text': 'rgb(var(--color-text) / <alpha-value>)',
'text-muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
'border': 'rgb(var(--color-border) / <alpha-value>)',
}
},
backgroundColor: {
'base': 'rgb(var(--color-background) / <alpha-value>)',
'surface': 'rgb(var(--color-surface) / <alpha-value>)',
},
textColor: {
'base': 'rgb(var(--color-text) / <alpha-value>)',
'muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
},
borderColor: {
'base': 'rgb(var(--color-border) / <alpha-value>)',
},
ringColor: {
'primary': 'rgb(var(--color-primary) / <alpha-value>)',
},
fontFamily: {
'sans': ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
'mono': ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-soft': 'pulseSoft 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
pulseSoft: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' },
},
},
boxShadow: {
'theme-sm': '0 1px 2px 0 rgb(var(--color-shadow) / 0.05)',
'theme-md': '0 4px 6px -1px rgb(var(--color-shadow) / 0.1)',
'theme-lg': '0 10px 15px -3px rgb(var(--color-shadow) / 0.1)',
'theme-xl': '0 20px 25px -5px rgb(var(--color-shadow) / 0.1)',
},
},
},
plugins: [],
}

View file

@ -1,37 +0,0 @@
{
"expo": {
"name": "Transcriber",
"slug": "transcriber",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "transcriber",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#9333ea"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.manacore.transcriber"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#9333ea"
},
"package": "com.manacore.transcriber"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": ["expo-router", "expo-secure-store"],
"experiments": {
"typedRoutes": true
}
}
}

View file

@ -1,48 +0,0 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#9333ea',
tabBarInactiveTintColor: '#6b7280',
headerStyle: {
backgroundColor: '#9333ea',
},
headerTintColor: '#fff',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="transcribe"
options={{
title: 'Transcribe',
tabBarIcon: ({ color, size }) => <Ionicons name="mic" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="transcripts"
options={{
title: 'Transcripts',
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color, size }) => <Ionicons name="settings" size={size} color={color} />,
}}
/>
</Tabs>
);
}

View file

@ -1,164 +0,0 @@
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useJobStore } from '@/stores/jobs';
export default function HomeScreen() {
const { jobs, activeJobs } = useJobStore();
const stats = {
totalTranscripts: jobs.filter((j) => j.status === 'completed').length,
activeJobs: activeJobs.length,
};
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Transcriber</Text>
<Text style={styles.subtitle}>AI-powered video transcription</Text>
</View>
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{stats.totalTranscripts}</Text>
<Text style={styles.statLabel}>Transcripts</Text>
</View>
<View style={styles.statCard}>
<Text style={[styles.statNumber, { color: '#eab308' }]}>{stats.activeJobs}</Text>
<Text style={styles.statLabel}>Active Jobs</Text>
</View>
</View>
<Link href="/(tabs)/transcribe" asChild>
<Pressable style={styles.button}>
<Text style={styles.buttonText}>Start New Transcription</Text>
</Pressable>
</Link>
{activeJobs.length > 0 && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Active Jobs</Text>
{activeJobs.map((job) => (
<View key={job.id} style={styles.jobCard}>
<Text style={styles.jobTitle} numberOfLines={1}>
{job.videoInfo?.title || job.url}
</Text>
<Text style={styles.jobStatus}>{job.status}</Text>
<View style={styles.progressBar}>
<View style={[styles.progressFill, { width: `${job.progress}%` }]} />
</View>
<Text style={styles.progressText}>{job.progress}%</Text>
</View>
))}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
header: {
padding: 24,
backgroundColor: '#9333ea',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
},
subtitle: {
fontSize: 16,
color: '#e9d5ff',
marginTop: 4,
},
statsContainer: {
flexDirection: 'row',
padding: 16,
gap: 16,
},
statCard: {
flex: 1,
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
statNumber: {
fontSize: 32,
fontWeight: 'bold',
color: '#9333ea',
},
statLabel: {
fontSize: 14,
color: '#6b7280',
marginTop: 4,
},
button: {
marginHorizontal: 16,
backgroundColor: '#9333ea',
padding: 16,
borderRadius: 12,
alignItems: 'center',
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
section: {
padding: 16,
},
sectionTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
color: '#1f2937',
},
jobCard: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
jobTitle: {
fontSize: 16,
fontWeight: '500',
color: '#1f2937',
},
jobStatus: {
fontSize: 12,
color: '#6b7280',
marginTop: 4,
textTransform: 'capitalize',
},
progressBar: {
height: 8,
backgroundColor: '#e5e7eb',
borderRadius: 4,
marginTop: 12,
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: '#9333ea',
borderRadius: 4,
},
progressText: {
fontSize: 12,
color: '#6b7280',
marginTop: 4,
},
});

View file

@ -1,70 +0,0 @@
import { View, Text, StyleSheet, ScrollView } from 'react-native';
export default function SettingsScreen() {
return (
<ScrollView style={styles.container}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.card}>
<Text style={styles.label}>Version</Text>
<Text style={styles.value}>1.0.0</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Backend URL</Text>
<Text style={styles.value}>http://localhost:3006</Text>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Default Settings</Text>
<View style={styles.card}>
<Text style={styles.label}>Language</Text>
<Text style={styles.value}>German (de)</Text>
</View>
<View style={styles.card}>
<Text style={styles.label}>Provider</Text>
<Text style={styles.value}>OpenAI Whisper API</Text>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
section: {
padding: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
color: '#6b7280',
textTransform: 'uppercase',
marginBottom: 12,
},
card: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
marginBottom: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
label: {
fontSize: 16,
color: '#1f2937',
},
value: {
fontSize: 14,
color: '#6b7280',
},
});

View file

@ -1,190 +0,0 @@
import { useState } from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ScrollView, Alert } from 'react-native';
import { router } from 'expo-router';
import { api } from '@/services/api';
import { useJobStore } from '@/stores/jobs';
export default function TranscribeScreen() {
const [url, setUrl] = useState('');
const [language, setLanguage] = useState('de');
const [provider, setProvider] = useState<'openai' | 'local'>('openai');
const [loading, setLoading] = useState(false);
const addJob = useJobStore((state) => state.addJob);
const languages = [
{ code: 'de', name: 'German' },
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
];
const handleSubmit = async () => {
if (!url.trim()) {
Alert.alert('Error', 'Please enter a YouTube URL');
return;
}
setLoading(true);
try {
const job = await api.createJob({ url, language, provider });
addJob(job);
setUrl('');
Alert.alert('Success', 'Transcription job started!', [
{ text: 'OK', onPress: () => router.push('/(tabs)/') },
]);
} catch (error) {
Alert.alert(
'Error',
error instanceof Error ? error.message : 'Failed to start transcription'
);
} finally {
setLoading(false);
}
};
return (
<ScrollView style={styles.container}>
<View style={styles.form}>
<View style={styles.field}>
<Text style={styles.label}>YouTube URL</Text>
<TextInput
style={styles.input}
value={url}
onChangeText={setUrl}
placeholder="https://www.youtube.com/watch?v=..."
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Language</Text>
<View style={styles.optionsRow}>
{languages.map((lang) => (
<Pressable
key={lang.code}
style={[styles.option, language === lang.code && styles.optionSelected]}
onPress={() => setLanguage(lang.code)}
>
<Text
style={[styles.optionText, language === lang.code && styles.optionTextSelected]}
>
{lang.name}
</Text>
</Pressable>
))}
</View>
</View>
<View style={styles.field}>
<Text style={styles.label}>Provider</Text>
<View style={styles.optionsRow}>
<Pressable
style={[styles.option, provider === 'openai' && styles.optionSelected]}
onPress={() => setProvider('openai')}
>
<Text style={[styles.optionText, provider === 'openai' && styles.optionTextSelected]}>
OpenAI
</Text>
</Pressable>
<Pressable
style={[styles.option, provider === 'local' && styles.optionSelected]}
onPress={() => setProvider('local')}
>
<Text style={[styles.optionText, provider === 'local' && styles.optionTextSelected]}>
Local
</Text>
</Pressable>
</View>
<Text style={styles.hint}>
{provider === 'openai'
? 'Fast, cloud-based transcription'
: 'Free, requires local Whisper'}
</Text>
</View>
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Starting...' : 'Start Transcription'}</Text>
</Pressable>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
form: {
padding: 16,
},
field: {
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
color: '#1f2937',
marginBottom: 8,
},
input: {
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 12,
padding: 16,
fontSize: 16,
},
optionsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
option: {
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
},
optionSelected: {
backgroundColor: '#9333ea',
borderColor: '#9333ea',
},
optionText: {
fontSize: 14,
color: '#374151',
},
optionTextSelected: {
color: '#fff',
fontWeight: '600',
},
hint: {
fontSize: 12,
color: '#6b7280',
marginTop: 8,
},
button: {
backgroundColor: '#9333ea',
padding: 16,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -1,85 +0,0 @@
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { useJobStore } from '@/stores/jobs';
export default function TranscriptsScreen() {
const { jobs } = useJobStore();
const completedJobs = jobs.filter((j) => j.status === 'completed');
return (
<ScrollView style={styles.container}>
{completedJobs.length === 0 ? (
<View style={styles.empty}>
<Text style={styles.emptyText}>No transcripts yet</Text>
<Text style={styles.emptyHint}>Start a new transcription to see results here</Text>
</View>
) : (
<View style={styles.list}>
{completedJobs.map((job) => (
<Pressable key={job.id} style={styles.card}>
<Text style={styles.cardTitle} numberOfLines={2}>
{job.videoInfo?.title || 'Untitled'}
</Text>
<Text style={styles.cardSubtitle}>{job.videoInfo?.channel || 'Unknown channel'}</Text>
<Text style={styles.cardDate}>
{new Date(job.completedAt || '').toLocaleDateString()}
</Text>
</Pressable>
))}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f9fafb',
},
empty: {
flex: 1,
padding: 32,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#6b7280',
},
emptyHint: {
fontSize: 14,
color: '#9ca3af',
marginTop: 8,
textAlign: 'center',
},
list: {
padding: 16,
},
card: {
backgroundColor: '#fff',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1f2937',
},
cardSubtitle: {
fontSize: 14,
color: '#6b7280',
marginTop: 4,
},
cardDate: {
fontSize: 12,
color: '#9ca3af',
marginTop: 8,
},
});

View file

@ -1,23 +0,0 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
export default function RootLayout() {
return (
<>
<StatusBar style="auto" />
<Stack
screenOptions={{
headerStyle: {
backgroundColor: '#9333ea',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</>
);
}

View file

@ -1,16 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: {
'@': './src',
},
},
],
],
};
};

View file

@ -1,38 +0,0 @@
{
"name": "@wisekeep/mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"build": "expo export",
"lint": "eslint .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"expo": "~52.0.0",
"expo-clipboard": "~7.0.0",
"expo-constants": "~17.0.0",
"expo-linking": "~7.0.0",
"expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0",
"expo-status-bar": "~2.0.0",
"nativewind": "^4.1.0",
"react": "18.3.1",
"react-native": "0.76.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "~18.3.0",
"babel-plugin-module-resolver": "^5.0.0",
"tailwindcss": "^3.4.15",
"typescript": "~5.7.2"
}
}

View file

@ -1,66 +0,0 @@
import Constants from 'expo-constants';
const API_BASE = Constants.expoConfig?.extra?.apiUrl || 'http://localhost:3006';
export interface TranscriptionJob {
id: string;
url: string;
language: string;
provider: string;
model?: string;
status: 'pending' | 'downloading' | 'transcribing' | 'completed' | 'failed' | 'cancelled';
progress: number;
createdAt: string;
completedAt?: string;
videoInfo?: {
id: string;
title: string;
channel: string;
thumbnail: string;
duration: number;
};
transcriptPath?: string;
transcriptText?: string;
error?: string;
}
export interface CreateJobRequest {
url: string;
language?: string;
provider?: 'openai' | 'local';
model?: 'tiny' | 'base' | 'small' | 'medium' | 'large';
}
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || 'Request failed');
}
return res.json();
}
export const api = {
createJob: (data: CreateJobRequest) =>
request<TranscriptionJob>('/transcription', {
method: 'POST',
body: JSON.stringify(data),
}),
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
cancelJob: (id: string) =>
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
health: () => request<{ status: string }>('/health'),
};

View file

@ -1,57 +0,0 @@
import { create } from 'zustand';
import type { TranscriptionJob } from '@/services/api';
interface JobStore {
jobs: TranscriptionJob[];
activeJobs: TranscriptionJob[];
addJob: (job: TranscriptionJob) => void;
updateJob: (id: string, updates: Partial<TranscriptionJob>) => void;
removeJob: (id: string) => void;
setJobs: (jobs: TranscriptionJob[]) => void;
}
export const useJobStore = create<JobStore>((set, get) => ({
jobs: [],
activeJobs: [],
addJob: (job) =>
set((state) => {
const jobs = [job, ...state.jobs];
return {
jobs,
activeJobs: jobs.filter(
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
),
};
}),
updateJob: (id, updates) =>
set((state) => {
const jobs = state.jobs.map((j) => (j.id === id ? { ...j, ...updates } : j));
return {
jobs,
activeJobs: jobs.filter(
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
),
};
}),
removeJob: (id) =>
set((state) => {
const jobs = state.jobs.filter((j) => j.id !== id);
return {
jobs,
activeJobs: jobs.filter(
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
),
};
}),
setJobs: (jobs) =>
set({
jobs,
activeJobs: jobs.filter(
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
),
}),
}));

View file

@ -1,10 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

View file

@ -1,17 +0,0 @@
// @ts-check
import {
baseConfig,
typescriptConfig,
svelteConfig,
prettierConfig,
} from '@manacore/eslint-config';
export default [
{
ignores: ['dist/**', '.svelte-kit/**', 'node_modules/**'],
},
...baseConfig,
...typescriptConfig,
...svelteConfig,
...prettierConfig,
];

View file

@ -1,34 +0,0 @@
{
"name": "@wisekeep/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"type-check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-ui": "workspace:*"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/node": "^22.10.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"svelte": "^5.12.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2",
"vite": "^6.0.1"
},
"type": "module"
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -1,107 +0,0 @@
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_API_URL || 'http://localhost:3006';
export interface TranscriptionJob {
id: string;
url: string;
language: string;
provider: string;
model?: string;
status: 'pending' | 'downloading' | 'transcribing' | 'completed' | 'failed' | 'cancelled';
progress: number;
createdAt: string;
completedAt?: string;
videoInfo?: {
id: string;
title: string;
channel: string;
thumbnail: string;
duration: number;
};
transcriptPath?: string;
transcriptText?: string;
error?: string;
}
export interface CreateJobRequest {
url: string;
language?: string;
provider?: 'openai' | 'local';
model?: 'tiny' | 'base' | 'small' | 'medium' | 'large';
}
export interface Playlist {
category: string;
name: string;
path: string;
urlCount: number;
urls: string[];
description?: string;
}
export interface Stats {
totalTranscripts: number;
totalSizeMB: number;
activeJobs: number;
completedJobs: number;
failedJobs: number;
}
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || 'Request failed');
}
return res.json();
}
export const api = {
// Transcription
createJob: (data: CreateJobRequest) =>
request<TranscriptionJob>('/transcription', {
method: 'POST',
body: JSON.stringify(data),
}),
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
cancelJob: (id: string) =>
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
getStats: () => request<Stats>('/transcription/stats'),
// Playlists
getPlaylists: () => request<Playlist[]>('/playlist'),
getPlaylist: (category: string, name: string) =>
request<Playlist>(`/playlist/${category}/${name}`),
createPlaylist: (data: { name: string; description?: string; urls: string[] }) =>
request<Playlist>('/playlist', {
method: 'POST',
body: JSON.stringify(data),
}),
// Whisper
getModels: () =>
request<{
models: { name: string; size: string; speed: string; accuracy: string }[];
defaultProvider: string;
openaiAvailable: boolean;
}>('/whisper/models'),
// Health
health: () => request<{ status: string }>('/health'),
};

View file

@ -1,15 +0,0 @@
/**
* Feedback Service Instance for Wisekeep Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'wisekeep',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -1,33 +0,0 @@
<script lang="ts">
import { AppSlider } from '@manacore/shared-ui';
import type { AppItem } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
// Convert MANA_APPS to AppItem format (German)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.de,
longDescription: app.longDescription.de,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -1,205 +0,0 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Using Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
// Getters
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
initialized = true;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, error: null, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get user credit balance
*/
async getCredits() {
const authService = getAuthService();
if (!authService) {
return null;
}
try {
const credits = await authService.getUserCredits();
return credits;
} catch (error) {
console.error('Failed to get credits:', error);
return null;
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -1,108 +0,0 @@
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { TranscriptionJob } from '$lib/api/client';
const API_URL = 'http://localhost:3006';
const WS_URL = API_URL.replace('http', 'ws');
export const jobs: Writable<Map<string, TranscriptionJob>> = writable(new Map());
export const isConnected = writable(false);
export const jobList = derived(jobs, ($jobs) =>
Array.from($jobs.values()).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
);
export const activeJobs = derived(jobList, ($jobs) =>
$jobs.filter(
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
)
);
let socket: WebSocket | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
export function initWebSocket() {
if (!browser) return;
const connect = () => {
socket = new WebSocket(`${WS_URL}/progress`);
socket.onopen = () => {
console.log('[WebSocket] Connected');
isConnected.set(true);
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'heartbeat') {
return;
}
if (
data.type === 'job_update' ||
data.type === 'job_complete' ||
data.type === 'job_error'
) {
jobs.update((map) => {
const existing = map.get(data.jobId);
if (existing) {
map.set(data.jobId, {
...existing,
status: data.status || existing.status,
progress: data.progress ?? existing.progress,
error: data.error || existing.error,
videoInfo: data.videoInfo || existing.videoInfo,
transcriptPath: data.transcriptPath || existing.transcriptPath,
});
}
return new Map(map);
});
}
} catch (e) {
console.error('[WebSocket] Parse error:', e);
}
};
socket.onclose = () => {
console.log('[WebSocket] Disconnected');
isConnected.set(false);
// Reconnect after 3 seconds
reconnectTimeout = setTimeout(connect, 3000);
};
socket.onerror = (error) => {
console.error('[WebSocket] Error:', error);
};
};
connect();
}
export function addJob(job: TranscriptionJob) {
jobs.update((map) => {
map.set(job.id, job);
return new Map(map);
});
}
export function removeJob(id: string) {
jobs.update((map) => {
map.delete(id);
return new Map(map);
});
}
export function cleanup() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
if (socket) {
socket.close();
}
}

View file

@ -1,46 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
// German translations
const translations = {
title: 'Passwort vergessen',
subtitle: 'Gib deine E-Mail ein, um einen Reset-Link zu erhalten',
emailPlaceholder: 'E-Mail',
resetButton: 'Reset-Link senden',
sending: 'Wird gesendet...',
success: 'E-Mail gesendet!',
backToLogin: 'Zurück zur Anmeldung',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
resetFailed: 'Zurücksetzen fehlgeschlagen',
resetSuccess: 'Bitte überprüfe deine E-Mails',
};
async function handleResetPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>Passwort vergessen | Wisekeep</title>
</svelte:head>
<ForgotPasswordPage
appName="Wisekeep"
logo={ManaCoreLogo}
primaryColor="#8b5cf6"
onResetPassword={handleResetPassword}
{goto}
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -1,65 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/dashboard');
// German translations
const translations = {
title: 'Anmelden',
subtitle: 'Melde dich mit deinem Konto an',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
rememberMe: 'Angemeldet bleiben',
forgotPassword: 'Passwort vergessen?',
signInButton: 'Anmelden',
signingIn: 'Wird angemeldet...',
success: 'Erfolgreich!',
orDivider: 'oder',
noAccount: 'Noch kein Konto?',
createAccount: 'Jetzt registrieren',
skipToForm: 'Zum Login-Formular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
signInFailed: 'Anmeldung fehlgeschlagen',
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...',
};
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<svelte:head>
<title>Anmelden | Wisekeep</title>
</svelte:head>
<LoginPage
appName="Wisekeep"
logo={ManaCoreLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -1,60 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
// German translations
const translations = {
title: 'Registrieren',
subtitle: 'Erstelle dein Konto',
emailPlaceholder: 'E-Mail',
passwordPlaceholder: 'Passwort',
confirmPasswordPlaceholder: 'Passwort wiederholen',
signUpButton: 'Registrieren',
signingUp: 'Wird registriert...',
success: 'Erfolgreich!',
orDivider: 'oder',
hasAccount: 'Bereits ein Konto?',
signIn: 'Jetzt anmelden',
skipToForm: 'Zum Registrierungsformular springen',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
emailRequired: 'E-Mail ist erforderlich',
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
passwordRequired: 'Passwort ist erforderlich',
passwordMinLength: 'Passwort muss mindestens 8 Zeichen haben',
passwordsNotMatch: 'Passwörter stimmen nicht überein',
signUpFailed: 'Registrierung fehlgeschlagen',
signUpSuccess: 'Erfolgreich registriert. Weiterleitung...',
verificationRequired: 'Bitte überprüfe deine E-Mails zur Bestätigung',
};
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>Registrieren | Wisekeep</title>
</svelte:head>
<RegisterPage
appName="Wisekeep"
logo={ManaCoreLogo}
primaryColor="#8b5cf6"
onSignUp={handleSignUp}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect="/"
loginPath="/login"
lightBackground="#f3e8ff"
darkBackground="#1e1b4b"
{translations}
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -1,13 +0,0 @@
/**
* Protected routes layout server
* Auth checking is done client-side via Mana Core Auth
*/
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ url }) => {
// Return the current path for client-side redirect logic
return {
pathname: url.pathname,
};
};

View file

@ -1,182 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
import type { LayoutData } from './$types';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem } from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
let { children, data }: { children: any; data: LayoutData } = $props();
// App switcher items
const appItems = getPillAppItems('wisekeep');
// User email for dropdown
let userEmail = $derived(authStore.user?.email);
// Navigation items for Wisekeep
const navItems: PillNavItem[] = [
{ href: '/dashboard', label: 'Dashboard', icon: 'home' },
{ href: '/transcribe', label: 'Transcribe', icon: 'mic' },
{ href: '/transcripts', label: 'Transcripts', icon: 'document' },
{ href: '/playlists', label: 'Playlists', icon: 'list' },
{ href: '/settings', label: 'Settings', icon: 'settings' },
];
let isChecking = $state(true);
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
let isDark = $state(false);
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = ['/dashboard', '/transcribe', '/transcripts', '/playlists', '/settings'];
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= 5) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('wisekeep-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('wisekeep-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
isDark = !isDark;
document.documentElement.classList.toggle('dark', isDark);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('wisekeep-dark-mode', String(isDark));
}
}
async function handleSignOut() {
await authStore.signOut();
goto('/login');
}
// Check auth on mount and redirect if not authenticated
onMount(async () => {
let shouldRedirect = false;
try {
await authStore.initialize();
shouldRedirect = !authStore.isAuthenticated;
if (!shouldRedirect) {
// Initialize WebSocket after auth check
initWebSocket();
}
} catch (error) {
console.error('Protected layout init error:', error);
shouldRedirect = true;
}
// Restore nav mode from localStorage
if (typeof localStorage !== 'undefined') {
const savedSidebar = localStorage.getItem('wisekeep-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
}
const savedCollapsed = localStorage.getItem('wisekeep-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
}
const savedDark = localStorage.getItem('wisekeep-dark-mode');
if (savedDark === 'true') {
isDark = true;
document.documentElement.classList.add('dark');
}
}
// Always set isChecking to false
isChecking = false;
if (shouldRedirect) {
const redirectTo = encodeURIComponent(data.pathname || '/dashboard');
goto(`/login?redirectTo=${redirectTo}`);
}
// Return cleanup function
return () => cleanup();
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isChecking}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-purple-600 border-r-transparent"
></div>
<p class="text-gray-600 dark:text-gray-400">Laden...</p>
</div>
</div>
{:else}
<div class="flex min-h-screen flex-col">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Wisekeep"
homeRoute="/dashboard"
onLogout={handleSignOut}
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
primaryColor="#9333ea"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/subscription"
profileHref="/profile"
allAppsHref="/apps"
>
{#snippet logo()}
<span class="text-xl">🧠</span>
<span class="pill-label font-bold">Wisekeep</span>
{/snippet}
</PillNavigation>
<main
class="main-content flex-1 transition-all duration-300 {isCollapsed
? ''
: isSidebarMode
? 'pl-[180px]'
: 'pt-20'}"
>
<div class="container mx-auto px-4 py-8">
{@render children()}
</div>
</main>
</div>
{/if}

View file

@ -1,17 +0,0 @@
<script lang="ts">
import { AppsPage } from '@manacore/shared-ui';
</script>
<svelte:head>
<title>Alle Apps - Wisekeep</title>
</svelte:head>
<div class="apps-page-wrapper">
<AppsPage currentAppId="wisekeep" locale="de" title="Alle Apps" />
</div>
<style>
.apps-page-wrapper {
min-height: 100%;
}
</style>

View file

@ -1,101 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import type { Stats } from '$lib/api/client';
import { activeJobs, jobList } from '$lib/stores/jobs';
let stats: Stats | null = $state(null);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
stats = await api.getStats();
const jobs = await api.getAllJobs();
// Initialize jobs store with existing jobs
jobs.forEach((job) => {
jobList; // trigger reactivity
});
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load stats';
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>Dashboard | Wisekeep</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
{#if loading}
<div class="text-gray-500">Loading...</div>
{:else if error}
<div class="bg-red-50 text-red-600 p-4 rounded-lg">{error}</div>
{:else if stats}
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div class="text-sm text-gray-500 mb-1">Total Transcripts</div>
<div class="text-3xl font-bold text-purple-600">{stats.totalTranscripts}</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div class="text-sm text-gray-500 mb-1">Storage Used</div>
<div class="text-3xl font-bold">{stats.totalSizeMB} MB</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div class="text-sm text-gray-500 mb-1">Active Jobs</div>
<div class="text-3xl font-bold text-yellow-600">{stats.activeJobs}</div>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm border">
<div class="text-sm text-gray-500 mb-1">Completed</div>
<div class="text-3xl font-bold text-green-600">{stats.completedJobs}</div>
</div>
</div>
{/if}
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Quick Start</h2>
<a
href="/transcribe"
class="inline-flex items-center px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Start New Transcription
</a>
</div>
{#if $activeJobs.length > 0}
<div class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-xl font-semibold mb-4">Active Jobs</h2>
<div class="space-y-4">
{#each $activeJobs as job (job.id)}
<div class="border rounded-lg p-4">
<div class="flex justify-between items-start mb-2">
<div>
<div class="font-medium">{job.videoInfo?.title || job.url}</div>
<div class="text-sm text-gray-500">{job.videoInfo?.channel || 'Loading...'}</div>
</div>
<span
class="px-2 py-1 text-xs rounded-full
{job.status === 'downloading' ? 'bg-blue-100 text-blue-700' : ''}
{job.status === 'transcribing' ? 'bg-yellow-100 text-yellow-700' : ''}
{job.status === 'pending' ? 'bg-gray-100 text-gray-700' : ''}"
>
{job.status}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-purple-600 h-2 rounded-full transition-all"
style="width: {job.progress}%"
></div>
</div>
<div class="text-sm text-gray-500 mt-1">{job.progress}%</div>
</div>
{/each}
</div>
</div>
{/if}
</div>

View file

@ -1,11 +0,0 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
</script>
<FeedbackPage
{feedbackService}
appName="Wisekeep"
currentUserId={authStore.user?.id}
/>

View file

@ -1,65 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import type { Playlist } from '$lib/api/client';
let playlists = $state<Playlist[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
playlists = await api.getPlaylists();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load playlists';
} finally {
loading = false;
}
});
const groupedPlaylists = $derived(() => {
const grouped: Record<string, Playlist[]> = {};
for (const playlist of playlists) {
if (!grouped[playlist.category]) {
grouped[playlist.category] = [];
}
grouped[playlist.category].push(playlist);
}
return grouped;
});
</script>
<svelte:head>
<title>Playlists | Wisekeep</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">Playlists</h1>
{#if loading}
<div class="text-gray-500">Loading...</div>
{:else if error}
<div class="bg-red-50 text-red-600 p-4 rounded-lg">{error}</div>
{:else if playlists.length === 0}
<div class="bg-gray-50 rounded-lg p-8 text-center">
<p class="text-gray-500">No playlists yet</p>
</div>
{:else}
{#each Object.entries(groupedPlaylists()) as [category, categoryPlaylists]}
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4 capitalize">{category}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each categoryPlaylists as playlist}
<div class="bg-white rounded-lg shadow-sm border p-4">
<h3 class="font-medium">{playlist.name}</h3>
{#if playlist.description}
<p class="text-sm text-gray-500 mt-1">{playlist.description}</p>
{/if}
<p class="text-xs text-gray-400 mt-2">{playlist.urlCount} URLs</p>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</div>

View file

@ -1,136 +0,0 @@
<script lang="ts">
import { api } from '$lib/api/client';
import { addJob } from '$lib/stores/jobs';
import { goto } from '$app/navigation';
let url = $state('');
let language = $state('de');
let provider = $state<'openai' | 'local'>('openai');
let model = $state<'tiny' | 'base' | 'small' | 'medium' | 'large'>('base');
let loading = $state(false);
let error = $state<string | null>(null);
const languages = [
{ code: 'de', name: 'German' },
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'fr', name: 'French' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'zh', name: 'Chinese' },
];
const models = [
{ value: 'tiny', label: 'Tiny (39 MB, ~10x speed)' },
{ value: 'base', label: 'Base (74 MB, ~7x speed)' },
{ value: 'small', label: 'Small (244 MB, ~4x speed)' },
{ value: 'medium', label: 'Medium (769 MB, ~2x speed)' },
{ value: 'large', label: 'Large (1.5 GB, best accuracy)' },
];
async function handleSubmit(e: Event) {
e.preventDefault();
error = null;
loading = true;
try {
const job = await api.createJob({
url,
language,
provider,
model: provider === 'local' ? model : undefined,
});
addJob(job);
goto('/');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start transcription';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>New Transcription | Wisekeep</title>
</svelte:head>
<div class="max-w-2xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">New Transcription</h1>
{#if error}
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="bg-white rounded-lg shadow-sm border p-6 space-y-6">
<div>
<label for="url" class="block text-sm font-medium text-gray-700 mb-2"> YouTube URL </label>
<input
type="url"
id="url"
bind:value={url}
placeholder="https://www.youtube.com/watch?v=..."
required
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label for="language" class="block text-sm font-medium text-gray-700 mb-2"> Language </label>
<select
id="language"
bind:value={language}
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{#each languages as lang}
<option value={lang.code}>{lang.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"> Transcription Provider </label>
<div class="flex gap-4">
<label class="flex items-center gap-2">
<input type="radio" bind:group={provider} value="openai" />
<span>OpenAI Whisper API</span>
</label>
<label class="flex items-center gap-2">
<input type="radio" bind:group={provider} value="local" />
<span>Local Whisper</span>
</label>
</div>
<p class="text-sm text-gray-500 mt-1">
{provider === 'openai'
? 'Fast, cloud-based transcription (~$0.006/min)'
: 'Free, requires local Whisper installation'}
</p>
</div>
{#if provider === 'local'}
<div>
<label for="model" class="block text-sm font-medium text-gray-700 mb-2">
Whisper Model
</label>
<select
id="model"
bind:value={model}
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{#each models as m}
<option value={m.value}>{m.label}</option>
{/each}
</select>
</div>
{/if}
<button
type="submit"
disabled={loading || !url}
class="w-full py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Starting...' : 'Start Transcription'}
</button>
</form>
</div>

View file

@ -1,70 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import { jobList } from '$lib/stores/jobs';
let loading = $state(true);
onMount(async () => {
try {
const jobs = await api.getAllJobs();
// Jobs are managed via the store
} finally {
loading = false;
}
});
const completedJobs = $derived($jobList.filter((j) => j.status === 'completed'));
</script>
<svelte:head>
<title>Transcripts | Wisekeep</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">Transcripts</h1>
{#if loading}
<div class="text-gray-500">Loading...</div>
{:else if completedJobs.length === 0}
<div class="bg-gray-50 rounded-lg p-8 text-center">
<p class="text-gray-500 mb-4">No transcripts yet</p>
<a
href="/transcribe"
class="inline-flex items-center px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Create your first transcript
</a>
</div>
{:else}
<div class="grid gap-4">
{#each completedJobs as job (job.id)}
<div class="bg-white rounded-lg shadow-sm border p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-medium">{job.videoInfo?.title || 'Untitled'}</h3>
<p class="text-sm text-gray-500">{job.videoInfo?.channel || 'Unknown channel'}</p>
<p class="text-xs text-gray-400 mt-1">
Completed: {new Date(job.completedAt || '').toLocaleString()}
</p>
</div>
<span class="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">
Completed
</span>
</div>
{#if job.transcriptText}
<details class="mt-4">
<summary class="cursor-pointer text-sm text-purple-600 hover:text-purple-700">
View transcript
</summary>
<pre
class="mt-2 p-4 bg-gray-50 rounded text-sm whitespace-pre-wrap overflow-auto max-h-96">
{job.transcriptText}
</pre>
</details>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View file

@ -1,9 +0,0 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
<div class="min-h-screen bg-gray-50">
{@render children()}
</div>

View file

@ -1,27 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
onMount(async () => {
await authStore.initialize();
if (authStore.isAuthenticated) {
goto('/dashboard', { replaceState: true });
} else {
goto('/login', { replaceState: true });
}
});
</script>
<svelte:head>
<title>Wisekeep - AI Wisdom Extraction</title>
</svelte:head>
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<div
class="animate-spin w-10 h-10 border-4 border-purple-500 border-r-transparent rounded-full mx-auto"
></div>
<p class="mt-4 text-gray-600">Wird geladen...</p>
</div>
</div>

View file

@ -1,23 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
primary: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
},
},
},
},
plugins: [],
};

View file

@ -1,372 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Transcriber - Admin Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
min-height: 100vh;
}
.header {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
padding: 1.5rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #60a5fa;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 1.5rem;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.stat-label {
color: #94a3b8;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.quick-action {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
}
.quick-action h2 {
margin-bottom: 1rem;
color: #cbd5e1;
}
.input-group {
display: flex;
gap: 1rem;
}
input {
flex: 1;
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
color: #e2e8f0;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
select {
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
color: #e2e8f0;
font-size: 1rem;
cursor: pointer;
}
button {
padding: 0.75rem 2rem;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
border: none;
border-radius: 8px;
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(59, 130, 246, 0.3);
}
button:active {
transform: translateY(0);
}
.jobs-list {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 2rem;
}
.job-item {
background: rgba(15, 23, 42, 0.4);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.job-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
.status-completed {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.status-processing {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.status-failed {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.loader {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(96, 165, 250, 0.3);
border-radius: 50%;
border-top-color: #60a5fa;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.nav-links {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.nav-links a {
color: #94a3b8;
text-decoration: none;
transition: color 0.2s;
}
.nav-links a:hover {
color: #60a5fa;
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<h1>🎥 YouTube Transcriber - Admin Dashboard</h1>
<div class="nav-links">
<a href="http://localhost:4321">→ Public Website</a>
<a href="http://localhost:8000/docs">→ API Docs</a>
</div>
</div>
</div>
<div class="container">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="total-transcripts">-</div>
<div class="stat-label">Transkripte</div>
</div>
<div class="stat-card">
<div class="stat-value" id="active-jobs">-</div>
<div class="stat-label">Aktive Jobs</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-size">-</div>
<div class="stat-label">Speicher (MB)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="playlists-count">-</div>
<div class="stat-label">Playlists</div>
</div>
</div>
<div class="quick-action">
<h2>🚀 Neue Transkription starten</h2>
<div class="input-group">
<input type="text" id="url-input" placeholder="YouTube URL eingeben...">
<select id="model-select">
<option value="tiny">Tiny (Schnell)</option>
<option value="base" selected>Base</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large (Beste Qualität)</option>
</select>
<select id="language-select">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
<button onclick="startTranscription()">Transkribieren</button>
</div>
</div>
<div class="jobs-list">
<h2 style="margin-bottom: 1rem;">📋 Aktuelle Jobs</h2>
<div id="jobs-container">
<div class="job-item">
<span style="color: #94a3b8;">Keine aktiven Jobs</span>
</div>
</div>
</div>
</div>
<script>
const API_URL = 'http://localhost:8000';
async function loadStats() {
try {
const response = await fetch(`${API_URL}/api/stats`);
const data = await response.json();
document.getElementById('total-transcripts').textContent = data.total_transcripts || '0';
document.getElementById('active-jobs').textContent = data.active_jobs || '0';
document.getElementById('total-size').textContent = data.total_size_mb?.toFixed(1) || '0';
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadPlaylists() {
try {
const response = await fetch(`${API_URL}/api/playlists`);
const data = await response.json();
document.getElementById('playlists-count').textContent = data.length || '0';
} catch (error) {
console.error('Error loading playlists:', error);
}
}
async function loadJobs() {
try {
const response = await fetch(`${API_URL}/api/jobs`);
const jobs = await response.json();
const container = document.getElementById('jobs-container');
if (jobs.length === 0) {
container.innerHTML = '<div class="job-item"><span style="color: #94a3b8;">Keine aktiven Jobs</span></div>';
} else {
container.innerHTML = jobs.map(job => `
<div class="job-item">
<div>
<div style="font-weight: 600; margin-bottom: 0.25rem;">${job.url}</div>
<div style="color: #94a3b8; font-size: 0.875rem;">
${new Date(job.created_at).toLocaleString('de-DE')}
</div>
</div>
<span class="job-status status-${job.status}">
${job.status === 'transcribing' ? '<span class="loader"></span>' : ''}
${job.status.toUpperCase()}
</span>
</div>
`).join('');
}
} catch (error) {
console.error('Error loading jobs:', error);
}
}
async function startTranscription() {
const url = document.getElementById('url-input').value;
const model = document.getElementById('model-select').value;
const language = document.getElementById('language-select').value;
if (!url) {
alert('Bitte YouTube URL eingeben');
return;
}
try {
const response = await fetch(`${API_URL}/api/transcribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, model, language })
});
if (response.ok) {
document.getElementById('url-input').value = '';
alert('Transkription gestartet!');
loadStats();
loadJobs();
} else {
alert('Fehler beim Starten der Transkription');
}
} catch (error) {
console.error('Error starting transcription:', error);
alert('Fehler: API nicht erreichbar');
}
}
// Initial load
loadStats();
loadPlaylists();
loadJobs();
// Refresh every 5 seconds
setInterval(() => {
loadStats();
loadJobs();
}, 5000);
</script>
</body>
</html>

View file

@ -1,372 +0,0 @@
#!/usr/bin/env python3
"""
FastAPI Server für YouTube Transcriber Web Interface
"""
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, HttpUrl
from typing import List, Optional, Dict, Any
import asyncio
import json
import os
from pathlib import Path
from datetime import datetime
import uuid
from enum import Enum
# Import existing transcriber modules
from transcriber_v4_parallel import ParallelTranscriber
import whisper
app = FastAPI(title="YouTube Transcriber API", version="1.0.0")
# CORS middleware for Astro frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4321", "http://localhost:3000"], # Astro dev server
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global state
class JobStatus(str, Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
TRANSCRIBING = "transcribing"
COMPLETED = "completed"
FAILED = "failed"
class TranscriptionJob:
def __init__(self, job_id: str, url: str, model: str = "base", language: str = "de"):
self.id = job_id
self.url = url
self.model = model
self.language = language
self.status = JobStatus.PENDING
self.progress = 0
self.created_at = datetime.now()
self.completed_at = None
self.transcript_path = None
self.error = None
self.video_info = {}
# Store active jobs
active_jobs: Dict[str, TranscriptionJob] = {}
websocket_connections: List[WebSocket] = []
# Request/Response models
class TranscribeRequest(BaseModel):
url: HttpUrl
model: str = "base"
language: str = "de"
class PlaylistRequest(BaseModel):
name: str
description: Optional[str] = None
urls: List[HttpUrl]
class JobResponse(BaseModel):
id: str
url: str
status: str
progress: int
created_at: datetime
completed_at: Optional[datetime]
transcript_path: Optional[str]
error: Optional[str]
video_info: Dict[str, Any]
# WebSocket manager
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except:
pass
manager = ConnectionManager()
# API Endpoints
@app.get("/")
async def root():
return {"message": "YouTube Transcriber API", "version": "1.0.0"}
@app.post("/api/transcribe", response_model=JobResponse)
async def start_transcription(request: TranscribeRequest, background_tasks: BackgroundTasks):
"""Start a new transcription job"""
job_id = str(uuid.uuid4())
job = TranscriptionJob(job_id, str(request.url), request.model, request.language)
active_jobs[job_id] = job
# Start transcription in background
background_tasks.add_task(process_transcription, job)
return JobResponse(
id=job.id,
url=job.url,
status=job.status,
progress=job.progress,
created_at=job.created_at,
completed_at=job.completed_at,
transcript_path=job.transcript_path,
error=job.error,
video_info=job.video_info
)
@app.get("/api/status/{job_id}", response_model=JobResponse)
async def get_job_status(job_id: str):
"""Get status of a transcription job"""
if job_id not in active_jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = active_jobs[job_id]
return JobResponse(
id=job.id,
url=job.url,
status=job.status,
progress=job.progress,
created_at=job.created_at,
completed_at=job.completed_at,
transcript_path=job.transcript_path,
error=job.error,
video_info=job.video_info
)
@app.get("/api/jobs")
async def list_jobs():
"""List all transcription jobs"""
return [
JobResponse(
id=job.id,
url=job.url,
status=job.status,
progress=job.progress,
created_at=job.created_at,
completed_at=job.completed_at,
transcript_path=job.transcript_path,
error=job.error,
video_info=job.video_info
)
for job in active_jobs.values()
]
@app.get("/api/transcripts")
async def list_transcripts():
"""List all available transcripts"""
transcript_dir = Path("transcripts")
transcripts = []
if transcript_dir.exists():
for playlist_dir in transcript_dir.iterdir():
if playlist_dir.is_dir():
for channel_dir in playlist_dir.iterdir():
if channel_dir.is_dir():
for transcript_file in channel_dir.glob("*.txt"):
transcripts.append({
"playlist": playlist_dir.name,
"channel": channel_dir.name,
"filename": transcript_file.name,
"path": str(transcript_file),
"size": transcript_file.stat().st_size,
"modified": datetime.fromtimestamp(transcript_file.stat().st_mtime)
})
return transcripts
@app.get("/api/transcript/{transcript_path:path}")
async def get_transcript(transcript_path: str):
"""Get transcript content"""
file_path = Path(transcript_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="Transcript not found")
return FileResponse(file_path)
@app.get("/api/playlists")
async def list_playlists():
"""List all playlists"""
playlist_dir = Path("playlists")
playlists = []
if playlist_dir.exists():
for category_dir in playlist_dir.iterdir():
if category_dir.is_dir():
for playlist_file in category_dir.glob("*.txt"):
urls = []
with open(playlist_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
urls.append(line)
playlists.append({
"category": category_dir.name,
"name": playlist_file.stem,
"path": str(playlist_file),
"url_count": len(urls),
"urls": urls
})
return playlists
@app.post("/api/playlists")
async def create_playlist(request: PlaylistRequest):
"""Create a new playlist"""
# Extract category and name from the playlist name (e.g., "tech/python_tutorials")
parts = request.name.split('/')
if len(parts) == 2:
category, name = parts
else:
category = "general"
name = request.name
playlist_dir = Path("playlists") / category
playlist_dir.mkdir(parents=True, exist_ok=True)
playlist_file = playlist_dir / f"{name}.txt"
with open(playlist_file, 'w') as f:
if request.description:
f.write(f"# {request.description}\n")
f.write("# Eine URL pro Zeile\n\n")
for url in request.urls:
f.write(f"{url}\n")
return {"message": "Playlist created", "path": str(playlist_file)}
@app.delete("/api/jobs/{job_id}")
async def cancel_job(job_id: str):
"""Cancel a transcription job"""
if job_id not in active_jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = active_jobs[job_id]
job.status = JobStatus.FAILED
job.error = "Cancelled by user"
await manager.broadcast({
"type": "job_cancelled",
"job_id": job_id
})
return {"message": "Job cancelled"}
@app.websocket("/ws/progress")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time progress updates"""
await manager.connect(websocket)
try:
while True:
# Keep connection alive
await asyncio.sleep(1)
# Send heartbeat
await websocket.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
manager.disconnect(websocket)
# Background task for processing
async def process_transcription(job: TranscriptionJob):
"""Process a transcription job"""
try:
# Update status
job.status = JobStatus.DOWNLOADING
await manager.broadcast({
"type": "job_update",
"job_id": job.id,
"status": job.status,
"progress": 10
})
# Initialize transcriber
transcriber = ParallelTranscriber(
model_size=job.model,
language=job.language,
max_downloads=1, # Single job
max_transcriptions=1
)
# Simulate processing (replace with actual transcriber call)
job.status = JobStatus.TRANSCRIBING
job.progress = 50
await manager.broadcast({
"type": "job_update",
"job_id": job.id,
"status": job.status,
"progress": job.progress
})
# TODO: Integrate actual transcription
# result = await transcriber.process_single(job.url)
# Mark as completed
job.status = JobStatus.COMPLETED
job.progress = 100
job.completed_at = datetime.now()
await manager.broadcast({
"type": "job_complete",
"job_id": job.id,
"status": job.status,
"progress": job.progress
})
except Exception as e:
job.status = JobStatus.FAILED
job.error = str(e)
await manager.broadcast({
"type": "job_error",
"job_id": job.id,
"error": job.error
})
@app.get("/api/models")
async def get_available_models():
"""Get available Whisper models"""
return {
"models": [
{"name": "tiny", "size": "39 MB", "speed": "~10x", "accuracy": "75%"},
{"name": "base", "size": "74 MB", "speed": "~7x", "accuracy": "85%"},
{"name": "small", "size": "244 MB", "speed": "~4x", "accuracy": "91%"},
{"name": "medium", "size": "769 MB", "speed": "~2x", "accuracy": "94%"},
{"name": "large", "size": "1.5 GB", "speed": "~1x", "accuracy": "96-98%"}
]
}
@app.get("/api/stats")
async def get_statistics():
"""Get system statistics"""
transcript_dir = Path("transcripts")
total_transcripts = 0
total_size = 0
if transcript_dir.exists():
for file in transcript_dir.rglob("*.txt"):
total_transcripts += 1
total_size += file.stat().st_size
return {
"total_transcripts": total_transcripts,
"total_size_mb": round(total_size / 1024 / 1024, 2),
"active_jobs": len([j for j in active_jobs.values() if j.status in [JobStatus.PENDING, JobStatus.DOWNLOADING, JobStatus.TRANSCRIBING]]),
"completed_jobs": len([j for j in active_jobs.values() if j.status == JobStatus.COMPLETED]),
"failed_jobs": len([j for j in active_jobs.values() if j.status == JobStatus.FAILED])
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)

View file

@ -1,31 +0,0 @@
{
"default_model": "small",
"default_language": "de",
"models": {
"tiny": {
"size_mb": 39,
"speed": "~10x Echtzeit",
"accuracy": "75%"
},
"base": {
"size_mb": 74,
"speed": "~7x Echtzeit",
"accuracy": "85%"
},
"small": {
"size_mb": 244,
"speed": "~4x Echtzeit",
"accuracy": "91%"
},
"medium": {
"size_mb": 769,
"speed": "~2x Echtzeit",
"accuracy": "94%"
},
"large": {
"size_mb": 1550,
"speed": "~1x Echtzeit",
"accuracy": "96-98%"
}
}
}

View file

@ -1,44 +0,0 @@
#!/bin/bash
# YouTube Transcriber - Schnellauswahl
source venv/bin/activate
echo "🎥 YouTube Transcriber - Modell-Auswahl"
echo "========================================"
echo ""
echo "1) 🚀 TINY - Schneller Test (39MB, ~10x Speed)"
echo "2) 🎯 LARGE - Beste Qualität (1.5GB, ~1x Speed)"
echo "3) 📋 SCAN - Alle Playlists scannen"
echo "4) ⚡ PARALLEL - Mehrere Videos parallel (3x Speed)"
echo ""
read -p "Wähle Modell (1-4): " choice
case $choice in
1)
echo "→ Nutze TINY Modell für schnellen Test"
read -p "YouTube URL: " url
python3 transcriber_v3.py process "$url" --model tiny
;;
2)
echo "→ Nutze LARGE Modell für beste Qualität"
read -p "YouTube URL: " url
python3 transcriber_v3.py process "$url" --model large
;;
3)
echo "→ Scanne alle Playlists mit LARGE Modell"
python3 transcriber_v3.py scan --model large
;;
4)
echo "→ Parallel-Verarbeitung (3x schneller!)"
echo "Gib URLs ein (mit Leerzeichen getrennt, oder Enter für Playlist):"
read -p "URLs: " urls
if [ -z "$urls" ]; then
python3 transcriber_v4_parallel.py process --playlist people/rory-sutherland --model large
else
python3 transcriber_v4_parallel.py process --urls $urls --model large
fi
;;
*)
echo "Ungültige Auswahl"
;;
esac

View file

@ -1,4 +0,0 @@
yt-dlp
openai-whisper
ffmpeg-python
rich

View file

@ -1,47 +0,0 @@
#!/bin/bash
# YouTube Transcriber - Start Script
echo "🎥 YouTube Transcriber System"
echo "============================="
echo ""
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
else
source venv/bin/activate
fi
# Start services
echo "Starting services..."
echo ""
# Start FastAPI backend
echo "1⃣ Starting API Server (Port 8000)..."
uvicorn api_server:app --reload --host 0.0.0.0 --port 8000 &
API_PID=$!
# Wait for API to start
sleep 3
# Start Astro frontend
echo "2⃣ Starting Website (Port 4321)..."
cd website && npx astro dev &
WEB_PID=$!
echo ""
echo "✅ System started!"
echo ""
echo "📍 Access points:"
echo " • Public Website: http://localhost:4321"
echo " • Admin Panel: http://localhost:4321/admin"
echo " • API Docs: http://localhost:8000/docs"
echo ""
echo "Press CTRL+C to stop all services"
# Wait for interrupt
trap "echo 'Stopping services...'; kill $API_PID $WEB_PID; exit" INT
wait

View file

@ -1,294 +0,0 @@
#!/usr/bin/env python3
"""
YouTube Auto-Transcriber MVP
Phase 1: Core Functionality - Download und Transkription
"""
import os
import sys
import argparse
from pathlib import Path
from datetime import datetime
import yt_dlp
import whisper
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
class YouTubeTranscriber:
def __init__(self, model_size="base", output_dir="transcripts"):
"""
Initialisiert den Transcriber
Args:
model_size: Whisper Model Größe (tiny, base, small, medium, large)
output_dir: Ausgabe-Verzeichnis für Transkriptionen
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.temp_dir = Path("temp_audio")
self.temp_dir.mkdir(exist_ok=True)
print(f"Lade Whisper Model '{model_size}'...")
self.model = whisper.load_model(model_size)
print(f"Model geladen: {model_size}")
self.ydl_opts = {
'format': 'bestaudio/best',
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}],
'outtmpl': str(self.temp_dir / '%(title)s.%(ext)s'),
'quiet': True,
'no_warnings': True,
}
def download_audio(self, url):
"""
Lädt Audio von YouTube herunter
Args:
url: YouTube URL
Returns:
Tuple (audio_path, video_info)
"""
print(f"\nLade Video von: {url}")
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=True)
title = info.get('title', 'unknown')
channel = info.get('uploader', 'unknown')
duration = info.get('duration', 0)
# Finde die heruntergeladene Audio-Datei
audio_file = None
for file in self.temp_dir.glob("*.mp3"):
if file.stat().st_mtime > (datetime.now().timestamp() - 60):
audio_file = file
break
if not audio_file:
raise Exception("Audio-Datei nicht gefunden")
print(f"✓ Download abgeschlossen: {title}")
print(f" Kanal: {channel}")
print(f" Dauer: {duration//60}:{duration%60:02d} Minuten")
return audio_file, {
'title': title,
'channel': channel,
'duration': duration,
'url': url
}
except Exception as e:
print(f"✗ Fehler beim Download: {e}")
return None, None
def transcribe_audio(self, audio_path, language="de"):
"""
Transkribiert Audio-Datei mit Whisper
Args:
audio_path: Pfad zur Audio-Datei
language: Sprache für Transkription
Returns:
Transkriptionstext
"""
print(f"\nStarte Transkription...")
print(f" Sprache: {language}")
try:
result = self.model.transcribe(
str(audio_path),
language=language,
verbose=False
)
print(f"✓ Transkription abgeschlossen")
print(f" Erkannte Sprache: {result.get('language', 'unbekannt')}")
return result['text']
except Exception as e:
print(f"✗ Fehler bei Transkription: {e}")
return None
def save_transcript(self, text, video_info):
"""
Speichert Transkript als Textdatei
Args:
text: Transkriptionstext
video_info: Video-Metadaten
Returns:
Pfad zur gespeicherten Datei
"""
# Erstelle sicheren Dateinamen
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_title}_{timestamp}.txt"
# Erstelle Kanal-Ordner
channel_dir = self.output_dir / video_info['channel'].replace('/', '_')
channel_dir.mkdir(exist_ok=True)
filepath = channel_dir / filename
# Schreibe Transkript mit Metadaten
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"YouTube Transkription\n")
f.write("=" * 50 + "\n\n")
f.write(f"Titel: {video_info['title']}\n")
f.write(f"Kanal: {video_info['channel']}\n")
f.write(f"URL: {video_info['url']}\n")
f.write(f"Dauer: {video_info['duration']//60}:{video_info['duration']%60:02d} Minuten\n")
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
f.write("\n" + "=" * 50 + "\n\n")
f.write("TRANSKRIPTION:\n\n")
f.write(text)
print(f"\n✓ Transkript gespeichert: {filepath}")
return filepath
def cleanup_temp_files(self):
"""Löscht temporäre Audio-Dateien"""
for file in self.temp_dir.glob("*.mp3"):
try:
file.unlink()
except:
pass
print("✓ Temporäre Dateien aufgeräumt")
def process_video(self, url, language="de"):
"""
Kompletter Workflow: Download Transkription Speichern
Args:
url: YouTube URL
language: Sprache für Transkription
Returns:
Pfad zur Transkriptionsdatei oder None
"""
print("\n" + "=" * 60)
print(f"VERARBEITE VIDEO")
print("=" * 60)
# 1. Download Audio
audio_path, video_info = self.download_audio(url)
if not audio_path:
return None
# 2. Transkribiere
transcript = self.transcribe_audio(audio_path, language)
if not transcript:
return None
# 3. Speichern
output_path = self.save_transcript(transcript, video_info)
# 4. Aufräumen
self.cleanup_temp_files()
print("\n✓ Video erfolgreich verarbeitet!")
return output_path
def main():
parser = argparse.ArgumentParser(
description='YouTube Video Transcriber - Transkribiert YouTube Videos mit Whisper'
)
parser.add_argument(
'url',
nargs='?',
help='YouTube Video URL'
)
parser.add_argument(
'--model',
default='base',
choices=['tiny', 'base', 'small', 'medium', 'large'],
help='Whisper Model Größe (default: base)'
)
parser.add_argument(
'--language',
default='de',
help='Sprache für Transkription (default: de)'
)
parser.add_argument(
'--output',
default='transcripts',
help='Ausgabe-Verzeichnis (default: transcripts)'
)
parser.add_argument(
'--batch',
action='store_true',
help='Batch-Modus: URLs aus stdin lesen'
)
args = parser.parse_args()
# Initialisiere Transcriber
transcriber = YouTubeTranscriber(
model_size=args.model,
output_dir=args.output
)
if args.batch:
# Batch-Modus: Lese URLs von stdin
print("Batch-Modus: Gebe URLs ein (eine pro Zeile, beende mit Ctrl+D):")
urls = []
try:
for line in sys.stdin:
url = line.strip()
if url and url.startswith('http'):
urls.append(url)
except KeyboardInterrupt:
pass
print(f"\n{len(urls)} Videos zu verarbeiten")
for i, url in enumerate(urls, 1):
print(f"\n[{i}/{len(urls)}] Verarbeite Video...")
transcriber.process_video(url, args.language)
elif args.url:
# Single Video
transcriber.process_video(args.url, args.language)
else:
# Interaktiver Modus
print("\nYouTube Transcriber - Interaktiver Modus")
print("=" * 50)
print(f"Model: {args.model}")
print(f"Sprache: {args.language}")
print(f"Ausgabe: {args.output}/")
print("=" * 50)
print("\nGebe YouTube URL ein (oder 'q' zum Beenden):")
while True:
try:
url = input("\nURL: ").strip()
if url.lower() in ['q', 'quit', 'exit']:
break
if url.startswith('http'):
transcriber.process_video(url, args.language)
else:
print("Ungültige URL. Bitte YouTube URL eingeben.")
except KeyboardInterrupt:
break
print("\nAuf Wiedersehen!")
if __name__ == "__main__":
main()

View file

@ -1,476 +0,0 @@
#!/usr/bin/env python3
"""
YouTube Auto-Transcriber v2.0
Mit verbesserter Download-Experience und Rich UI
"""
import os
import sys
import json
import argparse
import hashlib
from pathlib import Path
from datetime import datetime, timedelta
import time
import yt_dlp
import whisper
import warnings
from rich.console import Console
from rich.progress import (
Progress,
SpinnerColumn,
TextColumn,
BarColumn,
TaskProgressColumn,
TimeRemainingColumn,
TimeElapsedColumn,
DownloadColumn,
TransferSpeedColumn
)
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.layout import Layout
from rich import print as rprint
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
console = Console()
# ASCII Art Logo
LOGO = """
[bold cyan]
[bold white]🎥 YouTube Auto-Transcriber v2.0[/bold white]
[dim]Powered by OpenAI Whisper & yt-dlp[/dim]
[/bold cyan]
"""
class YouTubeTranscriber:
def __init__(self, model_size="base", output_dir="transcripts", cache_dir=".cache"):
"""
Initialisiert den Transcriber mit Rich UI
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
self.cache_file = self.cache_dir / "transcribed_videos.json"
self.temp_dir = Path("temp_audio")
self.temp_dir.mkdir(exist_ok=True)
# Lade Cache
self.cache = self.load_cache()
# Lade Whisper Model mit Progress
with console.status(f"[bold green]⏳ Lade Whisper Model '{model_size}'...", spinner="dots"):
self.model = whisper.load_model(model_size)
console.print(f"[bold green]✅ Model geladen: {model_size}[/bold green]")
# Model-Geschwindigkeiten (ungefähre Werte)
self.model_speeds = {
'tiny': 10,
'base': 7,
'small': 4,
'medium': 2,
'large': 1
}
self.model_size = model_size
self.speed_factor = self.model_speeds.get(model_size, 3)
self.ydl_opts = {
'format': 'bestaudio/best',
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}],
'outtmpl': str(self.temp_dir / '%(title)s.%(ext)s'),
'quiet': True,
'no_warnings': True,
'progress_hooks': [self._download_progress_hook],
}
self.current_progress = None
self.download_task = None
def load_cache(self):
"""Lädt den Cache bereits transkribierter Videos"""
if self.cache_file.exists():
with open(self.cache_file, 'r') as f:
return json.load(f)
return {}
def save_cache(self):
"""Speichert den Cache"""
with open(self.cache_file, 'w') as f:
json.dump(self.cache, f, indent=2)
def get_video_hash(self, url):
"""Erstellt einen Hash für die Video-URL"""
return hashlib.md5(url.encode()).hexdigest()
def is_cached(self, url):
"""Prüft ob Video bereits transkribiert wurde"""
video_hash = self.get_video_hash(url)
if video_hash in self.cache:
cached_info = self.cache[video_hash]
output_file = Path(cached_info['output_file'])
if output_file.exists():
return cached_info
return None
def _download_progress_hook(self, d):
"""Progress Hook für yt-dlp"""
if d['status'] == 'downloading' and self.download_task:
if d.get('total_bytes'):
downloaded = d.get('downloaded_bytes', 0)
total = d['total_bytes']
self.current_progress.update(self.download_task, completed=downloaded, total=total)
elif d.get('total_bytes_estimate'):
downloaded = d.get('downloaded_bytes', 0)
total = d['total_bytes_estimate']
self.current_progress.update(self.download_task, completed=downloaded, total=total)
def get_video_info(self, url):
"""
Holt Video-Informationen VOR dem Download
"""
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
return {
'title': info.get('title', 'Unbekannt'),
'channel': info.get('uploader', 'Unbekannt'),
'duration': info.get('duration', 0),
'view_count': info.get('view_count', 0),
'upload_date': info.get('upload_date', ''),
'description': info.get('description', '')[:200],
'filesize': info.get('filesize', 0) or info.get('filesize_approx', 0)
}
except Exception as e:
console.print(f"[red]❌ Fehler beim Abrufen der Video-Info: {e}[/red]")
return None
def display_video_info(self, info):
"""Zeigt Video-Informationen in einer schönen Tabelle"""
if not info:
return
# Erstelle Info-Tabelle
table = Table(title="📹 Video Information", show_header=False, box=None)
table.add_column("Property", style="cyan", width=20)
table.add_column("Value", style="white")
table.add_row("Titel", info['title'][:60] + "..." if len(info['title']) > 60 else info['title'])
table.add_row("Kanal", info['channel'])
duration = info['duration']
duration_str = f"{duration//60}:{duration%60:02d} Minuten"
table.add_row("Dauer", duration_str)
# Zeitschätzung für Transkription
estimated_time = duration / self.speed_factor
eta_str = f"~{estimated_time//60:.0f}:{estimated_time%60:02.0f} Minuten"
table.add_row("Geschätzte Zeit", f"{eta_str} (mit {self.model_size} model)")
if info.get('view_count'):
views = f"{info['view_count']:,}".replace(',', '.')
table.add_row("Aufrufe", views)
console.print(Panel(table, border_style="cyan"))
# Warnung bei langen Videos
if duration > 1800: # 30 Minuten
console.print(f"[yellow]⚠️ Hinweis: Dieses Video ist über 30 Minuten lang. Die Transkription kann einige Zeit dauern.[/yellow]")
return estimated_time
def download_audio(self, url, progress):
"""
Lädt Audio mit Progress Bar herunter
"""
self.current_progress = progress
self.download_task = progress.add_task(
"[cyan]📥 Download Audio...",
total=None
)
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=True)
title = info.get('title', 'unknown')
channel = info.get('uploader', 'unknown')
duration = info.get('duration', 0)
# Finde die heruntergeladene Audio-Datei
audio_file = None
for file in self.temp_dir.glob("*.mp3"):
if file.stat().st_mtime > (datetime.now().timestamp() - 60):
audio_file = file
break
if not audio_file:
raise Exception("Audio-Datei nicht gefunden")
progress.update(self.download_task, completed=100, total=100)
return audio_file, {
'title': title,
'channel': channel,
'duration': duration,
'url': url
}
except Exception as e:
console.print(f"[red]❌ Fehler beim Download: {e}[/red]")
return None, None
def transcribe_audio(self, audio_path, language="de", progress=None):
"""
Transkribiert Audio-Datei mit Progress Bar
"""
if progress:
task = progress.add_task(
f"[green]🎙️ Transkribiere mit {self.model_size} model...",
total=100
)
try:
# Simuliere Progress (Whisper hat keine direkte Progress-API)
def progress_callback(current, total):
if progress:
progress.update(task, completed=min(current, 100))
result = self.model.transcribe(
str(audio_path),
language=language,
verbose=False,
fp16=False # Für M1 Mac
)
if progress:
progress.update(task, completed=100)
return result['text'], result.get('language', 'unbekannt')
except Exception as e:
console.print(f"[red]❌ Fehler bei Transkription: {e}[/red]")
return None, None
def save_transcript(self, text, video_info, detected_language=None):
"""
Speichert Transkript als Textdatei
"""
# Erstelle sicheren Dateinamen
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_title}_{timestamp}.txt"
# Erstelle Kanal-Ordner
channel_dir = self.output_dir / video_info['channel'].replace('/', '_')
channel_dir.mkdir(exist_ok=True)
filepath = channel_dir / filename
# Schreibe Transkript mit Metadaten
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"YouTube Transkription\n")
f.write("=" * 50 + "\n\n")
f.write(f"Titel: {video_info['title']}\n")
f.write(f"Kanal: {video_info['channel']}\n")
f.write(f"URL: {video_info['url']}\n")
f.write(f"Dauer: {video_info['duration']//60}:{video_info['duration']%60:02d} Minuten\n")
if detected_language:
f.write(f"Erkannte Sprache: {detected_language}\n")
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
f.write(f"Whisper Model: {self.model_size}\n")
f.write("\n" + "=" * 50 + "\n\n")
f.write("TRANSKRIPTION:\n\n")
f.write(text)
return filepath
def cleanup_temp_files(self):
"""Löscht temporäre Audio-Dateien"""
for file in self.temp_dir.glob("*.mp3"):
try:
file.unlink()
except:
pass
def process_video(self, url, language="de", force_reprocess=False):
"""
Kompletter Workflow mit Rich UI
"""
console.rule(f"[bold blue]Verarbeite Video[/bold blue]")
# Prüfe Cache
if not force_reprocess:
cached = self.is_cached(url)
if cached:
console.print(f"[yellow]⚠️ Video bereits transkribiert:[/yellow]")
console.print(f" 📁 {cached['output_file']}")
console.print(f" 📅 {cached['transcribed_at']}")
console.print(f"[dim] (Nutze --force um neu zu transkribieren)[/dim]")
return cached['output_file']
# Hole Video-Info vorab
console.print("\n[cyan]📊 Lade Video-Informationen...[/cyan]")
video_info = self.get_video_info(url)
if not video_info:
return None
estimated_time = self.display_video_info(video_info)
# Multi-Progress für Download und Transkription
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
console=console
) as progress:
# 1. Download Audio
audio_path, download_info = self.download_audio(url, progress)
if not audio_path:
return None
# 2. Transkribiere
transcript, detected_lang = self.transcribe_audio(audio_path, language, progress)
if not transcript:
return None
# 3. Speichern
output_path = self.save_transcript(transcript, download_info, detected_lang)
# 4. Cache aktualisieren
video_hash = self.get_video_hash(url)
self.cache[video_hash] = {
'url': url,
'title': download_info['title'],
'output_file': str(output_path),
'transcribed_at': datetime.now().isoformat(),
'model': self.model_size,
'language': detected_lang
}
self.save_cache()
# 5. Aufräumen
self.cleanup_temp_files()
# Erfolgs-Meldung
console.print("\n[bold green]✅ Video erfolgreich verarbeitet![/bold green]")
console.print(f"📁 Gespeichert: [cyan]{output_path}[/cyan]")
return output_path
def main():
parser = argparse.ArgumentParser(
description='YouTube Video Transcriber v2.0 - Mit verbesserter UI'
)
parser.add_argument(
'url',
nargs='?',
help='YouTube Video URL'
)
parser.add_argument(
'--model',
default='base',
choices=['tiny', 'base', 'small', 'medium', 'large'],
help='Whisper Model Größe (default: base)'
)
parser.add_argument(
'--language',
default='de',
help='Sprache für Transkription (default: de)'
)
parser.add_argument(
'--output',
default='transcripts',
help='Ausgabe-Verzeichnis (default: transcripts)'
)
parser.add_argument(
'--batch',
action='store_true',
help='Batch-Modus: URLs aus stdin lesen'
)
parser.add_argument(
'--force',
action='store_true',
help='Ignoriere Cache und transkribiere neu'
)
args = parser.parse_args()
# Zeige Logo
console.print(LOGO)
# Initialisiere Transcriber
transcriber = YouTubeTranscriber(
model_size=args.model,
output_dir=args.output
)
if args.batch:
# Batch-Modus
console.print("[cyan]📋 Batch-Modus: Gebe URLs ein (eine pro Zeile, beende mit Ctrl+D):[/cyan]")
urls = []
try:
for line in sys.stdin:
url = line.strip()
if url and url.startswith('http'):
urls.append(url)
except KeyboardInterrupt:
pass
console.print(f"\n[bold]{len(urls)} Videos zu verarbeiten[/bold]")
for i, url in enumerate(urls, 1):
console.print(f"\n[bold cyan]━━━ Video {i}/{len(urls)} ━━━[/bold cyan]")
transcriber.process_video(url, args.language, args.force)
elif args.url:
# Single Video
transcriber.process_video(args.url, args.language, args.force)
else:
# Interaktiver Modus
console.print("[bold cyan]🎬 Interaktiver Modus[/bold cyan]")
console.print(f"Model: [green]{args.model}[/green]")
console.print(f"Sprache: [green]{args.language}[/green]")
console.print(f"Ausgabe: [green]{args.output}/[/green]")
console.print("\nGebe YouTube URL ein (oder 'q' zum Beenden):\n")
while True:
try:
url = console.input("[bold cyan]URL ▶ [/bold cyan]").strip()
if url.lower() in ['q', 'quit', 'exit']:
break
if url.startswith('http'):
transcriber.process_video(url, args.language, args.force)
else:
console.print("[red]❌ Ungültige URL. Bitte YouTube URL eingeben.[/red]")
except KeyboardInterrupt:
break
console.print("\n[bold green]👋 Auf Wiedersehen![/bold green]")
if __name__ == "__main__":
main()

View file

@ -1,603 +0,0 @@
#!/usr/bin/env python3
"""
YouTube Auto-Transcriber v3.0
Mit Playlist-Management und Themen-Ordnern
"""
import os
import sys
import json
import argparse
import hashlib
from pathlib import Path
from datetime import datetime, timedelta
import time
from typing import List, Dict, Tuple
import yt_dlp
import whisper
import warnings
from rich.console import Console
from rich.progress import (
Progress,
SpinnerColumn,
TextColumn,
BarColumn,
TaskProgressColumn,
TimeRemainingColumn,
TimeElapsedColumn,
MofNCompleteColumn
)
from rich.table import Table
from rich.panel import Panel
from rich.tree import Tree
from rich import print as rprint
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
console = Console()
# ASCII Art Logo
LOGO = """
[bold cyan]
[bold white]🎥 YouTube Auto-Transcriber v3.0[/bold white]
[dim]Playlist Management & Batch Processing[/dim]
[/bold cyan]
"""
class PlaylistManager:
"""
Verwaltet Playlists und URL-Listen
"""
def __init__(self, playlists_dir="playlists"):
self.playlists_dir = Path(playlists_dir)
self.playlists_dir.mkdir(exist_ok=True)
# Erstelle Beispiel-Struktur wenn leer
self._create_example_structure()
def _create_example_structure(self):
"""Erstellt Beispiel-Ordnerstruktur"""
example_file = self.playlists_dir / "example_tech.txt"
if not example_file.exists() and not any(self.playlists_dir.glob("*.txt")):
with open(example_file, 'w') as f:
f.write("# Tech Videos - Beispiel Playlist\n")
f.write("# Zeilen mit # werden ignoriert\n")
f.write("# Eine URL pro Zeile:\n")
f.write("#\n")
f.write("# https://www.youtube.com/watch?v=VIDEO_ID\n")
def get_all_playlists(self) -> Dict[str, Path]:
"""Findet alle Playlist-Dateien"""
playlists = {}
# Suche .txt Dateien im Hauptordner
for file in self.playlists_dir.glob("*.txt"):
name = file.stem
playlists[name] = file
# Suche auch in Unterordnern
for folder in self.playlists_dir.iterdir():
if folder.is_dir():
for file in folder.glob("*.txt"):
name = f"{folder.name}/{file.stem}"
playlists[name] = file
return playlists
def read_playlist(self, playlist_path: Path) -> List[str]:
"""Liest URLs aus einer Playlist-Datei"""
urls = []
if not playlist_path.exists():
return urls
with open(playlist_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Ignoriere leere Zeilen und Kommentare
if line and not line.startswith('#'):
if 'youtube.com' in line or 'youtu.be' in line:
urls.append(line)
return urls
def display_playlists_tree(self):
"""Zeigt alle Playlists als Baum-Struktur"""
tree = Tree("[bold cyan]📁 Playlists[/bold cyan]")
# Hauptordner-Dateien
for file in sorted(self.playlists_dir.glob("*.txt")):
urls = self.read_playlist(file)
tree.add(f"📄 {file.stem} ({len(urls)} URLs)")
# Unterordner
for folder in sorted(self.playlists_dir.iterdir()):
if folder.is_dir():
branch = tree.add(f"📂 {folder.name}/")
for file in sorted(folder.glob("*.txt")):
urls = self.read_playlist(file)
branch.add(f"📄 {file.stem} ({len(urls)} URLs)")
console.print(tree)
return tree
class YouTubeTranscriber:
def __init__(self, model_size="base", output_dir="transcripts", cache_dir=".cache"):
"""
Initialisiert den Transcriber mit Rich UI
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
self.cache_file = self.cache_dir / "transcribed_videos.json"
self.temp_dir = Path("temp_audio")
self.temp_dir.mkdir(exist_ok=True)
# Lade Cache
self.cache = self.load_cache()
# Lade Whisper Model mit Progress
with console.status(f"[bold green]⏳ Lade Whisper Model '{model_size}'...", spinner="dots"):
self.model = whisper.load_model(model_size)
console.print(f"[bold green]✅ Model geladen: {model_size}[/bold green]")
# Model-Geschwindigkeiten
self.model_speeds = {
'tiny': 10,
'base': 7,
'small': 4,
'medium': 2,
'large': 1
}
self.model_size = model_size
self.speed_factor = self.model_speeds.get(model_size, 3)
self.ydl_opts = {
'format': 'bestaudio/best',
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}],
'outtmpl': str(self.temp_dir / '%(title)s.%(ext)s'),
'quiet': True,
'no_warnings': True,
'progress_hooks': [self._download_progress_hook],
}
self.current_progress = None
self.download_task = None
def load_cache(self):
"""Lädt den Cache bereits transkribierter Videos"""
if self.cache_file.exists():
with open(self.cache_file, 'r') as f:
return json.load(f)
return {}
def save_cache(self):
"""Speichert den Cache"""
with open(self.cache_file, 'w') as f:
json.dump(self.cache, f, indent=2)
def get_video_hash(self, url):
"""Erstellt einen Hash für die Video-URL"""
return hashlib.md5(url.encode()).hexdigest()
def is_cached(self, url):
"""Prüft ob Video bereits transkribiert wurde"""
video_hash = self.get_video_hash(url)
if video_hash in self.cache:
cached_info = self.cache[video_hash]
output_file = Path(cached_info['output_file'])
if output_file.exists():
return cached_info
return None
def _download_progress_hook(self, d):
"""Progress Hook für yt-dlp"""
if d['status'] == 'downloading' and self.download_task:
if d.get('total_bytes'):
downloaded = d.get('downloaded_bytes', 0)
total = d['total_bytes']
self.current_progress.update(self.download_task, completed=downloaded, total=total)
def get_video_info(self, url):
"""Holt Video-Informationen VOR dem Download"""
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': False,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=False)
return {
'title': info.get('title', 'Unbekannt'),
'channel': info.get('uploader', 'Unbekannt'),
'duration': info.get('duration', 0),
'url': url
}
except Exception as e:
console.print(f"[red]❌ Fehler beim Abrufen der Video-Info: {e}[/red]")
return None
def download_audio(self, url, progress=None):
"""Lädt Audio mit Progress Bar herunter"""
self.current_progress = progress
if progress:
self.download_task = progress.add_task(
"[cyan]📥 Download...",
total=None
)
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
try:
info = ydl.extract_info(url, download=True)
title = info.get('title', 'unknown')
channel = info.get('uploader', 'unknown')
duration = info.get('duration', 0)
# Finde die heruntergeladene Audio-Datei
audio_file = None
for file in self.temp_dir.glob("*.mp3"):
if file.stat().st_mtime > (datetime.now().timestamp() - 60):
audio_file = file
break
if not audio_file:
raise Exception("Audio-Datei nicht gefunden")
if progress and self.download_task:
progress.update(self.download_task, completed=100, total=100)
return audio_file, {
'title': title,
'channel': channel,
'duration': duration,
'url': url
}
except Exception as e:
console.print(f"[red]❌ Download-Fehler: {e}[/red]")
return None, None
def transcribe_audio(self, audio_path, language="de", progress=None):
"""Transkribiert Audio-Datei"""
if progress:
task = progress.add_task(
f"[green]🎙️ Transkribiere...",
total=100
)
try:
result = self.model.transcribe(
str(audio_path),
language=language,
verbose=False,
fp16=False
)
if progress:
progress.update(task, completed=100)
return result['text'], result.get('language', 'unbekannt')
except Exception as e:
console.print(f"[red]❌ Transkriptions-Fehler: {e}[/red]")
return None, None
def save_transcript(self, text, video_info, playlist_name=None):
"""Speichert Transkript mit optionalem Playlist-Ordner"""
# Basis-Ordner
base_dir = self.output_dir
# Wenn Playlist, erstelle Unterordner
if playlist_name:
base_dir = base_dir / playlist_name.replace('/', '_')
base_dir.mkdir(parents=True, exist_ok=True)
# Kanal-Ordner
channel_dir = base_dir / video_info['channel'].replace('/', '_')
channel_dir.mkdir(exist_ok=True)
# Dateiname
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_title}_{timestamp}.txt"
filepath = channel_dir / filename
# Schreibe Transkript
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"YouTube Transkription\n")
f.write("=" * 50 + "\n\n")
f.write(f"Titel: {video_info['title']}\n")
f.write(f"Kanal: {video_info['channel']}\n")
f.write(f"URL: {video_info['url']}\n")
if playlist_name:
f.write(f"Playlist: {playlist_name}\n")
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
f.write(f"Whisper Model: {self.model_size}\n")
f.write("\n" + "=" * 50 + "\n\n")
f.write("TRANSKRIPTION:\n\n")
f.write(text)
return filepath
def cleanup_temp_files(self):
"""Löscht temporäre Audio-Dateien"""
for file in self.temp_dir.glob("*.mp3"):
try:
file.unlink()
except:
pass
def process_video(self, url, language="de", playlist_name=None, progress=None):
"""Verarbeitet ein einzelnes Video"""
# Prüfe Cache
cached = self.is_cached(url)
if cached:
return cached['output_file'], True # True = war gecached
# Hole Video-Info
video_info = self.get_video_info(url)
if not video_info:
return None, False
# Download Audio
audio_path, download_info = self.download_audio(url, progress)
if not audio_path:
return None, False
# Transkribiere
transcript, detected_lang = self.transcribe_audio(audio_path, language, progress)
if not transcript:
return None, False
# Speichern
output_path = self.save_transcript(transcript, download_info, playlist_name)
# Cache aktualisieren
video_hash = self.get_video_hash(url)
self.cache[video_hash] = {
'url': url,
'title': download_info['title'],
'output_file': str(output_path),
'transcribed_at': datetime.now().isoformat(),
'model': self.model_size,
'playlist': playlist_name
}
self.save_cache()
# Aufräumen
self.cleanup_temp_files()
return output_path, False # False = neu transkribiert
def process_playlist(self, playlist_name: str, urls: List[str], language="de"):
"""
Verarbeitet eine komplette Playlist
"""
console.rule(f"[bold cyan]📋 Playlist: {playlist_name}[/bold cyan]")
# Filtere bereits transkribierte Videos
new_urls = []
cached_count = 0
for url in urls:
if self.is_cached(url):
cached_count += 1
else:
new_urls.append(url)
# Status-Übersicht
table = Table(show_header=False, box=None)
table.add_column("Info", style="cyan")
table.add_column("Wert", style="white")
table.add_row("📊 Gesamt Videos:", str(len(urls)))
table.add_row("✅ Bereits transkribiert:", str(cached_count))
table.add_row("🆕 Neu zu transkribieren:", str(len(new_urls)))
console.print(Panel(table, title="Playlist Status", border_style="cyan"))
if not new_urls:
console.print("[green]✅ Alle Videos bereits transkribiert![/green]")
return
# Verarbeite neue Videos
success_count = 0
error_count = 0
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
console=console
) as progress:
playlist_task = progress.add_task(
f"[cyan]Verarbeite {playlist_name}...",
total=len(new_urls)
)
for i, url in enumerate(new_urls, 1):
progress.update(
playlist_task,
description=f"[cyan]Video {i}/{len(new_urls)}..."
)
# Verarbeite Video
output_path, was_cached = self.process_video(
url,
language,
playlist_name,
progress
)
if output_path:
success_count += 1
console.print(f"{Path(output_path).name}")
else:
error_count += 1
console.print(f" ❌ Fehler bei: {url}")
progress.update(playlist_task, advance=1)
# Zusammenfassung
console.print("\n" + "=" * 50)
console.print(f"[bold green]✅ Erfolgreich: {success_count}[/bold green]")
if error_count > 0:
console.print(f"[bold red]❌ Fehler: {error_count}[/bold red]")
console.print(f"[bold cyan]📁 Gespeichert in: {self.output_dir}/{playlist_name}/[/bold cyan]")
def process_all_playlists(transcriber, playlist_manager, language="de"):
"""Verarbeitet alle Playlists"""
playlists = playlist_manager.get_all_playlists()
if not playlists:
console.print("[yellow]⚠️ Keine Playlists gefunden![/yellow]")
console.print(f"Erstelle .txt Dateien in: {playlist_manager.playlists_dir}/")
return
console.print(f"\n[bold cyan]🔍 Gefundene Playlists:[/bold cyan]")
playlist_manager.display_playlists_tree()
# Statistiken sammeln
total_urls = 0
total_new = 0
for name, path in playlists.items():
urls = playlist_manager.read_playlist(path)
new_count = sum(1 for url in urls if not transcriber.is_cached(url))
total_urls += len(urls)
total_new += new_count
console.print(f"\n[bold]📊 Gesamt: {total_urls} Videos, {total_new} neu zu transkribieren[/bold]")
# Verarbeite jede Playlist
for name, path in playlists.items():
urls = playlist_manager.read_playlist(path)
if urls:
console.print(f"\n" + "=" * 60)
transcriber.process_playlist(name, urls, language)
console.print("\n[bold green]🎉 Alle Playlists verarbeitet![/bold green]")
def main():
parser = argparse.ArgumentParser(
description='YouTube Transcriber v3.0 - Playlist Management'
)
parser.add_argument(
'command',
nargs='?',
choices=['scan', 'list', 'process'],
default='scan',
help='Befehl: scan (alle Playlists), list (zeige Playlists), process (einzelne URL)'
)
parser.add_argument(
'url',
nargs='?',
help='YouTube URL (nur für process)'
)
parser.add_argument(
'--playlist',
help='Spezifische Playlist verarbeiten'
)
parser.add_argument(
'--model',
default='base',
choices=['tiny', 'base', 'small', 'medium', 'large'],
help='Whisper Model (default: base)'
)
parser.add_argument(
'--language',
default='de',
help='Sprache (default: de)'
)
parser.add_argument(
'--playlists-dir',
default='playlists',
help='Ordner mit Playlist-Dateien (default: playlists)'
)
parser.add_argument(
'--output',
default='transcripts',
help='Ausgabe-Ordner (default: transcripts)'
)
args = parser.parse_args()
# Zeige Logo
console.print(LOGO)
# Initialisiere Manager
playlist_manager = PlaylistManager(args.playlists_dir)
transcriber = YouTubeTranscriber(
model_size=args.model,
output_dir=args.output
)
if args.command == 'list':
# Zeige nur Playlists
playlists = playlist_manager.get_all_playlists()
if playlists:
console.print("[bold cyan]📁 Verfügbare Playlists:[/bold cyan]\n")
playlist_manager.display_playlists_tree()
# Zeige Details
console.print("\n[bold]Details:[/bold]")
for name, path in playlists.items():
urls = playlist_manager.read_playlist(path)
new_count = sum(1 for url in urls if not transcriber.is_cached(url))
console.print(f"{name}: {len(urls)} URLs ({new_count} neu)")
else:
console.print("[yellow]Keine Playlists gefunden![/yellow]")
console.print(f"Erstelle .txt Dateien in: {args.playlists_dir}/")
elif args.command == 'process':
# Verarbeite einzelne URL
if args.url:
output, _ = transcriber.process_video(args.url, args.language)
if output:
console.print(f"[green]✅ Gespeichert: {output}[/green]")
else:
console.print("[red]❌ Bitte URL angeben für 'process' Befehl[/red]")
elif args.command == 'scan':
# Verarbeite Playlists
if args.playlist:
# Spezifische Playlist
playlists = playlist_manager.get_all_playlists()
if args.playlist in playlists:
path = playlists[args.playlist]
urls = playlist_manager.read_playlist(path)
transcriber.process_playlist(args.playlist, urls, args.language)
else:
console.print(f"[red]❌ Playlist '{args.playlist}' nicht gefunden![/red]")
console.print("Verfügbare Playlists:")
for name in playlists.keys():
console.print(f"{name}")
else:
# Alle Playlists
process_all_playlists(transcriber, playlist_manager, args.language)
console.print("\n[bold green]✨ Fertig![/bold green]")
if __name__ == "__main__":
main()

View file

@ -1,559 +0,0 @@
#!/usr/bin/env python3
"""
YouTube Auto-Transcriber v4.0 - PARALLEL EDITION
Mit Multi-Threading für 3-4x schnellere Verarbeitung
"""
import os
import sys
import json
import argparse
import hashlib
from pathlib import Path
from datetime import datetime
import time
from typing import List, Dict, Tuple, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
from queue import Queue, Empty
import threading
from dataclasses import dataclass
import multiprocessing
import yt_dlp
import whisper
import warnings
from rich.console import Console
from rich.progress import (
Progress,
SpinnerColumn,
TextColumn,
BarColumn,
TaskProgressColumn,
TimeRemainingColumn,
TimeElapsedColumn,
MofNCompleteColumn
)
from rich.table import Table
from rich.panel import Panel
from rich.live import Live
from rich.layout import Layout
from rich.columns import Columns
from rich import print as rprint
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)
console = Console()
# ASCII Art Logo
LOGO = """
[bold cyan]
[bold white]🚀 YouTube Transcriber v4.0 - PARALLEL[/bold white]
[dim]Multi-Threading für 3-4x Speed![/dim]
[/bold cyan]
"""
@dataclass
class VideoJob:
"""Datenklasse für Video-Jobs"""
url: str
playlist_name: Optional[str] = None
language: str = "de"
status: str = "pending" # pending, downloading, transcribing, completed, failed
error: Optional[str] = None
output_path: Optional[str] = None
title: Optional[str] = None
duration: Optional[int] = None
class ParallelTranscriber:
def __init__(self,
model_size="base",
output_dir="transcripts",
cache_dir=".cache",
max_downloads=3,
max_transcriptions=2):
"""
Initialisiert den Parallel-Transcriber
Args:
max_downloads: Maximale parallele Downloads
max_transcriptions: Maximale parallele Transkriptionen
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True)
self.cache_file = self.cache_dir / "transcribed_videos.json"
self.temp_dir = Path("temp_audio")
self.temp_dir.mkdir(exist_ok=True)
# Parallel-Processing Settings
self.max_downloads = max_downloads
self.max_transcriptions = max_transcriptions
# Optimale Werte für M1/M2 Macs
if model_size == "large":
self.max_transcriptions = min(2, max_transcriptions) # Max 2 Large-Modelle parallel
elif model_size in ["tiny", "base"]:
self.max_transcriptions = min(4, max_transcriptions) # Bis zu 4 kleine Modelle
# Queues für Pipeline
self.download_queue = Queue()
self.transcribe_queue = Queue()
self.completed_queue = Queue()
# Thread Pools
self.download_pool = ThreadPoolExecutor(max_workers=self.max_downloads)
self.transcribe_pool = ThreadPoolExecutor(max_workers=self.max_transcriptions)
# Jobs tracking
self.jobs: Dict[str, VideoJob] = {}
self.lock = threading.Lock()
# Lade Cache
self.cache = self.load_cache()
# Model Settings
self.model_size = model_size
self.model_speeds = {
'tiny': 10,
'base': 7,
'small': 4,
'medium': 2,
'large': 1
}
# Progress tracking
self.progress = None
self.main_task = None
console.print(f"[bold green]⚡ Parallel-Modus aktiviert:[/bold green]")
console.print(f" • Max Downloads: {self.max_downloads}")
console.print(f" • Max Transkriptionen: {self.max_transcriptions}")
console.print(f" • Whisper Model: {model_size}")
def load_cache(self):
"""Lädt den Cache"""
if self.cache_file.exists():
with open(self.cache_file, 'r') as f:
return json.load(f)
return {}
def save_cache(self):
"""Speichert den Cache"""
with open(self.cache_file, 'w') as f:
json.dump(self.cache, f, indent=2)
def get_video_hash(self, url):
"""Erstellt einen Hash für die Video-URL"""
return hashlib.md5(url.encode()).hexdigest()
def is_cached(self, url):
"""Prüft ob Video bereits transkribiert wurde"""
video_hash = self.get_video_hash(url)
if video_hash in self.cache:
cached_info = self.cache[video_hash]
output_file = Path(cached_info['output_file'])
if output_file.exists():
return cached_info
return None
def download_worker(self, job: VideoJob) -> Tuple[Optional[Path], Dict]:
"""
Worker-Funktion für Downloads
Läuft in einem Thread
"""
try:
with self.lock:
job.status = "downloading"
ydl_opts = {
'format': 'bestaudio/best',
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}],
'outtmpl': str(self.temp_dir / f'%(id)s_%(title)s.%(ext)s'),
'quiet': True,
'no_warnings': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(job.url, download=True)
# Finde die heruntergeladene Datei
video_id = info.get('id', '')
audio_files = list(self.temp_dir.glob(f"{video_id}*.mp3"))
if not audio_files:
raise Exception("Audio-Datei nicht gefunden")
audio_file = audio_files[0]
video_info = {
'title': info.get('title', 'unknown'),
'channel': info.get('uploader', 'unknown'),
'duration': info.get('duration', 0),
'url': job.url
}
with self.lock:
job.title = video_info['title']
job.duration = video_info['duration']
return audio_file, video_info
except Exception as e:
with self.lock:
job.status = "failed"
job.error = str(e)
console.print(f"[red]❌ Download-Fehler für {job.url}: {e}[/red]")
return None, {}
def transcribe_worker(self, model, audio_path: Path, job: VideoJob, video_info: Dict) -> Optional[str]:
"""
Worker-Funktion für Transkription
Läuft in einem Thread mit eigenem Whisper-Model
"""
try:
with self.lock:
job.status = "transcribing"
# Transkribiere
result = model.transcribe(
str(audio_path),
language=job.language,
verbose=False,
fp16=False # Für M1 Mac
)
transcript = result['text']
# Speichere Transkript
output_path = self.save_transcript(transcript, video_info, job.playlist_name)
# Update Cache
video_hash = self.get_video_hash(job.url)
self.cache[video_hash] = {
'url': job.url,
'title': video_info['title'],
'output_file': str(output_path),
'transcribed_at': datetime.now().isoformat(),
'model': self.model_size,
'playlist': job.playlist_name
}
self.save_cache()
# Lösche Audio-Datei
try:
audio_path.unlink()
except:
pass
with self.lock:
job.status = "completed"
job.output_path = str(output_path)
return str(output_path)
except Exception as e:
with self.lock:
job.status = "failed"
job.error = str(e)
console.print(f"[red]❌ Transkriptions-Fehler: {e}[/red]")
return None
def save_transcript(self, text, video_info, playlist_name=None):
"""Speichert Transkript"""
base_dir = self.output_dir
if playlist_name:
base_dir = base_dir / playlist_name.replace('/', '_')
base_dir.mkdir(parents=True, exist_ok=True)
channel_dir = base_dir / video_info['channel'].replace('/', '_')
channel_dir.mkdir(exist_ok=True)
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{safe_title}_{timestamp}.txt"
filepath = channel_dir / filename
with open(filepath, 'w', encoding='utf-8') as f:
f.write(f"YouTube Transkription\n")
f.write("=" * 50 + "\n\n")
f.write(f"Titel: {video_info['title']}\n")
f.write(f"Kanal: {video_info['channel']}\n")
f.write(f"URL: {video_info['url']}\n")
if playlist_name:
f.write(f"Playlist: {playlist_name}\n")
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
f.write(f"Whisper Model: {self.model_size}\n")
f.write("\n" + "=" * 50 + "\n\n")
f.write("TRANSKRIPTION:\n\n")
f.write(text)
return filepath
def process_pipeline(self, urls: List[str], playlist_name: Optional[str] = None, language: str = "de"):
"""
Haupt-Pipeline für parallele Verarbeitung
"""
# Filtere bereits transkribierte Videos
jobs_to_process = []
cached_count = 0
for url in urls:
if self.is_cached(url):
cached_count += 1
else:
job = VideoJob(url=url, playlist_name=playlist_name, language=language)
self.jobs[url] = job
jobs_to_process.append(job)
if not jobs_to_process:
console.print("[green]✅ Alle Videos bereits transkribiert![/green]")
return
# Status-Übersicht
console.print(Panel(
f"[bold]🚀 Starte parallele Verarbeitung[/bold]\n\n"
f"📊 Gesamt: {len(urls)} Videos\n"
f"✅ Gecached: {cached_count}\n"
f"🆕 Zu verarbeiten: {len(jobs_to_process)}\n\n"
f"⚡ Downloads: {self.max_downloads} parallel\n"
f"🎙️ Transkriptionen: {self.max_transcriptions} parallel",
border_style="cyan"
))
# Lade Whisper-Modelle (eines pro Thread)
console.print(f"\n[cyan]⏳ Lade {self.max_transcriptions}x Whisper {self.model_size} Modelle...[/cyan]")
models = []
for i in range(self.max_transcriptions):
model = whisper.load_model(self.model_size)
models.append(model)
console.print(f" ✅ Model {i+1}/{self.max_transcriptions} geladen")
# Progress Bar
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TimeElapsedColumn(),
TimeRemainingColumn(),
console=console
) as progress:
main_task = progress.add_task(
"[cyan]Verarbeite Videos...",
total=len(jobs_to_process)
)
# Futures für Downloads und Transkriptionen
download_futures = {}
transcribe_futures = {}
model_pool = models.copy() # Pool verfügbarer Modelle
# Starte initiale Downloads
for job in jobs_to_process[:self.max_downloads]:
future = self.download_pool.submit(self.download_worker, job)
download_futures[future] = job
remaining_jobs = jobs_to_process[self.max_downloads:]
completed_count = 0
# Haupt-Loop
while download_futures or transcribe_futures or remaining_jobs:
# Prüfe fertige Downloads
for future in list(download_futures.keys()):
if future.done():
job = download_futures.pop(future)
audio_path, video_info = future.result()
if audio_path and model_pool:
# Starte Transkription wenn Model verfügbar
model = model_pool.pop()
trans_future = self.transcribe_pool.submit(
self.transcribe_worker, model, audio_path, job, video_info
)
transcribe_futures[trans_future] = (job, model)
# Starte nächsten Download
if remaining_jobs:
next_job = remaining_jobs.pop(0)
future = self.download_pool.submit(self.download_worker, next_job)
download_futures[future] = next_job
# Prüfe fertige Transkriptionen
for future in list(transcribe_futures.keys()):
if future.done():
job, model = transcribe_futures.pop(future)
result = future.result()
# Model zurück in Pool
model_pool.append(model)
if result:
completed_count += 1
progress.update(main_task, advance=1)
console.print(f"{job.title[:50]}")
else:
console.print(f" ❌ Fehler bei: {job.url}")
# Kurze Pause für CPU
time.sleep(0.1)
# Warte auf alle verbleibenden Tasks
for future in as_completed(list(download_futures.keys()) + list(transcribe_futures.keys())):
pass
# Zusammenfassung
console.print("\n" + "=" * 60)
console.print(f"[bold green]✅ Verarbeitung abgeschlossen![/bold green]")
console.print(f"Erfolgreich: {completed_count}/{len(jobs_to_process)}")
# Zeige Fehler falls vorhanden
failed_jobs = [j for j in jobs_to_process if j.status == "failed"]
if failed_jobs:
console.print(f"\n[red]Fehlerhafte Videos:[/red]")
for job in failed_jobs:
console.print(f"{job.url}: {job.error}")
def process_playlist_file(self, playlist_path: Path, language: str = "de"):
"""Verarbeitet eine Playlist-Datei"""
urls = []
with open(playlist_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if 'youtube.com' in line or 'youtu.be' in line:
urls.append(line)
if urls:
playlist_name = playlist_path.stem
self.process_pipeline(urls, playlist_name, language)
else:
console.print(f"[yellow]Keine URLs in {playlist_path}[/yellow]")
def benchmark_parallel_vs_sequential():
"""
Benchmark-Funktion zum Vergleich
"""
console.print("\n[bold cyan]📊 Performance-Vergleich[/bold cyan]")
table = Table(title="Geschwindigkeitsvergleich")
table.add_column("Modus", style="cyan")
table.add_column("10 Videos (je 5 Min)", style="white")
table.add_column("Speedup", style="green")
table.add_row(
"Sequenziell (v3)",
"~50 Minuten",
"1x"
)
table.add_row(
"Parallel 2 Downloads",
"~25 Minuten",
"2x"
)
table.add_row(
"Parallel 3 Downloads + 2 Transkriptionen",
"~15 Minuten",
"3.3x"
)
console.print(table)
def main():
parser = argparse.ArgumentParser(
description='YouTube Transcriber v4.0 - PARALLEL EDITION'
)
parser.add_argument(
'command',
nargs='?',
choices=['process', 'benchmark'],
default='process',
help='Befehl: process oder benchmark'
)
parser.add_argument(
'--playlist',
help='Playlist-Datei'
)
parser.add_argument(
'--urls',
nargs='+',
help='Direkte URL-Liste'
)
parser.add_argument(
'--model',
default='base',
choices=['tiny', 'base', 'small', 'medium', 'large'],
help='Whisper Model'
)
parser.add_argument(
'--language',
default='de',
help='Sprache'
)
parser.add_argument(
'--max-downloads',
type=int,
default=3,
help='Max parallele Downloads (default: 3)'
)
parser.add_argument(
'--max-transcriptions',
type=int,
default=2,
help='Max parallele Transkriptionen (default: 2)'
)
args = parser.parse_args()
# Zeige Logo
console.print(LOGO)
if args.command == 'benchmark':
benchmark_parallel_vs_sequential()
return
# Initialisiere Parallel-Transcriber
transcriber = ParallelTranscriber(
model_size=args.model,
max_downloads=args.max_downloads,
max_transcriptions=args.max_transcriptions
)
if args.playlist:
# Verarbeite Playlist-Datei
playlist_path = Path(args.playlist)
if playlist_path.exists():
transcriber.process_playlist_file(playlist_path, args.language)
else:
console.print(f"[red]Playlist nicht gefunden: {args.playlist}[/red]")
elif args.urls:
# Verarbeite direkte URLs
transcriber.process_pipeline(args.urls, language=args.language)
else:
console.print("[yellow]Bitte URLs oder Playlist angeben![/yellow]")
console.print("\nBeispiele:")
console.print(" python3 transcriber_v4_parallel.py --urls URL1 URL2 URL3")
console.print(" python3 transcriber_v4_parallel.py --playlist playlists/tech/python.txt")
if __name__ == "__main__":
main()

View file

@ -1,22 +0,0 @@
{
"name": "wisekeep",
"version": "1.0.0",
"private": true,
"description": "Wisekeep - AI-powered wisdom extraction from video content",
"scripts": {
"dev": "turbo run dev",
"dev:backend": "pnpm --filter @wisekeep/backend dev",
"dev:web": "pnpm --filter @wisekeep/web dev",
"dev:landing": "pnpm --filter @wisekeep/landing dev",
"dev:mobile": "pnpm --filter @wisekeep/mobile dev",
"build": "turbo run build",
"lint": "turbo run lint",
"type-check": "turbo run type-check",
"clean": "turbo run clean"
},
"devDependencies": {
"turbo": "^1.13.4",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -1,13 +0,0 @@
{
"name": "@wisekeep/shared-types",
"version": "1.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}

View file

@ -1,92 +0,0 @@
// Transcription Job Types
export type JobStatus =
| 'pending'
| 'downloading'
| 'transcribing'
| 'completed'
| 'failed'
| 'cancelled';
export type WhisperProvider = 'groq' | 'local';
export type GroqWhisperModel = 'whisper-large-v3-turbo' | 'whisper-large-v3';
export type LocalWhisperModel = 'tiny' | 'base' | 'small' | 'medium' | 'large';
export type WhisperModel = GroqWhisperModel | LocalWhisperModel;
export interface VideoInfo {
id: string;
title: string;
channel: string;
duration: number;
thumbnail?: string;
}
export interface TranscriptionJob {
id: string;
url: string;
status: JobStatus;
progress: number;
language: string;
provider: WhisperProvider;
model?: WhisperModel;
videoInfo?: VideoInfo;
transcriptPath?: string;
transcriptText?: string;
error?: string;
createdAt: string;
completedAt?: string;
}
export interface TranscribeRequest {
url: string;
language?: string;
provider?: WhisperProvider;
model?: WhisperModel;
}
export interface TranscriptionStats {
totalTranscripts: number;
totalSizeMB: number;
activeJobs: number;
completedJobs: number;
failedJobs: number;
}
// WebSocket Event Types
export interface JobUpdateEvent {
type: 'job_update';
jobId: string;
status: JobStatus;
progress: number;
videoInfo?: VideoInfo;
}
export interface JobCompleteEvent {
type: 'job_complete';
jobId: string;
status: 'completed';
transcriptPath: string;
}
export interface JobErrorEvent {
type: 'job_error';
jobId: string;
error: string;
}
export type WebSocketEvent = JobUpdateEvent | JobCompleteEvent | JobErrorEvent;
// Playlist Types
export interface Playlist {
name: string;
category: string;
urls: string[];
createdAt: string;
updatedAt: string;
}
export interface PlaylistSummary {
category: string;
name: string;
urlCount: number;
}

View file

@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

53
apps/wisekeep/CLAUDE.md Normal file
View file

@ -0,0 +1,53 @@
# Wisekeep — AI Wisdom Extraction from Video
## Architecture
Local-first for transcripts/playlists, Hono/Bun server for Groq Whisper transcription.
```
Browser → IndexedDB (Transcripts, Playlists)
↕ sync
mana-sync → PostgreSQL
Browser → Hono Server → yt-dlp (download) → Groq Whisper (transcribe)
```
## Project Structure
```
apps/wisekeep/
├── apps/
│ ├── web/ # SvelteKit web app (local-first)
│ ├── server/ # Hono/Bun (transcription via Groq)
│ └── landing/ # Astro content site (curated talks)
└── package.json
```
## Commands
```bash
pnpm dev:wisekeep:web # SvelteKit dev server
pnpm dev:wisekeep:server # Hono/Bun server (port 3072)
pnpm dev:wisekeep:landing # Landing page
pnpm dev:wisekeep:local # Web + Sync + Server (no auth)
pnpm dev:wisekeep:full # Everything incl. auth
```
## Server Routes
| Route | Auth | Description |
|-------|------|-------------|
| `GET /health` | No | Health check |
| `POST /api/v1/transcribe` | JWT | Transcribe YouTube URL via Groq |
## Prerequisites
- `yt-dlp` installed (`brew install yt-dlp`)
- `GROQ_API_KEY` env variable set
## Local-First Collections
| Collection | Purpose |
|-----------|---------|
| `transcripts` | Video transcriptions (title, channel, transcript text) |
| `playlists` | Organized collections of transcripts |

View file

@ -4,8 +4,5 @@ import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [
solidJs(),
tailwind()
]
});
integrations: [solidJs(), tailwind()],
});

Some files were not shown because too many files have changed in this diff Show more