From 3e4fa0dc390b4b5fffecad9ed1d15a36babd1987 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 10:03:00 +0200 Subject: [PATCH] Medien: library-Pfade nach Medientyp (image/video/pdf/other) unterteilen Co-authored-by: Cursor --- backend/media_storage.py | 53 +++++++++++++++++++--- backend/routers/exercises.py | 1 + backend/routers/media_assets.py | 6 ++- backend/tests/test_library_storage_key.py | 28 ++++++++++-- backend/tests/test_media_assets_archive.py | 6 +-- backend/version.py | 15 ++++-- 6 files changed, 89 insertions(+), 20 deletions(-) diff --git a/backend/media_storage.py b/backend/media_storage.py index 7eac9de..4fa5c9a 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -2,7 +2,6 @@ from __future__ import annotations import os -import re import shutil from pathlib import Path from typing import Any, Optional @@ -45,6 +44,44 @@ def get_effective_media_root(cur: Any) -> Path: return (base / rel).resolve() +def library_media_kind_dir(mime_type: Optional[str], ext: str) -> str: + """ + Unterordner unter library/* — an API-Filter media_kind angelehnt (image/video/pdf/other). + + Bei fehlendem MIME wird anhand der Dateiendung geraten (letzte Instanz: other). + """ + m = (mime_type or "").strip().lower() + x = (ext or "").strip().lower() + if x and not x.startswith("."): + x = "." + x + + if m.startswith("image/"): + return "image" + if m.startswith("video/"): + return "video" + if m == "application/pdf" or (m and "pdf" in m): + return "pdf" + + if x in ( + ".jpg", + ".jpeg", + ".png", + ".gif", + ".webp", + ".heic", + ".heif", + ".bmp", + ".svg", + ".avif", + ): + return "image" + if x in (".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"): + return "video" + if x == ".pdf": + return "pdf" + return "other" + + def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]: """Gibt absoluten Pfad zurück oder None bei Path-Traversal.""" key = (storage_key or "").strip().replace("\\", "/").lstrip("/") @@ -65,13 +102,16 @@ def library_storage_key( ext: str, *, uploader_profile_id: Optional[int] = None, + mime_type: Optional[str] = None, ) -> str: """ Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets. - - official → library/official/{sha256}{ext} - - club (vereinsgeteilt) → library/club/c{club_id}/{sha256}{ext} - - private (nur Hochlader, Governance unverändert) → library/club/c{club_id}/u{profile}/{sha256}{ext} + - official → library/official/{kind}/{sha256}{ext} + - club (vereinsgeteilt) → library/club/c{club_id}/{kind}/{sha256}{ext} + - private (nur Hochlader, Governance unverändert) → library/club/c{club_id}/u{profile}/{kind}/{sha256}{ext} + + kind ∈ {image, video, pdf, other} — siehe library_media_kind_dir. Kein Ordnername „private“ auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein. """ @@ -88,11 +128,12 @@ def library_storage_key( e = ".bin" if not e.startswith("."): e = "." + e - e = e[:16] if ".." in e or "/" in e or "\\" in e or "\x00" in e: raise ValueError("Ungültige Dateiendung") - blob = f"{sha}{e}" + kind = library_media_kind_dir(mime_type, e) + e = e[:16] + blob = f"{kind}/{sha}{e}" if vis == "official": return f"library/official/{blob}" if club_id is None: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index cf478e7..48bec7c 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2525,6 +2525,7 @@ async def upload_exercise_media( full_sha, ext, uploader_profile_id=profile_id if ex_vis == "private" else None, + mime_type=mime, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index b5ed2a5..157a78e 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -340,6 +340,7 @@ def _relocate_asset_file_if_governance_changed( sha, ext, uploader_profile_id=up_i if next_vis == "private" else None, + mime_type=str(asset.get("mime_type") or "") or None, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -630,6 +631,7 @@ def _ingest_library_media_file( full_sha, ext, uploader_profile_id=profile_id if vis == "private" else None, + mime_type=mime, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -1009,7 +1011,7 @@ def bulk_media_patch( try: cur.execute( """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, - copyright_notice, original_filename, sha256, storage_key, storage_backend + copyright_notice, original_filename, sha256, storage_key, storage_backend, mime_type FROM media_assets WHERE id = %s""", (asset_id,), ) @@ -1113,7 +1115,7 @@ def patch_media_asset( cur = get_cursor(conn) cur.execute( """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, - copyright_notice, original_filename, sha256, storage_key, storage_backend + copyright_notice, original_filename, sha256, storage_key, storage_backend, mime_type FROM media_assets WHERE id = %s""", (asset_id,), ) diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py index 1cfe84e..97d4d98 100644 --- a/backend/tests/test_library_storage_key.py +++ b/backend/tests/test_library_storage_key.py @@ -3,31 +3,49 @@ from __future__ import annotations import pytest -from media_storage import library_storage_key +from media_storage import library_media_kind_dir, library_storage_key _HEX64 = "a" * 64 +def test_library_media_kind_dir_mime_overrides_ext() -> None: + assert library_media_kind_dir("image/png", ".bin") == "image" + assert library_media_kind_dir("application/pdf", ".tmp") == "pdf" + + def test_library_storage_key_private_is_uploader_under_club() -> None: assert ( library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99) - == f"library/club/c7/u99/{_HEX64}.jpg" + == f"library/club/c7/u99/image/{_HEX64}.jpg" ) def test_library_storage_key_club_flat_under_club() -> None: - assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/{_HEX64}.png" + assert ( + library_storage_key("club", 42, _HEX64, ".png") + == f"library/club/c42/image/{_HEX64}.png" + ) def test_library_storage_key_official() -> None: assert ( library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1) - == f"library/official/{_HEX64}.mp4" + == f"library/official/video/{_HEX64}.mp4" ) def test_library_storage_key_normalizes_visibility() -> None: - assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/{_HEX64}.pdf" + assert ( + library_storage_key(" CLUB ", 1, _HEX64, "pdf") + == f"library/club/c1/pdf/{_HEX64}.pdf" + ) + + +def test_library_storage_key_other_bin() -> None: + assert ( + library_storage_key("official", None, _HEX64, "", mime_type="application/octet-stream") + == f"library/official/other/{_HEX64}.bin" + ) def test_library_storage_key_private_requires_uploader() -> None: diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index 979192d..d721497 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -16,9 +16,9 @@ from main import app from tenant_context import TenantContext, get_tenant_context # Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256) -_SK_OFF_A = f"library/official/{'a' * 64}.jpg" -_SK_OFF_B = f"library/official/{'b' * 64}.jpg" -_SK_PRIV_C = f"library/club/c1/u1/{'c' * 64}.mp4" +_SK_OFF_A = f"library/official/image/{'a' * 64}.jpg" +_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg" +_SK_PRIV_C = f"library/club/c1/u1/video/{'c' * 64}.mp4" @pytest.fixture diff --git a/backend/version.py b/backend/version.py index 950a3be..b087ca6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,7 +1,7 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.54" -BUILD_DATE = "2026-05-08" +APP_VERSION = "0.8.55" +BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260508049" MODULE_VERSIONS = { @@ -13,11 +13,11 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "admin_users": "1.0.0", # GET /api/admin/users "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) - "media_assets": "1.9.0", # club: flach unter library/club/c*; private: …/u{profile}/; Dedupe private + uploader + "media_assets": "1.10.0", # library/*/image|video|pdf|other/ Unterordner (Medientyp) "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.17.2", # Dedupe private + uploaded_by; storage_key mit uploader_profile_id + "exercises": "2.17.3", # library_storage_key mit mime_type (Medientyp-Ordner) "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.55", + "date": "2026-05-07", + "changes": [ + "Medienbibliothek: Speicherpfad library/* mit Medientyp-Unterordnern (image, video, pdf, other) vor SHA-Dateinamen; Umzug bei Governance-PATCH nutzt mime_type aus DB", + ], + }, { "version": "0.8.54", "date": "2026-05-08",