diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py
index 4378131..e57cbd5 100644
--- a/backend/routers/media_assets.py
+++ b/backend/routers/media_assets.py
@@ -3,7 +3,10 @@ from __future__ import annotations
from typing import Any, Literal, Optional, Union
-from fastapi import APIRouter, Depends, HTTPException, Query, Request
+import hashlib
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel, Field, model_validator
from club_tenancy import (
@@ -33,6 +36,8 @@ from media_lifecycle import (
from media_storage import get_effective_media_root, path_under_media_root
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
+from routers.exercises import ALLOWED_UPLOAD_MIMES, _upload_limit_bytes
+
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
@@ -445,6 +450,190 @@ def _apply_lifecycle_action(
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
+_MAX_BULK_LIBRARY_FILES = 25
+
+
+def _ingest_library_media_file(
+ cur: Any,
+ tenant: TenantContext,
+ raw: bytes,
+ filename: Optional[str],
+ content_type: Optional[str],
+ visibility: str,
+ club_id_form: Optional[int],
+) -> dict:
+ """Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media."""
+ profile_id = tenant.profile_id
+ role = tenant.global_role or ""
+ vis = (visibility or "private").strip().lower()
+ if vis not in ("private", "club", "official"):
+ raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit")
+
+ next_cid: Optional[int] = None
+ if vis == "club":
+ if club_id_form is None:
+ raise HTTPException(status_code=400, detail="Verein erforderlich für Sichtbarkeit „Verein“")
+ next_cid = int(club_id_form)
+
+ assert_valid_governance_visibility(cur, profile_id, role, vis, next_cid)
+
+ max_b = _upload_limit_bytes(tenant)
+ if len(raw) > max_b:
+ raise HTTPException(
+ status_code=413,
+ detail=f"Datei zu groß (max. {max_b // (1024 * 1024)} MB)",
+ )
+
+ mime = (content_type or "").split(";")[0].strip().lower()
+ if mime not in ALLOWED_UPLOAD_MIMES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
+ )
+
+ full_sha = hashlib.sha256(raw).hexdigest()
+ cur.execute(
+ """SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
+ WHERE sha256 = %s AND lower(trim(visibility)) = %s
+ AND (club_id IS NOT DISTINCT FROM %s)
+ LIMIT 1""",
+ (full_sha, vis, next_cid),
+ )
+ existing_asset = cur.fetchone()
+
+ if existing_asset:
+ ea = r2d(existing_asset)
+ lc = (ea.get("lifecycle_state") or "").strip().lower()
+ if lc == "active":
+ return {
+ "status": "duplicate",
+ "media_asset_id": int(ea["id"]),
+ "original_filename": ea.get("original_filename"),
+ }
+ if lc in ("trash_soft", "trash_hidden"):
+ raise HTTPException(
+ status_code=409,
+ detail={
+ "code": "MEDIA_ASSET_IN_TRASH",
+ "message": (
+ "Diese Datei ist inhaltsgleich (SHA-256) mit einem Archiv-Medium im Papierkorb."
+ ),
+ "media_asset_id": ea["id"],
+ "lifecycle_state": lc,
+ },
+ )
+ raise HTTPException(
+ status_code=409,
+ detail="Es existiert bereits ein Archiv-Eintrag zu dieser Datei in einem nicht nutzbaren Zustand.",
+ )
+
+ ext = Path(filename or "").suffix[:12] if filename else ""
+ if not ext and mime == "image/jpeg":
+ ext = ".jpg"
+ elif not ext and mime == "image/png":
+ ext = ".png"
+
+ media_root = get_effective_media_root(cur)
+ storage_key = f"exercises/{full_sha}{ext}"
+ dest_path = path_under_media_root(media_root, storage_key)
+ if dest_path is None:
+ raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
+ if not dest_path.is_file():
+ dest_path.write_bytes(raw)
+
+ cur.execute(
+ """INSERT INTO media_assets (
+ mime_type, byte_size, sha256, original_filename, visibility, club_id,
+ uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active')
+ RETURNING id""",
+ (
+ mime,
+ len(raw),
+ full_sha,
+ filename or storage_key,
+ vis,
+ next_cid,
+ profile_id,
+ storage_key,
+ ),
+ )
+ ar = cur.fetchone()
+ aid = int(r2d(ar)["id"])
+ return {"status": "created", "media_asset_id": aid, "original_filename": filename or storage_key}
+
+
+@router.post("/bulk-upload")
+async def bulk_upload_media_assets(
+ tenant: TenantContext = Depends(get_tenant_context),
+ files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"),
+ visibility: str = Form("private"),
+ club_id: Optional[int] = Form(None),
+):
+ """Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion."""
+ if not files:
+ raise HTTPException(status_code=400, detail="Keine Dateien übermittelt")
+ if len(files) > _MAX_BULK_LIBRARY_FILES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Maximal {_MAX_BULK_LIBRARY_FILES} Dateien pro Anfrage",
+ )
+
+ results: list[dict[str, Any]] = []
+ created = duplicate = failed = 0
+
+ for uf in files:
+ fn = uf.filename or "ohne_name"
+ try:
+ raw = await uf.read()
+ if not raw:
+ results.append({"filename": fn, "ok": False, "detail": "Leere Datei"})
+ failed += 1
+ continue
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ r = _ingest_library_media_file(
+ cur,
+ tenant,
+ raw,
+ uf.filename,
+ uf.content_type,
+ visibility,
+ club_id,
+ )
+ results.append({"filename": fn, "ok": True, **r})
+ if r["status"] == "created":
+ created += 1
+ else:
+ duplicate += 1
+ except HTTPException as e:
+ detail = e.detail
+ if isinstance(detail, dict):
+ detail_s = detail.get("message") or detail.get("code") or str(detail)
+ else:
+ detail_s = str(detail)
+ results.append(
+ {
+ "filename": fn,
+ "ok": False,
+ "status_code": e.status_code,
+ "detail": detail_s,
+ },
+ )
+ failed += 1
+ except Exception as e:
+ results.append({"filename": fn, "ok": False, "detail": str(e)})
+ failed += 1
+
+ return {
+ "results": results,
+ "created_count": created,
+ "duplicate_count": duplicate,
+ "failed_count": failed,
+ }
+
+
@router.get("")
def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context),
@@ -489,21 +678,22 @@ def list_media_assets(
media_kind_sql = ""
if mk == "image":
- media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%'"
+ # %% für psycopg2: sonst wird % als Platzhalter-Syntax interpretiert
+ media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%%'"
elif mk == "video":
- media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%'"
+ media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%%'"
elif mk == "pdf":
media_kind_sql = (
" AND (lower(COALESCE(ma.mime_type, '')) = 'application/pdf'"
- " OR lower(COALESCE(ma.mime_type, '')) LIKE '%pdf%')"
+ " OR lower(COALESCE(ma.mime_type, '')) LIKE '%%pdf%%')"
)
elif mk == "other":
media_kind_sql = (
" AND COALESCE(ma.mime_type, '') <> ''"
- " AND lower(ma.mime_type) NOT LIKE 'image/%'"
- " AND lower(ma.mime_type) NOT LIKE 'video/%'"
+ " AND lower(ma.mime_type) NOT LIKE 'image/%%'"
+ " AND lower(ma.mime_type) NOT LIKE 'video/%%'"
" AND lower(ma.mime_type) <> 'application/pdf'"
- " AND lower(ma.mime_type) NOT LIKE '%pdf%'"
+ " AND lower(ma.mime_type) NOT LIKE '%%pdf%%'"
)
club_sql = ""
diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py
index fac7839..d5ba65c 100644
--- a/backend/tests/test_media_assets_archive.py
+++ b/backend/tests/test_media_assets_archive.py
@@ -87,6 +87,32 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
assert "viewer" in body
+def test_list_media_assets_media_kind_image_ok_mocked(client: TestClient) -> None:
+ """media_kind=image setzt %% in SQL — Regression gegen psycopg2-%-Platzhalter."""
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=10,
+ global_role="trainer",
+ effective_club_id=5,
+ club_ids=frozenset({5}),
+ memberships=[],
+ )
+
+ mock_cur = MagicMock()
+ mock_cur.fetchall.side_effect = [[], []]
+ mock_cm = _mock_db(mock_cur)
+
+ with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
+ "routers.media_assets.get_cursor", return_value=mock_cur
+ ), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
+ r = client.get("/api/media-assets?media_kind=image", headers={"X-Auth-Token": "t"})
+
+ assert r.status_code == 200
+ calls = [str(c) for c in mock_cur.execute.call_args_list]
+ joined = " ".join(calls)
+ assert "image/%%" in joined
+
+
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
diff --git a/frontend/src/app.css b/frontend/src/app.css
index cd7d141..7646ae4 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -5546,6 +5546,61 @@ a.analysis-split__nav-item {
flex: 1 1 140px;
max-width: 240px;
}
+.media-library__upload-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-top: 12px;
+}
+.media-library__upload-row .form-input {
+ min-width: 0;
+ flex: 0 1 160px;
+ max-width: 220px;
+}
+.media-library__upload-summary {
+ font-size: 0.85rem;
+ color: var(--text2);
+ flex: 1 1 200px;
+}
+.media-library__upload-icon {
+ vertical-align: middle;
+ margin-right: 6px;
+}
+.media-library__sr-file {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.media-library__card-type {
+ position: absolute;
+ left: 6px;
+ top: 6px;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.92);
+ color: var(--text2);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+ pointer-events: none;
+}
+.media-library__card-type--compact {
+ width: 22px;
+ height: 22px;
+ left: 3px;
+ top: 3px;
+ border-radius: 6px;
+}
.media-library__card-copyright {
position: absolute;
right: 6px;
@@ -5797,6 +5852,7 @@ a.analysis-split__nav-item {
width: 72px;
}
.media-library__table-thumb {
+ position: relative;
width: 56px;
height: 56px;
border-radius: 8px;
diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx
index 385d01d..3949372 100644
--- a/frontend/src/pages/MediaLibraryPage.jsx
+++ b/frontend/src/pages/MediaLibraryPage.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback } from 'react'
+import { useEffect, useState, useCallback, useRef } from 'react'
import { Link } from 'react-router-dom'
import {
LayoutGrid,
@@ -13,6 +13,11 @@ import {
CircleDot,
FilePenLine,
Copyright,
+ Image,
+ Video,
+ FileText,
+ File,
+ Upload,
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@@ -195,6 +200,32 @@ function previewDisplayKind(mimeType) {
return 'other'
}
+const MEDIA_KIND_LABELS = {
+ image: 'Bild',
+ video: 'Video',
+ pdf: 'PDF',
+ other: 'Sonstiges',
+}
+
+function MediaTypeGlyph({ mimeType, compact }) {
+ const kind = previewDisplayKind(mimeType)
+ const label = MEDIA_KIND_LABELS[kind] || 'Medium'
+ let Icon = File
+ if (kind === 'image') Icon = Image
+ else if (kind === 'video') Icon = Video
+ else if (kind === 'pdf') Icon = FileText
+ const sz = compact ? 12 : 14
+ return (
+
+