# Media Upload & Embed Specification
**Version:** 1.2
**Datum:** 2026-05-07
**Status:** Aktuell für Upload-Limits, MIME, Embed — **zentraler Medien-Ist-Stand** inkl. Archiv, Lifecycle, Pfadkonvention: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**
> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung, später Inline im Fließtext:**
> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (§11 Leitplanken Inline, ohne Umsetzung).
> Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor.
---
## 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
**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
- 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
```
### 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
```
### 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
{/* Primary Media (groß oben) */}
{primaryMedia && (
{renderMedia(primaryMedia, 'large')}
)}
{/* Grid mit restlichen Medien */}
{secondaryMedia.map(media => (
{renderMedia(media, 'thumbnail')}
{media.title}
{media.context}
))}
const renderMedia = (media, size) => {
// Lokale Datei
if (media.file_path) {
if (media.media_type === 'image') {
return
}
if (media.media_type === 'video') {
return
}
if (media.media_type === 'document') {
return 📄 {media.title}
}
}
// Embed
if (media.embed_url) {
return (
)
}
}
```
### 4.2 Lightbox (Vollbild-Ansicht)
**Pattern:**
```jsx
const [lightboxMedia, setLightboxMedia] = useState(null)
setLightboxMedia(media)}
/>
{lightboxMedia && (
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
(
{media.title || media.original_filename}
{media.is_primary &&
Hauptbild}
)}
/>
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
```
---
## 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