diff --git a/packages/content_sync/hms_content_sync/__init__.py b/packages/content_sync/hms_content_sync/__init__.py new file mode 100644 index 0000000..638a0f9 --- /dev/null +++ b/packages/content_sync/hms_content_sync/__init__.py @@ -0,0 +1,17 @@ +"""hms_content_sync – Content-Manifest und Sync-Basis (§6.4, §10.1).""" + +from hms_content_sync.manifest import ( + CHUNK_SIZE, + ContentManifest, + ManifestDiff, + ManifestEntry, + hash_file, +) + +__all__ = [ + "CHUNK_SIZE", + "ContentManifest", + "ManifestDiff", + "ManifestEntry", + "hash_file", +] diff --git a/packages/content_sync/hms_content_sync/manifest.py b/packages/content_sync/hms_content_sync/manifest.py new file mode 100644 index 0000000..52ea4d2 --- /dev/null +++ b/packages/content_sync/hms_content_sync/manifest.py @@ -0,0 +1,209 @@ +"""ContentManifest (PLAN.md §10.1, §6.4). + +Content Sync (§6.4): SHA-256-Manifest, resumierbare Chunks, Hashprüfung, +Staging – Ziel sind byte-identische freigegebene Inhalte auf allen Nodes. + +Manifest-Einträge decken Medien, Plugins und Projektdateien ab; die Art +wird je Eintrag vermerkt, damit Preflight Plugin-API- und +Backend-Anforderungen prüfen kann (§10.1). +""" + +from __future__ import annotations + +import hashlib +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath + +CHUNK_SIZE = 4 * 1024 * 1024 # 4 MiB Standard-Chunk für Resume (§6.4) + +KIND_MEDIA = "media" +KIND_PLUGIN = "plugin" +KIND_PROJECT = "project" + + +def hash_file(path: Path | str, chunk_size: int = CHUNK_SIZE) -> tuple[str, list[str]]: + """SHA-256 über die ganze Datei + je Chunk (resumierbar, §6.4). + + Rückgabe: (file_hash_hex, [chunk_hash_hex, ...] in Dateireihenfolge). + """ + if chunk_size <= 0: + raise ValueError("chunk_size muss positiv sein") + file_hash = hashlib.sha256() + chunk_hashes: list[str] = [] + with open(path, "rb") as fh: + while True: + data = fh.read(chunk_size) + if not data: + break + chunk_hashes.append(hashlib.sha256(data).hexdigest()) + file_hash.update(data) + if not chunk_hashes: # leere Datei: ein leerer Chunk-Hash + chunk_hashes.append(hashlib.sha256(b"").hexdigest()) + return file_hash.hexdigest(), chunk_hashes + + +@dataclass(frozen=True) +class ManifestEntry: + """Eine Datei im Manifest (§10.1): Pfad, Größe, Hash, Art.""" + + rel_path: str # portabel, POSIX-relativ + kind: str # media | plugin | project + size_bytes: int + sha256: str + chunk_hashes: tuple[str, ...] = () + + def to_dict(self) -> dict: + return { + "rel_path": self.rel_path, + "kind": self.kind, + "size_bytes": self.size_bytes, + "sha256": self.sha256, + "chunk_hashes": list(self.chunk_hashes), + } + + @classmethod + def from_dict(cls, data: dict) -> ManifestEntry: + return cls( + rel_path=data["rel_path"], + kind=data["kind"], + size_bytes=int(data["size_bytes"]), + sha256=data["sha256"], + chunk_hashes=tuple(data.get("chunk_hashes", ())), + ) + + +@dataclass(frozen=True) +class ManifestDiff: + """Unterschied zweier Manifeste (Basis für Sync-Transfer, §6.4).""" + + added: frozenset[str] = field(default_factory=frozenset) + removed: frozenset[str] = field(default_factory=frozenset) + changed: frozenset[str] = field(default_factory=frozenset) + + @property + def empty(self) -> bool: + return not (self.added or self.removed or self.changed) + + +class ContentManifest: + """Versioniertes Inhalts-Manifest (§10.1 ContentManifest). + + - manifest_id: Instanz-ID (UUID) + - revision: monotone Manifest-Revision (neu bei jeder Änderung) + - entries: rel_path → ManifestEntry + + Der Manifest-Inhalt selbst ist deterministisch (sortierte Einträge); + die manifest_id identifiziert den Ausgabezeitpunkt. + """ + + def __init__( + self, + manifest_id: str | None = None, + revision: int = 0, + entries: dict[str, ManifestEntry] | None = None, + ) -> None: + self.manifest_id = manifest_id or str(uuid.uuid4()) + self.revision = revision + self._entries: dict[str, ManifestEntry] = dict(entries or {}) + + @property + def entries(self) -> dict[str, ManifestEntry]: + return dict(self._entries) + + # ---------- Aufbau ---------- + + @classmethod + def build( + cls, + root: Path | str, + kind_of=None, + chunk_size: int = CHUNK_SIZE, + revision: int = 0, + ) -> ContentManifest: + """Baut das Manifest aus einem Verzeichnis (§6.4 SHA-256). + + kind_of: callable(rel_path) → "media"|"plugin"|"project"; + Default: alles KIND_MEDIA. + """ + root_path = Path(root) + if not root_path.is_dir(): + raise FileNotFoundError(f"Manifest-Root fehlt: {root_path}") + entries: dict[str, ManifestEntry] = {} + for dirpath, _dirnames, filenames in os.walk(root_path): + for name in sorted(filenames): + abs_path = Path(dirpath) / name + if not abs_path.is_file(): + continue + rel = abs_path.relative_to(root_path).as_posix() + if ".." in PurePosixPath(rel).parts: + continue # defensive: nie Traversal ins Manifest + file_hash, chunks = hash_file(abs_path, chunk_size=chunk_size) + kind = kind_of(rel) if kind_of else KIND_MEDIA + entries[rel] = ManifestEntry( + rel_path=rel, + kind=kind, + size_bytes=abs_path.stat().st_size, + sha256=file_hash, + chunk_hashes=tuple(chunks), + ) + return cls(revision=revision, entries=entries) + + # ---------- Verifikation (§6.4 Hashprüfung) ---------- + + def verify_file(self, root: Path | str, rel_path: str) -> bool: + """Prüft Größe + Hash einer Datei gegen den Manifest-Eintrag.""" + entry = self._entries.get(rel_path) + if entry is None: + return False + abs_path = Path(root) / rel_path + if not abs_path.is_file(): + return False + stat = abs_path.stat() + if stat.st_size != entry.size_bytes: + return False + file_hash, _chunks = hash_file(abs_path) + return file_hash == entry.sha256 + + # ---------- Diff (§6.4 Sync-Basis) ---------- + + def diff(self, other: ContentManifest) -> ManifestDiff: + """Was muss von self nach other übertragen/gelöscht werden?""" + mine = set(self._entries) + theirs = set(other._entries) + added = frozenset(theirs - mine) + removed = frozenset(mine - theirs) + changed = frozenset( + rel + for rel in mine & theirs + if self._entries[rel].sha256 != other._entries[rel].sha256 + ) + return ManifestDiff(added=added, removed=removed, changed=changed) + + # ---------- Serialisierung ---------- + + def to_dict(self) -> dict: + return { + "manifest_id": self.manifest_id, + "revision": self.revision, + "entries": { + rel: entry.to_dict() + for rel, entry in sorted(self._entries.items()) + }, + } + + @classmethod + def from_dict(cls, data: dict) -> ContentManifest: + entries = { + rel: ManifestEntry.from_dict(entry) + for rel, entry in data.get("entries", {}).items() + } + return cls( + manifest_id=data.get("manifest_id"), + revision=int(data.get("revision", 0)), + entries=entries, + ) + + def __len__(self) -> int: + return len(self._entries) diff --git a/packages/media/hms_media/__init__.py b/packages/media/hms_media/__init__.py new file mode 100644 index 0000000..7fdfad2 --- /dev/null +++ b/packages/media/hms_media/__init__.py @@ -0,0 +1,19 @@ +"""hms_media – Playback-Transport und Medienbibliothek (PLAN.md §12.5, §13).""" + +from hms_media.library import ImportOutcome, ImportStatus, MediaLibrary +from hms_media.transport import ( + PlaybackController, + PlaybackEvent, + PlaybackEventKind, + PreloadSlot, +) + +__all__ = [ + "PlaybackController", + "PlaybackEvent", + "PlaybackEventKind", + "PreloadSlot", + "MediaLibrary", + "ImportOutcome", + "ImportStatus", +] diff --git a/packages/media/hms_media/library.py b/packages/media/hms_media/library.py new file mode 100644 index 0000000..51248fe --- /dev/null +++ b/packages/media/hms_media/library.py @@ -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 diff --git a/packages/media/hms_media/transport.py b/packages/media/hms_media/transport.py new file mode 100644 index 0000000..911f309 --- /dev/null +++ b/packages/media/hms_media/transport.py @@ -0,0 +1,196 @@ +"""Playback-Transportmodell und atomarer Clipwechsel (§12.2, §12.5). + +Reine Steuerungslogik ohne Pixelverarbeitung (§33): Positions-, Loop- und +Trigger-Berechnung sind plattformneutral und werden vom Renderer pro Frame +ausgeführt. Ping-Pong ist modellseitig verfügbar – ob ein Medium es +performant kann, entscheidet die Codec-Eignung (MediaAsset.seek_suitability, +§12.5); ungeeignete Medien werden sichtbar gemeldet, nicht still verlangsamt. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from enum import StrEnum + +from hms_domain.model import LoopMode, TransportState + + +class PlaybackEventKind(StrEnum): + """Ereignisse je Advancement-Schritt (§12.5 Ende-Ereignis).""" + + END_OF_MEDIA = "end_of_media" + LOOP_WRAP = "loop_wrap" + DIRECTION_CHANGE = "direction_change" + + +@dataclass(frozen=True) +class PlaybackEvent: + """Ein Wiedergabe-Ereignis mit Position und monotone Zeit.""" + + kind: PlaybackEventKind + source_id: str + position: float + monotonic_ns: int + + +class PlaybackController: + """Transport-Zustandsmaschine für genau eine Quelle (§12.5). + + - play/pause/stop/retrigger + - Position normalisiert 0..1, In-/Out-Punkte aus Source (§10.1) + - Loop, Once, Ping-Pong + - variable Geschwindigkeit; negativ = rückwärts + - advance(dt) liefert Ereignisse; Medienende genau einmal je Pass + + Kein interner Timer: der Renderer ruft advance(dt) pro Frame mit der + gemeinsamen monotonen Zeitbasis (§12.2) auf. + """ + + def __init__( + self, + source_id: str, + in_point: float = 0.0, + out_point: float = 1.0, + loop_mode: LoopMode = LoopMode.LOOP, + speed: float = 1.0, + ) -> None: + if not 0.0 <= in_point < out_point <= 1.0: + raise ValueError("in_point muss kleiner als out_point im Bereich 0..1 sein") + self.source_id = source_id + self.in_point = in_point + self.out_point = out_point + self.loop_mode = loop_mode + self.state = TransportState.STOPPED + self.position = in_point + self.direction = 1 # +1 vorwärts, -1 rückwärts (Ping-Pong) + self.speed = speed + + # ---------- Transportbefehle (§12.5) ---------- + + def play(self) -> None: + self.state = TransportState.PLAYING + + def pause(self) -> None: + if self.state is TransportState.PLAYING: + self.state = TransportState.PAUSED + + def stop(self) -> None: + """Stop: Transport angehalten, Position zurück auf In-Point.""" + self.state = TransportState.STOPPED + self.position = self.in_point + self.direction = 1 + + def retrigger(self) -> None: + """Neustart ab In-Point (flankenbasiert getriggert, §16.5).""" + self.position = self.in_point + self.direction = 1 + self.state = TransportState.PLAYING + + def seek(self, position: float) -> None: + if not self.in_point <= position <= self.out_point: + raise ValueError(f"seek außerhalb In/Out: {position}") + self.position = position + + def set_speed(self, speed: float) -> None: + if speed == 0.0: + raise ValueError("speed 0 unzulässig; pause() verwenden") + self.speed = speed + + # ---------- Frame-Advancement ---------- + + def advance(self, dt_s: float, now_ns: int | None = None) -> list[PlaybackEvent]: + """Advancement um dt Sekunden; liefert Ereignisse dieses Schritts. + + Wird nur im PLAYING-Zustand wirksam. Grenzüberschreitungen werden + gemäß Loop-Modus behandelt; die Position bleibt immer innerhalb + [in_point, out_point]. + """ + if dt_s < 0.0: + raise ValueError("dt_s darf nicht negativ sein") + if self.state is not TransportState.PLAYING or dt_s == 0.0: + return [] + now = now_ns if now_ns is not None else time.monotonic_ns() + events: list[PlaybackEvent] = [] + span = self.out_point - self.in_point + new_pos = self.position + self.direction * self.speed * dt_s + + if self.loop_mode is LoopMode.LOOP: + if new_pos > self.out_point: + new_pos = self.in_point + (new_pos - self.out_point) % span + events.append(self._event(PlaybackEventKind.LOOP_WRAP, new_pos, now)) + elif new_pos < self.in_point: + overshoot = (self.in_point - new_pos) % span + new_pos = self.out_point - overshoot if overshoot else self.in_point + events.append(self._event(PlaybackEventKind.LOOP_WRAP, new_pos, now)) + elif self.loop_mode is LoopMode.ONCE: + if new_pos >= self.out_point or new_pos <= self.in_point: + new_pos = self.out_point if self.direction > 0 else self.in_point + self.state = TransportState.STOPPED + events.append(self._event(PlaybackEventKind.END_OF_MEDIA, new_pos, now)) + else: # PING_PONG + if self.direction > 0 and new_pos >= self.out_point: + new_pos = max(self.out_point - (new_pos - self.out_point), self.in_point) + self.direction = -1 + events.append(self._event(PlaybackEventKind.DIRECTION_CHANGE, new_pos, now)) + elif self.direction < 0 and new_pos <= self.in_point: + new_pos = min(self.in_point + (self.in_point - new_pos), self.out_point) + self.direction = 1 + events.append(self._event(PlaybackEventKind.DIRECTION_CHANGE, new_pos, now)) + + self.position = new_pos + return events + + def _event( + self, kind: PlaybackEventKind, position: float, now_ns: int + ) -> PlaybackEvent: + return PlaybackEvent( + kind=kind, + source_id=self.source_id, + position=position, + monotonic_ns=now_ns, + ) + + +class PreloadSlot: + """Vorgepufferte, atomar umschaltbare Clipauswahl (§12.2, §16.5). + + Ablauf: preload() → Renderer bereitet Decoder vor → mark_ready() → + commit() wechselt an der Framegrenze atomar um. Ist beim Commit nicht + ready, bleibt das alte Bild aktiv (Policy des Aufrufers: halten/warten/ + Fallback, §18.3) – niemals ein halb geladenes Medium. + """ + + def __init__(self) -> None: + self._pending: str | None = None # asset_id + self._ready: bool = False + + @property + def pending(self) -> str | None: + return self._pending + + @property + def ready(self) -> bool: + return self._ready + + def preload(self, asset_id: str) -> None: + self._pending = asset_id + self._ready = False + + def mark_ready(self, asset_id: str) -> None: + if asset_id != self._pending: + raise ValueError("mark_ready für anderes Asset als pending") + self._ready = True + + def commit(self) -> str | None: + """Atomarer Wechsel: gibt das Asset zurück oder None (nicht bereit).""" + if self._pending is not None and self._ready: + asset_id = self._pending + self._pending = None + self._ready = False + return asset_id + return None + + def cancel(self) -> None: + self._pending = None + self._ready = False diff --git a/pyproject.toml b/pyproject.toml index 8d237e3..c0500e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ packages = [ "packages/plugin_sdk/hms_plugin_sdk", "packages/persistence/hms_persistence", "packages/cluster/hms_cluster", + "packages/media/hms_media", + "packages/content_sync/hms_content_sync", "apps/renderer/hms_renderer", "apps/control_server/hms_control_server", "apps/launcher/hms_launcher", diff --git a/tests/conftest.py b/tests/conftest.py index 237c53f..d8dbeb4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,8 @@ _PACKAGE_DIRS = [ "packages/plugin_sdk", "packages/persistence", "packages/cluster", + "packages/media", + "packages/content_sync", "apps/renderer", "apps/control_server", "apps/launcher", diff --git a/tests/unit/test_content_manifest.py b/tests/unit/test_content_manifest.py new file mode 100644 index 0000000..56da715 --- /dev/null +++ b/tests/unit/test_content_manifest.py @@ -0,0 +1,119 @@ +"""Unit-Tests Content-Manifest (PLAN.md §6.4, §10.1, §29.1). + +Manifest-Chunks/-Hashes, Diff für Sync, Verifikation gegen Fälschung +und Rundreise – Grundlage für Preflight und resumierbare Übertragung. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from hms_content_sync import CHUNK_SIZE, ContentManifest, hash_file + + +@pytest.fixture() +def content_tree(tmp_path: Path) -> Path: + root = tmp_path / "content" + (root / "clips").mkdir(parents=True) + (root / "clips" / "intro.mp4").write_bytes(b"A" * 1000) + (root / "clips" / "loop.mp4").write_bytes(b"B" * 500) + (root / "presets").mkdir() + (root / "presets" / "look.json").write_text("{}", encoding="utf-8") + return root + + +def test_hash_file_chunks_match_content(tmp_path: Path) -> None: + f = tmp_path / "f.bin" + f.write_bytes(b"x" * (CHUNK_SIZE + 100)) # > 1 Chunk + file_hash, chunks = hash_file(f) + assert len(chunks) == 2 # voller + Rest-Chunk + assert len(file_hash) == 64 # SHA-256 Hex + + +def test_hash_file_empty_file_yields_empty_chunk(tmp_path: Path) -> None: + f = tmp_path / "empty.bin" + f.write_bytes(b"") + file_hash, chunks = hash_file(f) + assert len(chunks) == 1 + assert file_hash # auch leere Dateien haben einen definierten Hash + + +def test_manifest_build_covers_all_files(content_tree: Path) -> None: + manifest = ContentManifest.build(content_tree, revision=7) + assert len(manifest) == 3 + assert manifest.revision == 7 + assert "clips/intro.mp4" in manifest.entries + assert "presets/look.json" in manifest.entries + entry = manifest.entries["clips/intro.mp4"] + assert entry.size_bytes == 1000 + assert len(entry.sha256) == 64 + assert entry.kind == "media" # Default-Art + + +def test_manifest_kind_classification(content_tree: Path) -> None: + manifest = ContentManifest.build( + content_tree, + kind_of=lambda rel: "plugin" if rel.endswith(".json") else "media", + ) + assert manifest.entries["presets/look.json"].kind == "plugin" + assert manifest.entries["clips/intro.mp4"].kind == "media" + + +def test_manifest_roundtrip_preserves_entries(content_tree: Path) -> None: + manifest = ContentManifest.build(content_tree, revision=3) + restored = ContentManifest.from_dict(json.loads(json.dumps(manifest.to_dict()))) + assert restored.revision == 3 + assert set(restored.entries) == set(manifest.entries) + orig = manifest.entries["clips/intro.mp4"] + copy = restored.entries["clips/intro.mp4"] + assert copy.sha256 == orig.sha256 + assert copy.chunk_hashes == orig.chunk_hashes + + +def test_manifest_is_deterministic_across_builds(content_tree: Path) -> None: + """Gleicher Inhalt → gleiche Einträge (nur manifest_id ist neu).""" + a = ContentManifest.build(content_tree) + b = ContentManifest.build(content_tree) + assert a.to_dict()["entries"] == b.to_dict()["entries"] + assert a.manifest_id != b.manifest_id # Instanz-ID ist zeitlich unique + + +def test_verify_detects_tampered_file(content_tree: Path) -> None: + manifest = ContentManifest.build(content_tree) + assert manifest.verify_file(content_tree, "clips/intro.mp4") + (content_tree / "clips" / "intro.mp4").write_bytes(b"TAMPERED" * 50) + assert not manifest.verify_file(content_tree, "clips/intro.mp4") # §6.4 Hashprüfung + + +def test_verify_detects_missing_and_unknown(content_tree: Path) -> None: + manifest = ContentManifest.build(content_tree) + (content_tree / "clips" / "loop.mp4").unlink() + assert not manifest.verify_file(content_tree, "clips/loop.mp4") + assert not manifest.verify_file(content_tree, "gibts/nicht.mp4") + + +def test_diff_drives_sync_plan(content_tree: Path) -> None: + """Diff liefert exakt die Übertragungsliste (§6.4: added/removed/changed).""" + old = ContentManifest.build(content_tree, revision=1) + # Ändern + Hinzufügen + Entfernen + (content_tree / "clips" / "intro.mp4").write_bytes(b"GEAENDERT") + (content_tree / "clips" / "neu.mp4").write_bytes(b"N") + (content_tree / "clips" / "loop.mp4").unlink() + new = ContentManifest.build(content_tree, revision=2) + + diff = old.diff(new) + assert diff.added == frozenset({"clips/neu.mp4"}) + assert diff.removed == frozenset({"clips/loop.mp4"}) + assert diff.changed == frozenset({"clips/intro.mp4"}) + assert not diff.empty + + # identische Manifeste: leerer Diff + same = old.diff(ContentManifest.from_dict(old.to_dict())) + assert same.empty + + +def test_build_rejects_missing_root(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + ContentManifest.build(tmp_path / "gibts-nicht") diff --git a/tests/unit/test_media_library.py b/tests/unit/test_media_library.py new file mode 100644 index 0000000..0bb5238 --- /dev/null +++ b/tests/unit/test_media_library.py @@ -0,0 +1,89 @@ +"""Unit-Tests Medienbibliothek (PLAN.md §13.2, §13.3).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from hms_media import ImportStatus, MediaLibrary + + +@pytest.fixture() +def media_root(tmp_path: Path) -> Path: + root = tmp_path / "media" + (root / "clips").mkdir(parents=True) + (root / "clips" / "intro.mp4").write_bytes(b"video-bytes-1" * 20) + (root / "clips" / "loop.mp4").write_bytes(b"video-bytes-2" * 20) + return root + + +def test_import_creates_asset_with_hash(media_root: Path) -> None: + lib = MediaLibrary(media_root) + outcome = lib.import_file(media_root / "clips" / "intro.mp4") + assert outcome.status is ImportStatus.IMPORTED + assert outcome.asset is not None + assert outcome.asset.content_hash # §13.2: Hash vorhanden + assert outcome.asset.rel_path == "clips/intro.mp4" + assert len(lib) == 1 + + +def test_duplicate_content_detected(media_root: Path) -> None: + """Gleicher Inhalt unter anderem Namen = Duplikat (§13.3).""" + lib = MediaLibrary(media_root) + first = lib.import_file(media_root / "clips" / "intro.mp4") + copy = media_root / "clips" / "kopie.mp4" + copy.write_bytes((media_root / "clips" / "intro.mp4").read_bytes()) + outcome = lib.import_file(copy) + assert outcome.status is ImportStatus.DUPLICATE + assert outcome.duplicate_of == first.asset.id # verweist auf das Original + assert len(lib) == 1 # kein zweiter Eintrag + + +def test_import_missing_file_reports_cleanly(media_root: Path) -> None: + lib = MediaLibrary(media_root) + outcome = lib.import_file(media_root / "clips" / "fehlt.mp4") + assert outcome.status is ImportStatus.MISSING_FILE + assert outcome.asset is None + + +def test_bank_slots_are_explicit_metadata(media_root: Path) -> None: + """Bank/Index als Show-Metadaten, unabhängig vom Dateinamen (§13.2).""" + lib = MediaLibrary(media_root) + asset = lib.import_file(media_root / "clips" / "intro.mp4").asset + lib.register_bank_slot(asset.id, bank=1, index=5) + found = lib.asset_at(bank=1, index=5) + assert found is not None and found.id == asset.id + assert lib.asset_at(bank=1, index=6) is None + with pytest.raises(KeyError): + lib.register_bank_slot("unbekannt", 1, 1) + + +def test_missing_detection_and_relink(media_root: Path) -> None: + """Fehlende Dateien werden markiert; Relink bindet neu (§13.3).""" + lib = MediaLibrary(media_root) + asset = lib.import_file(media_root / "clips" / "loop.mp4").asset + (media_root / "clips" / "loop.mp4").unlink() # Datei verschwindet + assert lib.mark_missing() == [asset.id] + assert asset.id in lib.missing_asset_ids + + # Relink auf eine neue Datei mit gleichem Namen + (media_root / "clips" / "loop.mp4").write_bytes(b"neuer-inhalt") + updated = lib.relink(asset.id, media_root / "clips" / "loop.mp4") + assert updated.content_hash != asset.content_hash # neuer Inhalt → neuer Hash + assert asset.id not in lib.missing_asset_ids + + +def test_relink_rejects_missing_target(media_root: Path) -> None: + lib = MediaLibrary(media_root) + asset = lib.import_file(media_root / "clips" / "loop.mp4").asset + with pytest.raises(FileNotFoundError): + lib.relink(asset.id, media_root / "nirgendwo.mp4") + + +def test_library_all_and_get(media_root: Path) -> None: + lib = MediaLibrary(media_root) + a = lib.import_file(media_root / "clips" / "intro.mp4").asset + b = lib.import_file(media_root / "clips" / "loop.mp4").asset + assert {x.id for x in lib.all()} == {a.id, b.id} + assert lib.get(a.id).rel_path == "clips/intro.mp4" + assert lib.get("gibts-nicht") is None diff --git a/tests/unit/test_playback.py b/tests/unit/test_playback.py new file mode 100644 index 0000000..50d8d76 --- /dev/null +++ b/tests/unit/test_playback.py @@ -0,0 +1,203 @@ +"""Unit-Tests Playback-Transport (PLAN.md §12.2, §12.5, §16.6).""" + +from __future__ import annotations + +import pytest +from hms_domain import LoopMode, TransportState +from hms_media import PlaybackController, PlaybackEventKind, PreloadSlot + + +def _controller(**overrides) -> PlaybackController: + defaults = dict( + source_id="src-1", + in_point=0.0, + out_point=1.0, + loop_mode=LoopMode.LOOP, + speed=1.0, + ) + defaults.update(overrides) + return PlaybackController(**defaults) + + +# ---------- Konstruktion und Invarianten ---------- + + +def test_rejects_invalid_in_out_points() -> None: + with pytest.raises(ValueError, match="in_point"): + _controller(in_point=0.9, out_point=0.1) + with pytest.raises(ValueError, match="in_point"): + _controller(in_point=0.5, out_point=0.5) + + +def test_initial_state_stopped_at_in_point() -> None: + c = _controller(in_point=0.2) + assert c.state is TransportState.STOPPED + assert c.position == pytest.approx(0.2) + + +# ---------- Transportbefehle (§12.5) ---------- + + +def test_play_pause_resume_cycle() -> None: + c = _controller() + c.play() + c.advance(0.1) + pos_after_play = c.position + c.pause() + assert c.state is TransportState.PAUSED + events = c.advance(1.0) # pausiert: keine Bewegung + assert events == [] + assert c.position == pytest.approx(pos_after_play) + c.play() + c.advance(0.1) + assert c.position > pos_after_play + + +def test_stop_resets_position_and_direction() -> None: + c = _controller(in_point=0.1) + c.play() + c.advance(0.3) + c.stop() + assert c.state is TransportState.STOPPED + assert c.position == pytest.approx(0.1) + assert c.direction == 1 + + +def test_retrigger_restarts_from_in_point() -> None: + c = _controller() + c.play() + c.advance(0.4) + c.retrigger() # §16.5: flankenbasierter Neustart + assert c.position == pytest.approx(0.0) + assert c.state is TransportState.PLAYING + + +def test_seek_bounds_enforced() -> None: + c = _controller(in_point=0.2, out_point=0.8) + c.seek(0.5) + assert c.position == pytest.approx(0.5) + with pytest.raises(ValueError, match="In/Out"): + c.seek(0.9) + with pytest.raises(ValueError, match="In/Out"): + c.seek(0.1) + + +def test_speed_zero_rejected_pause_is_the_way() -> None: + c = _controller() + with pytest.raises(ValueError, match="speed 0"): + c.set_speed(0.0) + + +def test_negative_speed_plays_backward() -> None: + """§16.6: negative Geschwindigkeit zugelassen; Rückwärts am In-Point + wrappt im Loop-Modus korrekt ans Ende des Fensters (§12.5).""" + c = _controller() + c.play() + c.set_speed(-1.0) + events = c.advance(0.05) + assert c.position == pytest.approx(0.95) # Wrap nach hinten: out - 0.05 + assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP + # danach läuft es weiter rückwärts Richtung In-Point + c.advance(0.05) + assert c.position == pytest.approx(0.9) + + +# ---------- Loop-Verhalten ---------- + + +def test_loop_wraps_with_event() -> None: + c = _controller(out_point=1.0) + c.play() + events = c.advance(1.25) # über das Ende hinaus + assert c.position == pytest.approx(0.25) # Wrap: Überschuss am Anfang + assert events and events[0].kind is PlaybackEventKind.LOOP_WRAP + assert c.state is TransportState.PLAYING # Loop läuft weiter + + +def test_once_stops_at_out_point_with_end_event() -> None: + c = _controller(loop_mode=LoopMode.ONCE) + c.play() + events = c.advance(0.5) + assert events == [] # noch nicht am Ende + events = c.advance(0.6) # über das Ende hinaus + assert c.state is TransportState.STOPPED # §12.5: Ende-Ereignis + assert c.position == pytest.approx(1.0) + assert events and events[0].kind is PlaybackEventKind.END_OF_MEDIA + # weiteres Advancement bleibt gestoppt + assert c.advance(0.1) == [] + + +def test_ping_pong_reverses_direction() -> None: + c = _controller(loop_mode=LoopMode.PING_PONG) + c.play() + events = c.advance(1.2) # Ende erreicht → Richtungsumkehr + assert c.direction == -1 + assert c.position == pytest.approx(0.8) # 0.2 zurückgeprallt + assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE + events = c.advance(1.6) # zurück zum Anfang → wieder vorwärts + assert c.direction == 1 + assert events and events[0].kind is PlaybackEventKind.DIRECTION_CHANGE + assert c.state is TransportState.PLAYING # Ping-Pong läuft endlos + + +def test_in_out_window_respected() -> None: + """Loop beachtet In/Out-Fenster, nicht nur 0..1 (§12.5).""" + c = _controller(in_point=0.2, out_point=0.8) + c.play() + c.advance(0.7) # 0.2 + 0.7 = 0.9 > out 0.8 + assert c.position == pytest.approx(0.3) # Wrap innerhalb des Fensters + + +def test_advance_rejects_negative_dt() -> None: + c = _controller() + c.play() + with pytest.raises(ValueError, match="negativ"): + c.advance(-0.1) + + +def test_events_carry_source_and_monotonic_time() -> None: + c = _controller() + c.play() + events = c.advance(1.5, now_ns=123456789) + assert events[0].source_id == "src-1" + assert events[0].monotonic_ns == 123456789 + + +# ---------- PreloadSlot: atomarer Clipwechsel (§12.2, §16.5) ---------- + + +def test_preload_commit_atomic_flow() -> None: + slot = PreloadSlot() + assert slot.commit() is None # nichts geladen → kein Wechsel + slot.preload("asset-a") + assert slot.pending == "asset-a" + assert slot.commit() is None # noch nicht bereit → alter Clip bleibt + slot.mark_ready("asset-a") + assert slot.commit() == "asset-a" # atomar umgeschaltet + assert slot.pending is None + assert slot.commit() is None # Slot ist wieder leer + + +def test_preload_cancel_discards() -> None: + slot = PreloadSlot() + slot.preload("asset-a") + slot.mark_ready("asset-a") + slot.cancel() + assert slot.pending is None + assert slot.commit() is None # abgebrochen wird nie committed + + +def test_preload_rejects_foreign_ready() -> None: + slot = PreloadSlot() + slot.preload("asset-a") + with pytest.raises(ValueError, match="anderes Asset"): + slot.mark_ready("asset-b") + + +def test_preload_new_preload_resets_ready() -> None: + slot = PreloadSlot() + slot.preload("asset-a") + slot.mark_ready("asset-a") + slot.preload("asset-b") # umgeladen: a wird verworfen + assert slot.ready is False + assert slot.commit() is None