shinkan-jinkendo/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md
Lars b6de1f15ea
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
feat(media): implement centralized media archive and inline media linking
- 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>
2026-05-08 10:56:43 +02:00

783 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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