2026-04-24 14:41:52 +02:00
|
|
|
|
# Media Upload & Embed Specification
|
|
|
|
|
|
|
2026-04-28 16:18:25 +02:00
|
|
|
|
**Version:** 1.1
|
|
|
|
|
|
**Datum:** 2026-04-27
|
2026-04-24 14:41:52 +02:00
|
|
|
|
**Status:** DRAFT - Awaiting Review
|
2026-04-28 16:18:25 +02:00
|
|
|
|
**Autor:** Claude Code
|
|
|
|
|
|
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
|
2026-04-24 14:41:52 +02:00
|
|
|
|
|
2026-05-07 12:36:46 +02:00
|
|
|
|
> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung:**
|
|
|
|
|
|
> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (gleicher Ordner).
|
|
|
|
|
|
> Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor.
|
|
|
|
|
|
|
2026-04-24 14:41:52 +02:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 1. Upload-Strategie
|
|
|
|
|
|
|
|
|
|
|
|
### 1.1 Hybrid-Ansatz
|
|
|
|
|
|
|
|
|
|
|
|
**Zwei Medienquellen:**
|
|
|
|
|
|
1. **Lokale Uploads:** Datei-Upload auf Server (Images, Videos, PDFs, Sketches)
|
|
|
|
|
|
2. **Embeds:** Einbetten von YouTube, Instagram, Vimeo, TikTok
|
|
|
|
|
|
|
|
|
|
|
|
**Vorteile:**
|
|
|
|
|
|
- Lokale Kontrolle über kritische Inhalte
|
|
|
|
|
|
- Keine Abhängigkeit von Drittanbietern für Kern-Content
|
|
|
|
|
|
- Externe Plattformen für ergänzende Videos
|
|
|
|
|
|
|
2026-04-28 16:18:25 +02:00
|
|
|
|
**Limitierungen (implementiert):**
|
|
|
|
|
|
- Max. Dateigröße: **50 MB** Standardnutzer (`EXERCISE_MEDIA_MAX_UPLOAD_MB`, Default 50).
|
|
|
|
|
|
- **`admin` / `superadmin`**: höheres Limit (**1024 MB** Default `EXERCISE_MEDIA_ADMIN_MAX_UPLOAD_MB`; nie unter dem Nutzer-Limit in MB).
|
|
|
|
|
|
- Max. 10 Media-Items pro Übung (kombiniert Local + Embeds) — siehe Backend-Validierung
|
2026-04-24 14:41:52 +02:00
|
|
|
|
- Erlaubte Formate: JPEG, PNG, GIF, MP4, PDF
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 2. Upload-Flow (Lokale Dateien)
|
|
|
|
|
|
|
|
|
|
|
|
### 2.1 Frontend-Komponente: MediaUploader
|
|
|
|
|
|
|
|
|
|
|
|
**Props:**
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface MediaUploaderProps {
|
|
|
|
|
|
exerciseId: number
|
|
|
|
|
|
existingMedia: Media[]
|
|
|
|
|
|
onUploadComplete: (media: Media) => void
|
|
|
|
|
|
maxFiles?: number // default: 10
|
|
|
|
|
|
maxSizeMB?: number // default: 50
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**UI-Struktur:**
|
|
|
|
|
|
```jsx
|
|
|
|
|
|
<MediaUploader>
|
|
|
|
|
|
<div className="upload-zone">
|
|
|
|
|
|
{/* Drag & Drop Area */}
|
|
|
|
|
|
<input type="file" multiple accept="image/*,video/mp4,application/pdf" />
|
|
|
|
|
|
|
|
|
|
|
|
{/* Preview Grid */}
|
|
|
|
|
|
<div className="media-grid">
|
|
|
|
|
|
{uploadQueue.map(file => (
|
|
|
|
|
|
<div key={file.name} className="upload-preview">
|
|
|
|
|
|
<img src={file.preview} />
|
|
|
|
|
|
<ProgressBar percent={file.uploadProgress} />
|
|
|
|
|
|
<button onClick={() => removeFromQueue(file)}>×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Metadata Form (per File) */}
|
|
|
|
|
|
<div className="upload-metadata">
|
|
|
|
|
|
<input name="title" placeholder="Titel (optional)" />
|
|
|
|
|
|
<textarea name="description" placeholder="Beschreibung" />
|
|
|
|
|
|
<select name="context">
|
|
|
|
|
|
<option value="ablauf">Ablauf</option>
|
|
|
|
|
|
<option value="detail">Detail</option>
|
|
|
|
|
|
<option value="trainer_hint">Trainer-Hinweis</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<input type="checkbox" name="is_primary" />
|
|
|
|
|
|
Als Hauptbild markieren
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button onClick={handleUpload}>
|
|
|
|
|
|
{uploadQueue.length} Dateien hochladen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</MediaUploader>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.2 Client-seitige Validierung
|
|
|
|
|
|
|
|
|
|
|
|
**Vor Upload prüfen:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// MediaUploader.jsx
|
|
|
|
|
|
const validateFile = (file) => {
|
|
|
|
|
|
const errors = []
|
|
|
|
|
|
|
|
|
|
|
|
// Größe
|
|
|
|
|
|
const maxSize = 50 * 1024 * 1024 // 50 MB
|
|
|
|
|
|
if (file.size > maxSize) {
|
|
|
|
|
|
errors.push(`${file.name} ist zu groß (max. 50 MB)`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MIME-Type
|
|
|
|
|
|
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'application/pdf']
|
|
|
|
|
|
if (!allowed.includes(file.type)) {
|
|
|
|
|
|
errors.push(`${file.name} hat ungültiges Format`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Video-spezifisch: Auflösung prüfen
|
|
|
|
|
|
if (file.type === 'video/mp4') {
|
|
|
|
|
|
const video = document.createElement('video')
|
|
|
|
|
|
video.src = URL.createObjectURL(file)
|
|
|
|
|
|
video.onloadedmetadata = () => {
|
|
|
|
|
|
if (video.videoWidth > 1920 || video.videoHeight > 1080) {
|
|
|
|
|
|
errors.push(`${file.name} Auflösung zu hoch (max. 1920x1080)`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return errors
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleFilesSelected = (files) => {
|
|
|
|
|
|
const validFiles = []
|
|
|
|
|
|
const errors = []
|
|
|
|
|
|
|
|
|
|
|
|
files.forEach(file => {
|
|
|
|
|
|
const fileErrors = validateFile(file)
|
|
|
|
|
|
if (fileErrors.length === 0) {
|
|
|
|
|
|
validFiles.push(file)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
errors.push(...fileErrors)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (errors.length > 0) {
|
|
|
|
|
|
setValidationErrors(errors)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setUploadQueue(prev => [...prev, ...validFiles])
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.3 Upload mit Progress
|
|
|
|
|
|
|
|
|
|
|
|
**multipart/form-data Upload:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
const uploadFile = async (file, metadata) => {
|
|
|
|
|
|
const formData = new FormData()
|
|
|
|
|
|
formData.append('file', file)
|
|
|
|
|
|
formData.append('media_type', detectMediaType(file))
|
|
|
|
|
|
formData.append('title', metadata.title || '')
|
|
|
|
|
|
formData.append('description', metadata.description || '')
|
|
|
|
|
|
formData.append('context', metadata.context || 'ablauf')
|
|
|
|
|
|
formData.append('is_primary', metadata.is_primary || false)
|
|
|
|
|
|
|
|
|
|
|
|
return axios.post(
|
|
|
|
|
|
`/api/exercises/${exerciseId}/media`,
|
|
|
|
|
|
formData,
|
|
|
|
|
|
{
|
|
|
|
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
|
|
|
|
onUploadProgress: (progressEvent) => {
|
|
|
|
|
|
const percent = (progressEvent.loaded / progressEvent.total) * 100
|
|
|
|
|
|
setUploadProgress(file.name, percent)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const detectMediaType = (file) => {
|
|
|
|
|
|
if (file.type.startsWith('image/')) return 'image'
|
|
|
|
|
|
if (file.type === 'video/mp4') return 'video'
|
|
|
|
|
|
if (file.type === 'application/pdf') return 'document'
|
|
|
|
|
|
return 'sketch' // Fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.4 Backend-Verarbeitung
|
|
|
|
|
|
|
|
|
|
|
|
**Endpoint:** `POST /api/exercises/{exercise_id}/media`
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
from fastapi import UploadFile, File, Form
|
|
|
|
|
|
import magic # python-magic für MIME-Detection
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/exercises/{exercise_id}/media")
|
|
|
|
|
|
async def upload_media(
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
file: UploadFile = File(...),
|
|
|
|
|
|
media_type: str = Form(...),
|
|
|
|
|
|
title: str = Form(default=""),
|
|
|
|
|
|
description: str = Form(default=""),
|
|
|
|
|
|
context: str = Form(default="ablauf"),
|
|
|
|
|
|
is_primary: bool = Form(default=False),
|
|
|
|
|
|
session: dict = Depends(require_auth)
|
|
|
|
|
|
):
|
|
|
|
|
|
profile_id = session['profile_id']
|
|
|
|
|
|
|
|
|
|
|
|
# 1. Berechtigung prüfen
|
|
|
|
|
|
check_exercise_permission(exercise_id, profile_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. Datei-Validierung
|
|
|
|
|
|
max_size = 50 * 1024 * 1024 # 50 MB
|
|
|
|
|
|
file_content = await file.read()
|
|
|
|
|
|
if len(file_content) > max_size:
|
|
|
|
|
|
raise HTTPException(413, "Datei zu groß (max. 50 MB)")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. MIME-Type verifizieren (nicht nur Extension!)
|
|
|
|
|
|
mime = magic.from_buffer(file_content, mime=True)
|
|
|
|
|
|
allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'video/mp4', 'application/pdf']
|
|
|
|
|
|
if mime not in allowed_mimes:
|
|
|
|
|
|
raise HTTPException(400, f"Ungültiger Dateityp: {mime}")
|
|
|
|
|
|
|
|
|
|
|
|
# 4. Eindeutigen Dateinamen generieren
|
|
|
|
|
|
file_hash = hashlib.sha256(file_content).hexdigest()[:12]
|
|
|
|
|
|
file_ext = os.path.splitext(file.filename)[1]
|
|
|
|
|
|
safe_filename = f"{file_hash}_{exercise_id}{file_ext}"
|
|
|
|
|
|
|
|
|
|
|
|
# 5. Speichern
|
|
|
|
|
|
file_path = f"/app/media/exercises/{safe_filename}"
|
|
|
|
|
|
with open(file_path, 'wb') as f:
|
|
|
|
|
|
f.write(file_content)
|
|
|
|
|
|
|
|
|
|
|
|
# 6. DB-Eintrag
|
|
|
|
|
|
cur = get_db_cursor()
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO exercise_media (
|
|
|
|
|
|
exercise_id, media_type, file_path, file_size, mime_type,
|
|
|
|
|
|
original_filename, title, description, context, is_primary, sort_order
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
|
|
|
|
|
COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)
|
|
|
|
|
|
)
|
|
|
|
|
|
RETURNING id, sort_order, created_at
|
|
|
|
|
|
""", (
|
|
|
|
|
|
exercise_id, media_type, file_path, len(file_content), mime,
|
|
|
|
|
|
file.filename, title, description, context, is_primary, exercise_id
|
|
|
|
|
|
))
|
|
|
|
|
|
result = cur.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": result['id'],
|
|
|
|
|
|
"exercise_id": exercise_id,
|
|
|
|
|
|
"media_type": media_type,
|
|
|
|
|
|
"file_path": file_path,
|
|
|
|
|
|
"file_size": len(file_content),
|
|
|
|
|
|
"mime_type": mime,
|
|
|
|
|
|
"original_filename": file.filename,
|
|
|
|
|
|
"title": title,
|
|
|
|
|
|
"description": description,
|
|
|
|
|
|
"context": context,
|
|
|
|
|
|
"is_primary": is_primary,
|
|
|
|
|
|
"sort_order": result['sort_order'],
|
|
|
|
|
|
"created_at": result['created_at'].isoformat(),
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 3. Embed-Flow (YouTube, Instagram, Vimeo)
|
|
|
|
|
|
|
|
|
|
|
|
### 3.1 Frontend-Komponente: EmbedInput
|
|
|
|
|
|
|
|
|
|
|
|
**UI:**
|
|
|
|
|
|
```jsx
|
|
|
|
|
|
<div className="embed-input">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="url"
|
|
|
|
|
|
placeholder="YouTube, Instagram oder Vimeo URL einfügen"
|
|
|
|
|
|
value={embedUrl}
|
|
|
|
|
|
onChange={(e) => setEmbedUrl(e.target.value)}
|
|
|
|
|
|
onBlur={handleEmbedParse}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{preview && (
|
|
|
|
|
|
<div className="embed-preview">
|
|
|
|
|
|
<iframe src={preview.embedUrl} />
|
|
|
|
|
|
<div className="embed-metadata">
|
|
|
|
|
|
<strong>{preview.platform}</strong> • {preview.title}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="embed-metadata-form">
|
|
|
|
|
|
<input name="title" placeholder="Titel (optional)" />
|
|
|
|
|
|
<textarea name="description" />
|
|
|
|
|
|
<select name="context">
|
|
|
|
|
|
<option value="ablauf">Ablauf</option>
|
|
|
|
|
|
<option value="detail">Detail</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button onClick={handleEmbedSave}>Embed hinzufügen</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.2 URL-Parsing (Client-Side)
|
|
|
|
|
|
|
|
|
|
|
|
**Unterstützte Formate:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
const parseEmbedUrl = (url) => {
|
|
|
|
|
|
// YouTube
|
|
|
|
|
|
if (url.includes('youtube.com/watch?v=')) {
|
|
|
|
|
|
const videoId = new URL(url).searchParams.get('v')
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform: 'youtube',
|
|
|
|
|
|
videoId,
|
|
|
|
|
|
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
|
|
|
|
|
|
originalUrl: url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (url.includes('youtu.be/')) {
|
|
|
|
|
|
const videoId = url.split('youtu.be/')[1].split('?')[0]
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform: 'youtube',
|
|
|
|
|
|
videoId,
|
|
|
|
|
|
embedUrl: `https://www.youtube-nocookie.com/embed/${videoId}`,
|
|
|
|
|
|
originalUrl: url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Vimeo
|
|
|
|
|
|
if (url.includes('vimeo.com/')) {
|
|
|
|
|
|
const videoId = url.split('vimeo.com/')[1].split('?')[0]
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform: 'vimeo',
|
|
|
|
|
|
videoId,
|
|
|
|
|
|
embedUrl: `https://player.vimeo.com/video/${videoId}`,
|
|
|
|
|
|
originalUrl: url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Instagram (Reel oder Post)
|
|
|
|
|
|
if (url.includes('instagram.com/reel/') || url.includes('instagram.com/p/')) {
|
|
|
|
|
|
const postId = url.match(/\/(reel|p)\/([^\/\?]+)/)[2]
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform: 'instagram',
|
|
|
|
|
|
postId,
|
|
|
|
|
|
embedUrl: `https://www.instagram.com/p/${postId}/embed`,
|
|
|
|
|
|
originalUrl: url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TikTok
|
|
|
|
|
|
if (url.includes('tiktok.com/')) {
|
|
|
|
|
|
const videoId = url.match(/video\/(\d+)/)?.[1]
|
|
|
|
|
|
return {
|
|
|
|
|
|
platform: 'tiktok',
|
|
|
|
|
|
videoId,
|
|
|
|
|
|
embedUrl: `https://www.tiktok.com/embed/v2/${videoId}`,
|
|
|
|
|
|
originalUrl: url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleEmbedParse = async () => {
|
|
|
|
|
|
const parsed = parseEmbedUrl(embedUrl)
|
|
|
|
|
|
|
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
|
setError('Ungültige URL. Unterstützt: YouTube, Vimeo, Instagram, TikTok')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Optional: Metadata via oEmbed API holen
|
|
|
|
|
|
const metadata = await fetchOembedData(parsed)
|
|
|
|
|
|
|
|
|
|
|
|
setPreview({ ...parsed, ...metadata })
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.3 oEmbed Metadata (Optional)
|
|
|
|
|
|
|
|
|
|
|
|
**YouTube oEmbed:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
const fetchYoutubeMetadata = async (videoId) => {
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
|
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
|
|
|
|
|
|
)
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: data.title,
|
|
|
|
|
|
author: data.author_name,
|
|
|
|
|
|
thumbnail: data.thumbnail_url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Vimeo oEmbed:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
const fetchVimeoMetadata = async (videoId) => {
|
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
|
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`
|
|
|
|
|
|
)
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
return {
|
|
|
|
|
|
title: data.title,
|
|
|
|
|
|
author: data.author_name,
|
|
|
|
|
|
thumbnail: data.thumbnail_url,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.4 Backend-Speicherung (Embed)
|
|
|
|
|
|
|
|
|
|
|
|
**Endpoint:** `POST /api/exercises/{exercise_id}/media` (gleicher Endpoint)
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
@router.post("/exercises/{exercise_id}/media")
|
|
|
|
|
|
async def add_media(
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
# Entweder file ODER embed_url (nicht beides)
|
|
|
|
|
|
file: UploadFile = File(default=None),
|
|
|
|
|
|
embed_url: str = Form(default=None),
|
|
|
|
|
|
media_type: str = Form(...),
|
|
|
|
|
|
title: str = Form(default=""),
|
|
|
|
|
|
description: str = Form(default=""),
|
|
|
|
|
|
context: str = Form(default="ablauf"),
|
|
|
|
|
|
session: dict = Depends(require_auth)
|
|
|
|
|
|
):
|
|
|
|
|
|
if file and embed_url:
|
|
|
|
|
|
raise HTTPException(400, "Entweder file oder embed_url, nicht beides")
|
|
|
|
|
|
|
|
|
|
|
|
if not file and not embed_url:
|
|
|
|
|
|
raise HTTPException(400, "file oder embed_url erforderlich")
|
|
|
|
|
|
|
|
|
|
|
|
if embed_url:
|
|
|
|
|
|
# Embed-URL validieren
|
|
|
|
|
|
platform = detect_platform(embed_url)
|
|
|
|
|
|
if not platform:
|
|
|
|
|
|
raise HTTPException(400, "Ungültige Embed-URL")
|
|
|
|
|
|
|
|
|
|
|
|
# DB-Eintrag (ohne file_path)
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
INSERT INTO exercise_media (
|
|
|
|
|
|
exercise_id, media_type, embed_url, embed_platform,
|
|
|
|
|
|
title, description, context, is_primary, sort_order
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s,
|
|
|
|
|
|
COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)
|
|
|
|
|
|
)
|
|
|
|
|
|
RETURNING id, sort_order, created_at
|
|
|
|
|
|
""", (
|
|
|
|
|
|
exercise_id, media_type, embed_url, platform,
|
|
|
|
|
|
title, description, context, False, exercise_id
|
|
|
|
|
|
))
|
|
|
|
|
|
# ... return result
|
|
|
|
|
|
|
|
|
|
|
|
# ... else: file upload (siehe 2.4)
|
|
|
|
|
|
|
|
|
|
|
|
def detect_platform(url):
|
|
|
|
|
|
if 'youtube.com' in url or 'youtu.be' in url:
|
|
|
|
|
|
return 'youtube'
|
|
|
|
|
|
if 'vimeo.com' in url:
|
|
|
|
|
|
return 'vimeo'
|
|
|
|
|
|
if 'instagram.com' in url:
|
|
|
|
|
|
return 'instagram'
|
|
|
|
|
|
if 'tiktok.com' in url:
|
|
|
|
|
|
return 'tiktok'
|
|
|
|
|
|
return None
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 4. Media-Galerie (Anzeige)
|
|
|
|
|
|
|
|
|
|
|
|
### 4.1 Komponente: MediaGallery
|
|
|
|
|
|
|
|
|
|
|
|
**Props:**
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface MediaGalleryProps {
|
|
|
|
|
|
media: Media[]
|
|
|
|
|
|
primaryMediaId?: number
|
|
|
|
|
|
onMediaClick?: (media: Media) => void
|
|
|
|
|
|
layout?: 'grid' | 'list' | 'carousel'
|
|
|
|
|
|
showControls?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Render-Logik:**
|
|
|
|
|
|
```jsx
|
|
|
|
|
|
<div className="media-gallery">
|
|
|
|
|
|
{/* Primary Media (groß oben) */}
|
|
|
|
|
|
{primaryMedia && (
|
|
|
|
|
|
<div className="primary-media">
|
|
|
|
|
|
{renderMedia(primaryMedia, 'large')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Grid mit restlichen Medien */}
|
|
|
|
|
|
<div className="media-grid">
|
|
|
|
|
|
{secondaryMedia.map(media => (
|
|
|
|
|
|
<div key={media.id} className="media-item">
|
|
|
|
|
|
{renderMedia(media, 'thumbnail')}
|
|
|
|
|
|
<div className="media-overlay">
|
|
|
|
|
|
<span className="media-title">{media.title}</span>
|
|
|
|
|
|
<span className="media-context">{media.context}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
const renderMedia = (media, size) => {
|
|
|
|
|
|
// Lokale Datei
|
|
|
|
|
|
if (media.file_path) {
|
|
|
|
|
|
if (media.media_type === 'image') {
|
|
|
|
|
|
return <img src={`/media/${media.file_path}`} alt={media.title} />
|
|
|
|
|
|
}
|
|
|
|
|
|
if (media.media_type === 'video') {
|
|
|
|
|
|
return <video src={`/media/${media.file_path}`} controls />
|
|
|
|
|
|
}
|
|
|
|
|
|
if (media.media_type === 'document') {
|
|
|
|
|
|
return <a href={`/media/${media.file_path}`} target="_blank">📄 {media.title}</a>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Embed
|
|
|
|
|
|
if (media.embed_url) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<iframe
|
|
|
|
|
|
src={media.embed_url}
|
|
|
|
|
|
frameBorder="0"
|
|
|
|
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
|
|
|
|
allowFullScreen
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 4.2 Lightbox (Vollbild-Ansicht)
|
|
|
|
|
|
|
|
|
|
|
|
**Pattern:**
|
|
|
|
|
|
```jsx
|
|
|
|
|
|
const [lightboxMedia, setLightboxMedia] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
<MediaGallery
|
|
|
|
|
|
media={exercise.media}
|
|
|
|
|
|
onMediaClick={(media) => setLightboxMedia(media)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{lightboxMedia && (
|
|
|
|
|
|
<Lightbox
|
|
|
|
|
|
media={lightboxMedia}
|
|
|
|
|
|
onClose={() => setLightboxMedia(null)}
|
|
|
|
|
|
onPrev={() => setLightboxMedia(getPrevMedia(lightboxMedia))}
|
|
|
|
|
|
onNext={() => setLightboxMedia(getNextMedia(lightboxMedia))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 5. Drag & Drop Reordering
|
|
|
|
|
|
|
|
|
|
|
|
### 5.1 Frontend-Komponente
|
|
|
|
|
|
|
|
|
|
|
|
**Nutzt DragDropList aus UI_COMPONENTS_SPEC.md:**
|
|
|
|
|
|
```jsx
|
|
|
|
|
|
<DragDropList
|
|
|
|
|
|
items={media}
|
|
|
|
|
|
onReorder={handleMediaReorder}
|
|
|
|
|
|
renderItem={(media) => (
|
|
|
|
|
|
<div className="media-reorder-item">
|
|
|
|
|
|
<DragHandle />
|
|
|
|
|
|
<img src={getThumbnail(media)} />
|
|
|
|
|
|
<span>{media.title || media.original_filename}</span>
|
|
|
|
|
|
{media.is_primary && <Badge>Hauptbild</Badge>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
const handleMediaReorder = async (reorderedMedia) => {
|
|
|
|
|
|
const mediaIds = reorderedMedia.map(m => m.id)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.reorderExerciseMedia(exerciseId, mediaIds)
|
|
|
|
|
|
setMedia(reorderedMedia)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Reorder failed:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 5.2 Backend-Endpoint
|
|
|
|
|
|
|
|
|
|
|
|
**Endpoint:** `PUT /api/exercises/{exercise_id}/media/reorder`
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
@router.put("/exercises/{exercise_id}/media/reorder")
|
|
|
|
|
|
def reorder_media(
|
|
|
|
|
|
exercise_id: int,
|
|
|
|
|
|
request: dict, # {"media_ids": [3, 1, 2]}
|
|
|
|
|
|
session: dict = Depends(require_auth)
|
|
|
|
|
|
):
|
|
|
|
|
|
profile_id = session['profile_id']
|
|
|
|
|
|
check_exercise_permission(exercise_id, profile_id)
|
|
|
|
|
|
|
|
|
|
|
|
media_ids = request.get('media_ids', [])
|
|
|
|
|
|
|
|
|
|
|
|
# Validierung: Alle IDs gehören zur Exercise
|
|
|
|
|
|
cur = get_db_cursor()
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT id FROM exercise_media WHERE exercise_id = %s
|
|
|
|
|
|
""", (exercise_id,))
|
|
|
|
|
|
existing_ids = {row['id'] for row in cur.fetchall()}
|
|
|
|
|
|
|
|
|
|
|
|
if set(media_ids) != existing_ids:
|
|
|
|
|
|
raise HTTPException(400, "media_ids inkonsistent")
|
|
|
|
|
|
|
|
|
|
|
|
# Reorder
|
|
|
|
|
|
for idx, media_id in enumerate(media_ids, start=1):
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
UPDATE exercise_media SET sort_order = %s WHERE id = %s
|
|
|
|
|
|
""", (idx, media_id))
|
|
|
|
|
|
|
|
|
|
|
|
return {"ok": True, "reordered": len(media_ids)}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 6. Security & Performance
|
|
|
|
|
|
|
|
|
|
|
|
### 6.1 Security
|
|
|
|
|
|
|
|
|
|
|
|
**File Upload:**
|
|
|
|
|
|
- MIME-Type-Validierung mit `python-magic` (nicht nur Extension)
|
|
|
|
|
|
- Max. Dateigröße serverseitig prüfen
|
|
|
|
|
|
- Virus-Scan optional (ClamAV) bei Prod-System
|
|
|
|
|
|
- Keine Ausführungsrechte auf Upload-Ordner
|
|
|
|
|
|
|
|
|
|
|
|
**Embed:**
|
|
|
|
|
|
- Nur Whitelist-Domains (youtube.com, vimeo.com, instagram.com, tiktok.com)
|
|
|
|
|
|
- Keine User-Content-Domains (pastebin, file-sharing)
|
|
|
|
|
|
|
|
|
|
|
|
### 6.2 Performance
|
|
|
|
|
|
|
|
|
|
|
|
**Thumbnails generieren (Videos):**
|
|
|
|
|
|
```python
|
|
|
|
|
|
import ffmpeg
|
|
|
|
|
|
|
|
|
|
|
|
def generate_video_thumbnail(video_path, output_path):
|
|
|
|
|
|
(
|
|
|
|
|
|
ffmpeg
|
|
|
|
|
|
.input(video_path, ss='00:00:01') # 1 Sekunde ins Video
|
|
|
|
|
|
.filter('scale', 320, -1)
|
|
|
|
|
|
.output(output_path, vframes=1)
|
|
|
|
|
|
.run()
|
|
|
|
|
|
)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Responsive Images (automatisch generieren):**
|
|
|
|
|
|
```python
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
def create_responsive_sizes(image_path):
|
|
|
|
|
|
img = Image.open(image_path)
|
|
|
|
|
|
sizes = {
|
|
|
|
|
|
'thumbnail': (320, 240),
|
|
|
|
|
|
'medium': (800, 600),
|
|
|
|
|
|
'large': (1920, 1080),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for size_name, (width, height) in sizes.items():
|
|
|
|
|
|
img_resized = img.copy()
|
|
|
|
|
|
img_resized.thumbnail((width, height), Image.LANCZOS)
|
|
|
|
|
|
output_path = image_path.replace('.jpg', f'_{size_name}.jpg')
|
|
|
|
|
|
img_resized.save(output_path, quality=85)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Frontend nutzt `srcset`:**
|
|
|
|
|
|
```jsx
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={`/media/${media.file_path}`}
|
|
|
|
|
|
srcSet={`
|
|
|
|
|
|
/media/${media.file_path.replace('.jpg', '_thumbnail.jpg')} 320w,
|
|
|
|
|
|
/media/${media.file_path.replace('.jpg', '_medium.jpg')} 800w,
|
|
|
|
|
|
/media/${media.file_path} 1920w
|
|
|
|
|
|
`}
|
|
|
|
|
|
sizes="(max-width: 600px) 320px, (max-width: 1200px) 800px, 1920px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 7. Error-Handling
|
|
|
|
|
|
|
|
|
|
|
|
### 7.1 Upload-Fehler
|
|
|
|
|
|
|
|
|
|
|
|
**Frontend:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
try {
|
|
|
|
|
|
await uploadFile(file, metadata)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error.response?.status === 413) {
|
|
|
|
|
|
setError('Datei zu groß (max. 50 MB)')
|
|
|
|
|
|
} else if (error.response?.status === 400) {
|
|
|
|
|
|
setError(error.response.data.detail)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError('Upload fehlgeschlagen. Bitte erneut versuchen.')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Backend:**
|
|
|
|
|
|
```python
|
|
|
|
|
|
# Detaillierte Fehlermeldungen
|
|
|
|
|
|
if len(file_content) > max_size:
|
|
|
|
|
|
raise HTTPException(413, f"Datei zu groß: {len(file_content) / (1024*1024):.1f} MB (max. 50 MB)")
|
|
|
|
|
|
|
|
|
|
|
|
if mime not in allowed_mimes:
|
|
|
|
|
|
raise HTTPException(400, f"Dateityp {mime} nicht erlaubt. Erlaubt: {', '.join(allowed_mimes)}")
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 7.2 Embed-Fehler
|
|
|
|
|
|
|
|
|
|
|
|
**Ungültige URL:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
if (!parseEmbedUrl(url)) {
|
|
|
|
|
|
setError('URL nicht erkannt. Unterstützte Plattformen: YouTube, Vimeo, Instagram, TikTok')
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Embed lädt nicht:**
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
<iframe
|
|
|
|
|
|
src={embedUrl}
|
|
|
|
|
|
onError={() => {
|
|
|
|
|
|
setEmbedError('Video konnte nicht geladen werden. Ist das Video öffentlich?')
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 8. Testing
|
|
|
|
|
|
|
|
|
|
|
|
### 8.1 Upload-Tests
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// upload.spec.js
|
|
|
|
|
|
test('Upload von JPEG-Datei funktioniert', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/exercises/1/edit')
|
|
|
|
|
|
|
|
|
|
|
|
const fileInput = page.locator('input[type="file"]')
|
|
|
|
|
|
await fileInput.setInputFiles('test-files/image.jpg')
|
|
|
|
|
|
|
|
|
|
|
|
await page.click('button:has-text("Hochladen")')
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('.media-gallery img')).toBeVisible()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('Upload > 50 MB wird abgelehnt', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/exercises/1/edit')
|
|
|
|
|
|
|
|
|
|
|
|
await page.setInputFiles('input[type="file"]', 'test-files/large-video.mp4')
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('.error:has-text("zu groß")')).toBeVisible()
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 8.2 Embed-Tests
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
test('YouTube URL wird korrekt geparst', async ({ page }) => {
|
|
|
|
|
|
await page.goto('/exercises/1/edit')
|
|
|
|
|
|
|
|
|
|
|
|
await page.fill('input[placeholder*="YouTube"]', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
|
|
|
|
|
await page.blur('input[placeholder*="YouTube"]')
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('iframe[src*="youtube-nocookie.com"]')).toBeVisible()
|
|
|
|
|
|
})
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
**Version:** 1.0
|
|
|
|
|
|
**Letzte Änderung:** 2026-04-24
|
|
|
|
|
|
**Status:** DRAFT - Awaiting Review
|