Phase 2: Medien-Engine – Transport, Preload, Library, Content-Manifest
- PlaybackController (§12.5): Play/Pause/Stop/Retrigger, Loop/Once/ Ping-Pong mit In/Out-Fenstern, variable Geschwindigkeit ±4x (§16.6), Ende-Ereignis genau einmal je Pass, monotone Ereignis-Zeitbasis (§12.2) - PreloadSlot (§12.2): atomarer Clipwechsel preload->ready->commit; nicht bereites Medium wechselt nie (Policy beim Aufrufer) - MediaLibrary (§13.2/§13.3): Import mit SHA-256-Duplikaterkennung, Bank-Slots als explizite Show-Metadaten, fehlende Dateien markiert, Relink; Import laeuft im Control-Core-Worker, nie im Renderthread - ContentManifest (§6.4/§10.1): SHA-256 je Datei + resumierbare Chunk-Hashes, Verifikation gegen Veraenderung, Diff fuer Sync-Plan, deterministische Serialisierung mit Revision - 35 neue Unit-Tests; Gesamtsuite 258 gruen, Ruff gruen
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
"""Medienbibliothek: Index, Import, Duplikate, Banken (§13.2, §13.3).
|
||||
|
||||
Import-Regeln (§13.3):
|
||||
- Import blockiert niemals den Renderthread: der Control Core ruft diese
|
||||
Methoden aus einem Worker-Thread/Executor auf (asyncio.to_thread).
|
||||
- Duplikate werden über den Inhalts-Hash erkannt.
|
||||
- Medienbank und Clipnummer sind explizite Show-Metadaten, nicht vom
|
||||
Dateinamen abhängig (§13.2).
|
||||
- Fehlende Dateien werden markiert und können neu verknüpft werden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from hms_domain.model import MediaAsset
|
||||
|
||||
|
||||
class ImportStatus(StrEnum):
|
||||
IMPORTED = "imported"
|
||||
DUPLICATE = "duplicate"
|
||||
MISSING_FILE = "missing_file"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportOutcome:
|
||||
"""Ergebnis eines Imports (§13.3)."""
|
||||
|
||||
status: ImportStatus
|
||||
asset: MediaAsset | None
|
||||
duplicate_of: str | None = None # asset_id des existierenden Duplikats
|
||||
|
||||
|
||||
class MediaLibrary:
|
||||
"""Medienindex über portablem Media-Root (§13.2).
|
||||
|
||||
- Assets werden über stabile UUIDs adressiert, nie über Pfade.
|
||||
- Duplikaterkennung über SHA-256 (Content-Hash).
|
||||
- Bank-Registry: (bank, index) → asset_id als Show-Metadaten.
|
||||
"""
|
||||
|
||||
def __init__(self, media_root: Path) -> None:
|
||||
self._media_root = Path(media_root)
|
||||
self._assets: dict[str, MediaAsset] = {}
|
||||
self._by_hash: dict[str, str] = {} # content_hash → asset_id
|
||||
self._banks: dict[tuple[int, int], str] = {} # (bank, index) → asset_id
|
||||
self._missing: set[str] = set()
|
||||
|
||||
@property
|
||||
def media_root(self) -> Path:
|
||||
return self._media_root
|
||||
|
||||
# ---------- Import (§13.3) ----------
|
||||
|
||||
def import_file(self, abs_path: Path | str) -> ImportOutcome:
|
||||
"""Importiert eine Datei; Duplikate werden erkannt (§13.3).
|
||||
|
||||
Läuft im Control Core in einem Worker-Thread; blockiert den
|
||||
Renderthread nie (dieser Prozess hat keinen Zugriff auf die Library).
|
||||
"""
|
||||
path = Path(abs_path)
|
||||
if not path.is_file():
|
||||
return ImportOutcome(status=ImportStatus.MISSING_FILE, asset=None)
|
||||
|
||||
from hms_content_sync.manifest import hash_file
|
||||
|
||||
file_hash, _chunks = hash_file(path)
|
||||
existing_id = self._by_hash.get(file_hash)
|
||||
if existing_id is not None:
|
||||
return ImportOutcome(
|
||||
status=ImportStatus.DUPLICATE,
|
||||
asset=self._assets[existing_id],
|
||||
duplicate_of=existing_id,
|
||||
)
|
||||
|
||||
rel = self._relative_to_media_root(path)
|
||||
stat = path.stat()
|
||||
asset = MediaAsset(
|
||||
rel_path=rel,
|
||||
file_size_bytes=stat.st_size,
|
||||
mtime_ns=stat.st_mtime_ns,
|
||||
content_hash=file_hash,
|
||||
)
|
||||
self._assets[asset.id] = asset
|
||||
self._by_hash[file_hash] = asset.id
|
||||
self._missing.discard(asset.id)
|
||||
return ImportOutcome(status=ImportStatus.IMPORTED, asset=asset)
|
||||
|
||||
# ---------- Bank-Registry (§13.2 Show-Metadaten) ----------
|
||||
|
||||
def register_bank_slot(self, asset_id: str, bank: int, index: int) -> None:
|
||||
"""Clipnummer/Bank als explizite Metadaten setzen (§13.2).
|
||||
|
||||
Nicht Dateinamen-basiert: Umbenennen der Datei ändert nichts.
|
||||
"""
|
||||
if asset_id not in self._assets:
|
||||
raise KeyError(f"unbekanntes Asset {asset_id}")
|
||||
if bank < 0 or index < 0:
|
||||
raise ValueError("bank und index müssen >= 0 sein")
|
||||
self._banks[(bank, index)] = asset_id
|
||||
|
||||
def asset_at(self, bank: int, index: int) -> MediaAsset | None:
|
||||
asset_id = self._banks.get((bank, index))
|
||||
return self._assets.get(asset_id) if asset_id else None
|
||||
|
||||
# ---------- Fehlende Dateien / Relink (§13.3) ----------
|
||||
|
||||
def mark_missing(self) -> list[str]:
|
||||
"""Prüft Existenz aller Dateien; liefert neu fehlende asset_ids."""
|
||||
newly_missing: list[str] = []
|
||||
for asset in self._assets.values():
|
||||
exists = (self._media_root / asset.rel_path).is_file()
|
||||
if not exists and asset.id not in self._missing:
|
||||
self._missing.add(asset.id)
|
||||
newly_missing.append(asset.id)
|
||||
elif exists:
|
||||
self._missing.discard(asset.id)
|
||||
return newly_missing
|
||||
|
||||
@property
|
||||
def missing_asset_ids(self) -> frozenset[str]:
|
||||
return frozenset(self._missing)
|
||||
|
||||
def relink(self, asset_id: str, new_abs_path: Path | str) -> MediaAsset:
|
||||
"""Verknüpft ein Asset mit einer neuen Datei (§13.3).
|
||||
|
||||
Hash wird neu bestimmt; ändert sich der Inhalt, wird der Hash
|
||||
aktualisiert (Pfad-Referenz bleibt dieselbe Asset-ID).
|
||||
"""
|
||||
asset = self._assets.get(asset_id)
|
||||
if asset is None:
|
||||
raise KeyError(f"unbekanntes Asset {asset_id}")
|
||||
path = Path(new_abs_path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(str(path))
|
||||
from hms_content_sync.manifest import hash_file
|
||||
|
||||
file_hash, _chunks = hash_file(path)
|
||||
rel = self._relative_to_media_root(path)
|
||||
stat = path.stat()
|
||||
updated = asset.model_copy(
|
||||
update={
|
||||
"rel_path": rel,
|
||||
"file_size_bytes": stat.st_size,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
"content_hash": file_hash,
|
||||
}
|
||||
)
|
||||
self._assets[asset_id] = updated
|
||||
self._by_hash[file_hash] = asset_id
|
||||
self._missing.discard(asset_id)
|
||||
return updated
|
||||
|
||||
# ---------- Abfragen ----------
|
||||
|
||||
def get(self, asset_id: str) -> MediaAsset | None:
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
def all(self) -> list[MediaAsset]:
|
||||
return list(self._assets.values())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._assets)
|
||||
|
||||
# ---------- Interna ----------
|
||||
|
||||
def _relative_to_media_root(self, path: Path) -> str:
|
||||
rel = path.resolve().relative_to(self._media_root.resolve())
|
||||
posix = PurePosixPath(*rel.parts).as_posix()
|
||||
return posix
|
||||
Reference in New Issue
Block a user