All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
- Introduced a centralized media archive (`/media`) with lifecycle management, including soft delete and recovery options. - Enhanced media upload functionality to support multiple files and automatic type inference. - Updated documentation to reflect the new media architecture and inline media linking specifications. - Version bump to 0.8.59 to accommodate changes in media handling and database schema. Co-authored-by: Cursor <cursoragent@cursor.com>
783 lines
21 KiB
Markdown
783 lines
21 KiB
Markdown
# 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
|
||
<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
|