| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619 |
- # -*- coding: utf-8 -*-
- """
- Asset-Manager für Trixy.
- Verwaltet Assets mit Profil-basiertem Fallback-System:
- 1. Suche im aktuellen Profil-Verzeichnis
- 2. Fallback auf "default" Profil
- 3. Optional: Caching für Performance
- Beispiel:
- manager = AssetManager("./assets", profile="custom_profile")
- # Sucht: ./assets/custom_profile/audio/success.wav
- # Fallback: ./assets/default/audio/success.wav
- path = manager.get("audio/success.wav")
- """
- from dataclasses import dataclass, field
- from enum import Enum, auto
- from pathlib import Path
- from typing import Iterator
- import hashlib
- import mimetypes
- import threading
- import time
- from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
- class AssetType(Enum):
- """Asset-Kategorien für typisierte Zugriffe."""
- AUDIO = auto() # Audio-Dateien (WAV, MP3, etc.)
- MUSIC = auto() # Musik-Dateien
- IMAGE = auto() # Bilder (PNG, JPG, etc.)
- VIDEO = auto() # Video-Dateien
- CONFIG = auto() # Konfigurationsdateien
- DATA = auto() # Datendateien (JSON, XML, etc.)
- MODEL = auto() # ML-Modelle
- UNKNOWN = auto() # Unbekannter Typ
- # Dateiendungen zu Asset-Typen
- _EXTENSION_MAP: dict[str, AssetType] = {
- # Audio
- ".wav": AssetType.AUDIO,
- ".mp3": AssetType.AUDIO,
- ".ogg": AssetType.AUDIO,
- ".flac": AssetType.AUDIO,
- ".aac": AssetType.AUDIO,
- ".m4a": AssetType.AUDIO,
- # Musik (gleiche Formate, aber in music/ Verzeichnis)
- # Image
- ".png": AssetType.IMAGE,
- ".jpg": AssetType.IMAGE,
- ".jpeg": AssetType.IMAGE,
- ".gif": AssetType.IMAGE,
- ".bmp": AssetType.IMAGE,
- ".svg": AssetType.IMAGE,
- ".webp": AssetType.IMAGE,
- ".ico": AssetType.IMAGE,
- # Video
- ".mp4": AssetType.VIDEO,
- ".avi": AssetType.VIDEO,
- ".mkv": AssetType.VIDEO,
- ".mov": AssetType.VIDEO,
- ".webm": AssetType.VIDEO,
- # Config
- ".json": AssetType.CONFIG,
- ".yaml": AssetType.CONFIG,
- ".yml": AssetType.CONFIG,
- ".toml": AssetType.CONFIG,
- ".ini": AssetType.CONFIG,
- # Data
- ".xml": AssetType.DATA,
- ".csv": AssetType.DATA,
- ".txt": AssetType.DATA,
- # Model
- ".pth": AssetType.MODEL,
- ".pt": AssetType.MODEL,
- ".onnx": AssetType.MODEL,
- ".h5": AssetType.MODEL,
- ".keras": AssetType.MODEL,
- }
- class AssetNotFoundError(FileNotFoundError):
- """
- Wird ausgelöst, wenn ein Asset weder im Profil noch im Default gefunden wird.
- """
- def __init__(self, asset_path: str, profile: str, base_dir: Path) -> None:
- self.asset_path = asset_path
- self.profile = profile
- self.base_dir = base_dir
- self.searched_paths = [
- base_dir / profile / asset_path,
- base_dir / "default" / asset_path,
- ]
- super().__init__(
- f"Asset nicht gefunden: '{asset_path}' "
- f"(Profil: {profile}, gesucht in: {', '.join(str(p) for p in self.searched_paths)})"
- )
- @dataclass
- class AssetInfo:
- """
- Informationen über ein Asset.
- Attributes:
- path: Absoluter Pfad zum Asset
- relative_path: Relativer Pfad (z.B. "audio/success.wav")
- profile: Profil, aus dem das Asset stammt
- is_default: True wenn aus dem Default-Profil
- asset_type: Typ des Assets
- size: Dateigröße in Bytes
- modified: Letzte Änderung (Unix-Timestamp)
- mime_type: MIME-Typ der Datei
- checksum: MD5-Hash (optional, lazy berechnet)
- """
- path: Path
- relative_path: str
- profile: str
- is_default: bool
- asset_type: AssetType
- size: int
- modified: float
- mime_type: str | None = None
- _checksum: str | None = field(default=None, repr=False)
- @property
- def checksum(self) -> str:
- """Berechnet und cached den MD5-Hash des Assets."""
- if self._checksum is None:
- self._checksum = self._calculate_checksum()
- return self._checksum
- def _calculate_checksum(self) -> str:
- """Berechnet den MD5-Hash."""
- hash_md5 = hashlib.md5()
- try:
- with open(self.path, "rb") as f:
- for chunk in iter(lambda: f.read(4096), b""):
- hash_md5.update(chunk)
- return hash_md5.hexdigest()
- except Exception:
- return ""
- @property
- def exists(self) -> bool:
- """Prüft, ob das Asset noch existiert."""
- return self.path.exists()
- @property
- def extension(self) -> str:
- """Gibt die Dateiendung zurück."""
- return self.path.suffix.lower()
- @property
- def name(self) -> str:
- """Gibt den Dateinamen zurück."""
- return self.path.name
- @property
- def stem(self) -> str:
- """Gibt den Dateinamen ohne Endung zurück."""
- return self.path.stem
- @dataclass
- class CacheEntry:
- """Eintrag im Asset-Cache."""
- info: AssetInfo
- timestamp: float
- def is_valid(self, ttl: float) -> bool:
- """Prüft, ob der Cache-Eintrag noch gültig ist."""
- return (time.time() - self.timestamp) < ttl
- class AssetManager:
- """
- Verwaltet Assets mit Profil-basiertem Fallback.
- Der AssetManager sucht Assets zuerst im konfigurierten Profil-Verzeichnis
- und fällt auf das "default" Profil zurück, wenn das Asset dort nicht
- gefunden wird.
- Beispiel:
- manager = AssetManager("./assets", profile="my_profile")
- # Einfacher Zugriff
- path = manager.get("audio/success.wav")
- # Mit Info
- info = manager.get_info("audio/success.wav")
- print(f"Gefunden in: {info.profile}, Größe: {info.size}")
- # Typ-spezifisch
- audio_path = manager.get_audio("success.wav")
- music_path = manager.get_music("song.mp3")
- # Alle Assets eines Typs
- for info in manager.list_assets(AssetType.AUDIO):
- print(info.name)
- """
- DEFAULT_PROFILE = "default"
- def __init__(
- self,
- base_dir: str | Path = "./assets",
- profile: str = "default",
- cache_enabled: bool = True,
- cache_ttl: float = 60.0,
- ) -> None:
- """
- Initialisiert den AssetManager.
- Args:
- base_dir: Basis-Verzeichnis für Assets
- profile: Aktives Profil (Fallback: "default")
- cache_enabled: Cache für Asset-Lookups aktivieren
- cache_ttl: Cache Time-to-Live in Sekunden
- """
- self._base_dir = Path(base_dir).resolve()
- self._profile = profile
- self._cache_enabled = cache_enabled
- self._cache_ttl = cache_ttl
- self._cache: dict[str, CacheEntry] = {}
- self._lock = threading.RLock()
- # Verzeichnisse erstellen falls nicht vorhanden
- self._ensure_directories()
- pdebug(f"AssetManager initialisiert: base={self._base_dir}, profile={profile}")
- def _ensure_directories(self) -> None:
- """Stellt sicher, dass die Verzeichnisse existieren."""
- default_dir = self._base_dir / self.DEFAULT_PROFILE
- profile_dir = self._base_dir / self._profile
- default_dir.mkdir(parents=True, exist_ok=True)
- if self._profile != self.DEFAULT_PROFILE:
- profile_dir.mkdir(parents=True, exist_ok=True)
- @property
- def base_dir(self) -> Path:
- """Gibt das Basis-Verzeichnis zurück."""
- return self._base_dir
- @property
- def profile(self) -> str:
- """Gibt das aktive Profil zurück."""
- return self._profile
- @profile.setter
- def profile(self, value: str) -> None:
- """Setzt das aktive Profil und leert den Cache."""
- with self._lock:
- if value != self._profile:
- self._profile = value
- self._cache.clear()
- self._ensure_directories()
- pinfo(f"Profil gewechselt zu: {value}")
- @property
- def profile_dir(self) -> Path:
- """Gibt das Profil-Verzeichnis zurück."""
- return self._base_dir / self._profile
- @property
- def default_dir(self) -> Path:
- """Gibt das Default-Verzeichnis zurück."""
- return self._base_dir / self.DEFAULT_PROFILE
- def get(self, asset_path: str, raise_on_missing: bool = False) -> Path | None:
- """
- Gibt den Pfad zu einem Asset zurück.
- Sucht zuerst im aktiven Profil, dann im Default-Profil.
- Args:
- asset_path: Relativer Pfad zum Asset (z.B. "audio/success.wav")
- raise_on_missing: Bei True wird AssetNotFoundError geworfen
- Returns:
- Absoluter Pfad zum Asset oder None wenn nicht gefunden
- Raises:
- AssetNotFoundError: Wenn raise_on_missing=True und Asset nicht gefunden
- """
- info = self.get_info(asset_path)
- if info is not None:
- return info.path
- if raise_on_missing:
- raise AssetNotFoundError(asset_path, self._profile, self._base_dir)
- return None
- def get_info(self, asset_path: str) -> AssetInfo | None:
- """
- Gibt detaillierte Informationen über ein Asset zurück.
- Args:
- asset_path: Relativer Pfad zum Asset
- Returns:
- AssetInfo oder None wenn nicht gefunden
- """
- # Normalisiere Pfad
- asset_path = asset_path.replace("\\", "/").lstrip("/")
- # Cache prüfen
- with self._lock:
- if self._cache_enabled and asset_path in self._cache:
- entry = self._cache[asset_path]
- if entry.is_valid(self._cache_ttl):
- return entry.info
- else:
- del self._cache[asset_path]
- # Suche in Profil
- profile_path = self._base_dir / self._profile / asset_path
- if profile_path.exists() and profile_path.is_file():
- info = self._create_info(profile_path, asset_path, self._profile, False)
- self._cache_info(asset_path, info)
- return info
- # Fallback auf Default
- if self._profile != self.DEFAULT_PROFILE:
- default_path = self._base_dir / self.DEFAULT_PROFILE / asset_path
- if default_path.exists() and default_path.is_file():
- info = self._create_info(default_path, asset_path, self.DEFAULT_PROFILE, True)
- self._cache_info(asset_path, info)
- return info
- return None
- def _create_info(
- self,
- path: Path,
- relative_path: str,
- profile: str,
- is_default: bool
- ) -> AssetInfo:
- """Erstellt ein AssetInfo-Objekt."""
- stat = path.stat()
- asset_type = self._detect_type(path, relative_path)
- mime_type, _ = mimetypes.guess_type(str(path))
- return AssetInfo(
- path=path,
- relative_path=relative_path,
- profile=profile,
- is_default=is_default,
- asset_type=asset_type,
- size=stat.st_size,
- modified=stat.st_mtime,
- mime_type=mime_type,
- )
- def _detect_type(self, path: Path, relative_path: str) -> AssetType:
- """Erkennt den Asset-Typ basierend auf Pfad und Endung."""
- # Musik-Verzeichnis hat Vorrang
- if relative_path.startswith("music/"):
- return AssetType.MUSIC
- extension = path.suffix.lower()
- return _EXTENSION_MAP.get(extension, AssetType.UNKNOWN)
- def _cache_info(self, asset_path: str, info: AssetInfo) -> None:
- """Fügt Info zum Cache hinzu."""
- if self._cache_enabled:
- with self._lock:
- self._cache[asset_path] = CacheEntry(info, time.time())
- def exists(self, asset_path: str) -> bool:
- """
- Prüft, ob ein Asset existiert.
- Args:
- asset_path: Relativer Pfad zum Asset
- Returns:
- True wenn das Asset existiert
- """
- return self.get_info(asset_path) is not None
- def get_audio(self, filename: str) -> Path | None:
- """
- Gibt den Pfad zu einer Audio-Datei zurück.
- Args:
- filename: Dateiname (z.B. "success.wav")
- Returns:
- Pfad oder None
- """
- return self.get(f"audio/{filename}")
- def get_music(self, filename: str) -> Path | None:
- """
- Gibt den Pfad zu einer Musik-Datei zurück.
- Args:
- filename: Dateiname (z.B. "song.mp3")
- Returns:
- Pfad oder None
- """
- return self.get(f"music/{filename}")
- def get_image(self, filename: str) -> Path | None:
- """
- Gibt den Pfad zu einer Bild-Datei zurück.
- Args:
- filename: Dateiname (z.B. "logo.png")
- Returns:
- Pfad oder None
- """
- return self.get(f"images/{filename}")
- def list_assets(
- self,
- asset_type: AssetType | None = None,
- directory: str | None = None,
- include_default: bool = True,
- ) -> Iterator[AssetInfo]:
- """
- Listet alle Assets auf.
- Args:
- asset_type: Optional: Nur Assets dieses Typs
- directory: Optional: Nur in diesem Verzeichnis
- include_default: Default-Assets einschließen
- Yields:
- AssetInfo für jedes gefundene Asset
- """
- seen: set[str] = set()
- # Zuerst Profil scannen
- profile_dir = self.profile_dir
- if directory:
- profile_dir = profile_dir / directory
- if profile_dir.exists():
- for path in profile_dir.rglob("*"):
- if path.is_file():
- relative = str(path.relative_to(self._base_dir / self._profile))
- info = self._create_info(path, relative, self._profile, False)
- if asset_type is None or info.asset_type == asset_type:
- seen.add(relative)
- yield info
- # Dann Default (wenn gewünscht)
- if include_default and self._profile != self.DEFAULT_PROFILE:
- default_dir = self.default_dir
- if directory:
- default_dir = default_dir / directory
- if default_dir.exists():
- for path in default_dir.rglob("*"):
- if path.is_file():
- relative = str(path.relative_to(self._base_dir / self.DEFAULT_PROFILE))
- # Überspringe wenn bereits im Profil gefunden
- if relative in seen:
- continue
- info = self._create_info(path, relative, self.DEFAULT_PROFILE, True)
- if asset_type is None or info.asset_type == asset_type:
- yield info
- def list_audio(self) -> Iterator[AssetInfo]:
- """Listet alle Audio-Assets."""
- return self.list_assets(AssetType.AUDIO, "audio")
- def list_music(self) -> Iterator[AssetInfo]:
- """Listet alle Musik-Assets."""
- return self.list_assets(AssetType.MUSIC, "music")
- def list_images(self) -> Iterator[AssetInfo]:
- """Listet alle Bild-Assets."""
- return self.list_assets(AssetType.IMAGE, "images")
- def list_profiles(self) -> list[str]:
- """
- Listet alle verfügbaren Profile auf.
- Returns:
- Liste der Profil-Namen
- """
- profiles = []
- if self._base_dir.exists():
- for path in self._base_dir.iterdir():
- if path.is_dir() and not path.name.startswith("."):
- profiles.append(path.name)
- # "default" sollte immer zuerst sein
- if self.DEFAULT_PROFILE in profiles:
- profiles.remove(self.DEFAULT_PROFILE)
- profiles.insert(0, self.DEFAULT_PROFILE)
- return profiles
- def copy_to_profile(
- self,
- asset_path: str,
- target_profile: str | None = None
- ) -> Path | None:
- """
- Kopiert ein Default-Asset in ein Profil.
- Nützlich um ein Default-Asset anzupassen ohne das Original zu ändern.
- Args:
- asset_path: Relativer Pfad zum Asset
- target_profile: Ziel-Profil (Standard: aktuelles Profil)
- Returns:
- Pfad zur kopierten Datei oder None
- """
- import shutil
- target_profile = target_profile or self._profile
- # Hole Info (muss aus Default kommen)
- info = self.get_info(asset_path)
- if info is None:
- pwarn(f"Asset nicht gefunden: {asset_path}")
- return None
- if not info.is_default and info.profile == target_profile:
- pdebug(f"Asset bereits im Profil: {asset_path}")
- return info.path
- # Ziel-Pfad
- target_path = self._base_dir / target_profile / asset_path
- target_path.parent.mkdir(parents=True, exist_ok=True)
- try:
- shutil.copy2(info.path, target_path)
- pinfo(f"Asset kopiert: {asset_path} -> {target_profile}")
- # Cache invalidieren
- with self._lock:
- if asset_path in self._cache:
- del self._cache[asset_path]
- return target_path
- except Exception as e:
- perror(f"Fehler beim Kopieren: {e}")
- return None
- def clear_cache(self) -> None:
- """Leert den Asset-Cache."""
- with self._lock:
- self._cache.clear()
- pdebug("Asset-Cache geleert")
- def get_stats(self) -> dict:
- """
- Gibt Statistiken über die Assets zurück.
- Returns:
- Dictionary mit Statistiken
- """
- stats = {
- "profile": self._profile,
- "base_dir": str(self._base_dir),
- "cache_enabled": self._cache_enabled,
- "cache_size": len(self._cache),
- "profiles": self.list_profiles(),
- "counts": {
- "audio": 0,
- "music": 0,
- "images": 0,
- "other": 0,
- "total": 0,
- },
- "sizes": {
- "profile": 0,
- "default": 0,
- "total": 0,
- }
- }
- for info in self.list_assets():
- stats["counts"]["total"] += 1
- stats["sizes"]["total"] += info.size
- if info.is_default:
- stats["sizes"]["default"] += info.size
- else:
- stats["sizes"]["profile"] += info.size
- if info.asset_type == AssetType.AUDIO:
- stats["counts"]["audio"] += 1
- elif info.asset_type == AssetType.MUSIC:
- stats["counts"]["music"] += 1
- elif info.asset_type == AssetType.IMAGE:
- stats["counts"]["images"] += 1
- else:
- stats["counts"]["other"] += 1
- return stats
- def __repr__(self) -> str:
- return f"AssetManager(base_dir='{self._base_dir}', profile='{self._profile}')"
|