Medien: library-Pfade nach Medientyp (image/video/pdf/other) unterteilen
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8d85649cf8
commit
3e4fa0dc39
|
|
@ -2,7 +2,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
@ -45,6 +44,44 @@ def get_effective_media_root(cur: Any) -> Path:
|
||||||
return (base / rel).resolve()
|
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]:
|
def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
|
||||||
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
|
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
|
||||||
key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
|
key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
|
||||||
|
|
@ -65,13 +102,16 @@ def library_storage_key(
|
||||||
ext: str,
|
ext: str,
|
||||||
*,
|
*,
|
||||||
uploader_profile_id: Optional[int] = None,
|
uploader_profile_id: Optional[int] = None,
|
||||||
|
mime_type: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
|
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
|
||||||
|
|
||||||
- official → library/official/{sha256}{ext}
|
- official → library/official/{kind}/{sha256}{ext}
|
||||||
- club (vereinsgeteilt) → library/club/c{club_id}/{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}/{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.
|
Kein Ordnername „private“ auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein.
|
||||||
"""
|
"""
|
||||||
|
|
@ -88,11 +128,12 @@ def library_storage_key(
|
||||||
e = ".bin"
|
e = ".bin"
|
||||||
if not e.startswith("."):
|
if not e.startswith("."):
|
||||||
e = "." + e
|
e = "." + e
|
||||||
e = e[:16]
|
|
||||||
if ".." in e or "/" in e or "\\" in e or "\x00" in e:
|
if ".." in e or "/" in e or "\\" in e or "\x00" in e:
|
||||||
raise ValueError("Ungültige Dateiendung")
|
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":
|
if vis == "official":
|
||||||
return f"library/official/{blob}"
|
return f"library/official/{blob}"
|
||||||
if club_id is None:
|
if club_id is None:
|
||||||
|
|
|
||||||
|
|
@ -2525,6 +2525,7 @@ async def upload_exercise_media(
|
||||||
full_sha,
|
full_sha,
|
||||||
ext,
|
ext,
|
||||||
uploader_profile_id=profile_id if ex_vis == "private" else None,
|
uploader_profile_id=profile_id if ex_vis == "private" else None,
|
||||||
|
mime_type=mime,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ def _relocate_asset_file_if_governance_changed(
|
||||||
sha,
|
sha,
|
||||||
ext,
|
ext,
|
||||||
uploader_profile_id=up_i if next_vis == "private" else None,
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
@ -630,6 +631,7 @@ def _ingest_library_media_file(
|
||||||
full_sha,
|
full_sha,
|
||||||
ext,
|
ext,
|
||||||
uploader_profile_id=profile_id if vis == "private" else None,
|
uploader_profile_id=profile_id if vis == "private" else None,
|
||||||
|
mime_type=mime,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
@ -1009,7 +1011,7 @@ def bulk_media_patch(
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
"""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""",
|
FROM media_assets WHERE id = %s""",
|
||||||
(asset_id,),
|
(asset_id,),
|
||||||
)
|
)
|
||||||
|
|
@ -1113,7 +1115,7 @@ def patch_media_asset(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
"""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""",
|
FROM media_assets WHERE id = %s""",
|
||||||
(asset_id,),
|
(asset_id,),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,49 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from media_storage import library_storage_key
|
from media_storage import library_media_kind_dir, library_storage_key
|
||||||
|
|
||||||
_HEX64 = "a" * 64
|
_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:
|
def test_library_storage_key_private_is_uploader_under_club() -> None:
|
||||||
assert (
|
assert (
|
||||||
library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99)
|
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:
|
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:
|
def test_library_storage_key_official() -> None:
|
||||||
assert (
|
assert (
|
||||||
library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1)
|
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:
|
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:
|
def test_library_storage_key_private_requires_uploader() -> None:
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ from main import app
|
||||||
from tenant_context import TenantContext, get_tenant_context
|
from tenant_context import TenantContext, get_tenant_context
|
||||||
|
|
||||||
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
|
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
|
||||||
_SK_OFF_A = f"library/official/{'a' * 64}.jpg"
|
_SK_OFF_A = f"library/official/image/{'a' * 64}.jpg"
|
||||||
_SK_OFF_B = f"library/official/{'b' * 64}.jpg"
|
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
|
||||||
_SK_PRIV_C = f"library/club/c1/u1/{'c' * 64}.mp4"
|
_SK_PRIV_C = f"library/club/c1/u1/video/{'c' * 64}.mp4"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.54"
|
APP_VERSION = "0.8.55"
|
||||||
BUILD_DATE = "2026-05-08"
|
BUILD_DATE = "2026-05-07"
|
||||||
DB_SCHEMA_VERSION = "20260508049"
|
DB_SCHEMA_VERSION = "20260508049"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -13,11 +13,11 @@ MODULE_VERSIONS = {
|
||||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"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)
|
"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",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.54",
|
||||||
"date": "2026-05-08",
|
"date": "2026-05-08",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user