"""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