- Added new documentation for media assets and lifecycle management, establishing a single source of truth in MEDIA_ASSETS_AND_ARCHIVE_SPEC.md. - Updated project status to reflect the addition of media archive and lifecycle governance. - Introduced a new API endpoint for platform media storage, allowing superadmin access for media management. - Enhanced exercise media handling with improved database integration for media assets, including deduplication and effective media root resolution. - Updated frontend API utilities to support new media storage functionalities, ensuring seamless integration with the backend. - Incremented version to 0.8.41, reflecting the latest changes and improvements in media handling.
20 KiB
Media Upload & Embed Specification
Version: 1.1
Datum: 2026-04-27
Status: DRAFT - Awaiting Review
Autor: Claude Code
Änderungen v1.1: Rollenbasierte Server-Limits (EXERCISE_MEDIA_*_MB)
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.
1. Upload-Strategie
1.1 Hybrid-Ansatz
Zwei Medienquellen:
- Lokale Uploads: Datei-Upload auf Server (Images, Videos, PDFs, Sketches)
- 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 DefaultEXERCISE_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:
interface MediaUploaderProps {
exerciseId: number
existingMedia: Media[]
onUploadComplete: (media: Media) => void
maxFiles?: number // default: 10
maxSizeMB?: number // default: 50
}
UI-Struktur:
<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:
// 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:
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
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:
<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:
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:
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:
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)
@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:
interface MediaGalleryProps {
media: Media[]
primaryMediaId?: number
onMediaClick?: (media: Media) => void
layout?: 'grid' | 'list' | 'carousel'
showControls?: boolean
}
Render-Logik:
<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:
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:
<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
@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):
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):
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:
<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:
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:
# 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:
if (!parseEmbedUrl(url)) {
setError('URL nicht erkannt. Unterstützte Plattformen: YouTube, Vimeo, Instagram, TikTok')
}
Embed lädt nicht:
<iframe
src={embedUrl}
onError={() => {
setEmbedError('Video konnte nicht geladen werden. Ist das Video öffentlich?')
}}
/>
8. Testing
8.1 Upload-Tests
// 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
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