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

21 KiB
Raw Permalink Blame History

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:

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