manager.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. # -*- coding: utf-8 -*-
  2. """
  3. Asset-Manager für Trixy.
  4. Verwaltet Assets mit Profil-basiertem Fallback-System:
  5. 1. Suche im aktuellen Profil-Verzeichnis
  6. 2. Fallback auf "default" Profil
  7. 3. Optional: Caching für Performance
  8. Beispiel:
  9. manager = AssetManager("./assets", profile="custom_profile")
  10. # Sucht: ./assets/custom_profile/audio/success.wav
  11. # Fallback: ./assets/default/audio/success.wav
  12. path = manager.get("audio/success.wav")
  13. """
  14. from dataclasses import dataclass, field
  15. from enum import Enum, auto
  16. from pathlib import Path
  17. from typing import Iterator
  18. import hashlib
  19. import mimetypes
  20. import threading
  21. import time
  22. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  23. class AssetType(Enum):
  24. """Asset-Kategorien für typisierte Zugriffe."""
  25. AUDIO = auto() # Audio-Dateien (WAV, MP3, etc.)
  26. MUSIC = auto() # Musik-Dateien
  27. IMAGE = auto() # Bilder (PNG, JPG, etc.)
  28. VIDEO = auto() # Video-Dateien
  29. CONFIG = auto() # Konfigurationsdateien
  30. DATA = auto() # Datendateien (JSON, XML, etc.)
  31. MODEL = auto() # ML-Modelle
  32. UNKNOWN = auto() # Unbekannter Typ
  33. # Dateiendungen zu Asset-Typen
  34. _EXTENSION_MAP: dict[str, AssetType] = {
  35. # Audio
  36. ".wav": AssetType.AUDIO,
  37. ".mp3": AssetType.AUDIO,
  38. ".ogg": AssetType.AUDIO,
  39. ".flac": AssetType.AUDIO,
  40. ".aac": AssetType.AUDIO,
  41. ".m4a": AssetType.AUDIO,
  42. # Musik (gleiche Formate, aber in music/ Verzeichnis)
  43. # Image
  44. ".png": AssetType.IMAGE,
  45. ".jpg": AssetType.IMAGE,
  46. ".jpeg": AssetType.IMAGE,
  47. ".gif": AssetType.IMAGE,
  48. ".bmp": AssetType.IMAGE,
  49. ".svg": AssetType.IMAGE,
  50. ".webp": AssetType.IMAGE,
  51. ".ico": AssetType.IMAGE,
  52. # Video
  53. ".mp4": AssetType.VIDEO,
  54. ".avi": AssetType.VIDEO,
  55. ".mkv": AssetType.VIDEO,
  56. ".mov": AssetType.VIDEO,
  57. ".webm": AssetType.VIDEO,
  58. # Config
  59. ".json": AssetType.CONFIG,
  60. ".yaml": AssetType.CONFIG,
  61. ".yml": AssetType.CONFIG,
  62. ".toml": AssetType.CONFIG,
  63. ".ini": AssetType.CONFIG,
  64. # Data
  65. ".xml": AssetType.DATA,
  66. ".csv": AssetType.DATA,
  67. ".txt": AssetType.DATA,
  68. # Model
  69. ".pth": AssetType.MODEL,
  70. ".pt": AssetType.MODEL,
  71. ".onnx": AssetType.MODEL,
  72. ".h5": AssetType.MODEL,
  73. ".keras": AssetType.MODEL,
  74. }
  75. class AssetNotFoundError(FileNotFoundError):
  76. """
  77. Wird ausgelöst, wenn ein Asset weder im Profil noch im Default gefunden wird.
  78. """
  79. def __init__(self, asset_path: str, profile: str, base_dir: Path) -> None:
  80. self.asset_path = asset_path
  81. self.profile = profile
  82. self.base_dir = base_dir
  83. self.searched_paths = [
  84. base_dir / profile / asset_path,
  85. base_dir / "default" / asset_path,
  86. ]
  87. super().__init__(
  88. f"Asset nicht gefunden: '{asset_path}' "
  89. f"(Profil: {profile}, gesucht in: {', '.join(str(p) for p in self.searched_paths)})"
  90. )
  91. @dataclass
  92. class AssetInfo:
  93. """
  94. Informationen über ein Asset.
  95. Attributes:
  96. path: Absoluter Pfad zum Asset
  97. relative_path: Relativer Pfad (z.B. "audio/success.wav")
  98. profile: Profil, aus dem das Asset stammt
  99. is_default: True wenn aus dem Default-Profil
  100. asset_type: Typ des Assets
  101. size: Dateigröße in Bytes
  102. modified: Letzte Änderung (Unix-Timestamp)
  103. mime_type: MIME-Typ der Datei
  104. checksum: MD5-Hash (optional, lazy berechnet)
  105. """
  106. path: Path
  107. relative_path: str
  108. profile: str
  109. is_default: bool
  110. asset_type: AssetType
  111. size: int
  112. modified: float
  113. mime_type: str | None = None
  114. _checksum: str | None = field(default=None, repr=False)
  115. @property
  116. def checksum(self) -> str:
  117. """Berechnet und cached den MD5-Hash des Assets."""
  118. if self._checksum is None:
  119. self._checksum = self._calculate_checksum()
  120. return self._checksum
  121. def _calculate_checksum(self) -> str:
  122. """Berechnet den MD5-Hash."""
  123. hash_md5 = hashlib.md5()
  124. try:
  125. with open(self.path, "rb") as f:
  126. for chunk in iter(lambda: f.read(4096), b""):
  127. hash_md5.update(chunk)
  128. return hash_md5.hexdigest()
  129. except Exception:
  130. return ""
  131. @property
  132. def exists(self) -> bool:
  133. """Prüft, ob das Asset noch existiert."""
  134. return self.path.exists()
  135. @property
  136. def extension(self) -> str:
  137. """Gibt die Dateiendung zurück."""
  138. return self.path.suffix.lower()
  139. @property
  140. def name(self) -> str:
  141. """Gibt den Dateinamen zurück."""
  142. return self.path.name
  143. @property
  144. def stem(self) -> str:
  145. """Gibt den Dateinamen ohne Endung zurück."""
  146. return self.path.stem
  147. @dataclass
  148. class CacheEntry:
  149. """Eintrag im Asset-Cache."""
  150. info: AssetInfo
  151. timestamp: float
  152. def is_valid(self, ttl: float) -> bool:
  153. """Prüft, ob der Cache-Eintrag noch gültig ist."""
  154. return (time.time() - self.timestamp) < ttl
  155. class AssetManager:
  156. """
  157. Verwaltet Assets mit Profil-basiertem Fallback.
  158. Der AssetManager sucht Assets zuerst im konfigurierten Profil-Verzeichnis
  159. und fällt auf das "default" Profil zurück, wenn das Asset dort nicht
  160. gefunden wird.
  161. Beispiel:
  162. manager = AssetManager("./assets", profile="my_profile")
  163. # Einfacher Zugriff
  164. path = manager.get("audio/success.wav")
  165. # Mit Info
  166. info = manager.get_info("audio/success.wav")
  167. print(f"Gefunden in: {info.profile}, Größe: {info.size}")
  168. # Typ-spezifisch
  169. audio_path = manager.get_audio("success.wav")
  170. music_path = manager.get_music("song.mp3")
  171. # Alle Assets eines Typs
  172. for info in manager.list_assets(AssetType.AUDIO):
  173. print(info.name)
  174. """
  175. DEFAULT_PROFILE = "default"
  176. def __init__(
  177. self,
  178. base_dir: str | Path = "./assets",
  179. profile: str = "default",
  180. cache_enabled: bool = True,
  181. cache_ttl: float = 60.0,
  182. ) -> None:
  183. """
  184. Initialisiert den AssetManager.
  185. Args:
  186. base_dir: Basis-Verzeichnis für Assets
  187. profile: Aktives Profil (Fallback: "default")
  188. cache_enabled: Cache für Asset-Lookups aktivieren
  189. cache_ttl: Cache Time-to-Live in Sekunden
  190. """
  191. self._base_dir = Path(base_dir).resolve()
  192. self._profile = profile
  193. self._cache_enabled = cache_enabled
  194. self._cache_ttl = cache_ttl
  195. self._cache: dict[str, CacheEntry] = {}
  196. self._lock = threading.RLock()
  197. # Verzeichnisse erstellen falls nicht vorhanden
  198. self._ensure_directories()
  199. pdebug(f"AssetManager initialisiert: base={self._base_dir}, profile={profile}")
  200. def _ensure_directories(self) -> None:
  201. """Stellt sicher, dass die Verzeichnisse existieren."""
  202. default_dir = self._base_dir / self.DEFAULT_PROFILE
  203. profile_dir = self._base_dir / self._profile
  204. default_dir.mkdir(parents=True, exist_ok=True)
  205. if self._profile != self.DEFAULT_PROFILE:
  206. profile_dir.mkdir(parents=True, exist_ok=True)
  207. @property
  208. def base_dir(self) -> Path:
  209. """Gibt das Basis-Verzeichnis zurück."""
  210. return self._base_dir
  211. @property
  212. def profile(self) -> str:
  213. """Gibt das aktive Profil zurück."""
  214. return self._profile
  215. @profile.setter
  216. def profile(self, value: str) -> None:
  217. """Setzt das aktive Profil und leert den Cache."""
  218. with self._lock:
  219. if value != self._profile:
  220. self._profile = value
  221. self._cache.clear()
  222. self._ensure_directories()
  223. pinfo(f"Profil gewechselt zu: {value}")
  224. @property
  225. def profile_dir(self) -> Path:
  226. """Gibt das Profil-Verzeichnis zurück."""
  227. return self._base_dir / self._profile
  228. @property
  229. def default_dir(self) -> Path:
  230. """Gibt das Default-Verzeichnis zurück."""
  231. return self._base_dir / self.DEFAULT_PROFILE
  232. def get(self, asset_path: str, raise_on_missing: bool = False) -> Path | None:
  233. """
  234. Gibt den Pfad zu einem Asset zurück.
  235. Sucht zuerst im aktiven Profil, dann im Default-Profil.
  236. Args:
  237. asset_path: Relativer Pfad zum Asset (z.B. "audio/success.wav")
  238. raise_on_missing: Bei True wird AssetNotFoundError geworfen
  239. Returns:
  240. Absoluter Pfad zum Asset oder None wenn nicht gefunden
  241. Raises:
  242. AssetNotFoundError: Wenn raise_on_missing=True und Asset nicht gefunden
  243. """
  244. info = self.get_info(asset_path)
  245. if info is not None:
  246. return info.path
  247. if raise_on_missing:
  248. raise AssetNotFoundError(asset_path, self._profile, self._base_dir)
  249. return None
  250. def get_info(self, asset_path: str) -> AssetInfo | None:
  251. """
  252. Gibt detaillierte Informationen über ein Asset zurück.
  253. Args:
  254. asset_path: Relativer Pfad zum Asset
  255. Returns:
  256. AssetInfo oder None wenn nicht gefunden
  257. """
  258. # Normalisiere Pfad
  259. asset_path = asset_path.replace("\\", "/").lstrip("/")
  260. # Cache prüfen
  261. with self._lock:
  262. if self._cache_enabled and asset_path in self._cache:
  263. entry = self._cache[asset_path]
  264. if entry.is_valid(self._cache_ttl):
  265. return entry.info
  266. else:
  267. del self._cache[asset_path]
  268. # Suche in Profil
  269. profile_path = self._base_dir / self._profile / asset_path
  270. if profile_path.exists() and profile_path.is_file():
  271. info = self._create_info(profile_path, asset_path, self._profile, False)
  272. self._cache_info(asset_path, info)
  273. return info
  274. # Fallback auf Default
  275. if self._profile != self.DEFAULT_PROFILE:
  276. default_path = self._base_dir / self.DEFAULT_PROFILE / asset_path
  277. if default_path.exists() and default_path.is_file():
  278. info = self._create_info(default_path, asset_path, self.DEFAULT_PROFILE, True)
  279. self._cache_info(asset_path, info)
  280. return info
  281. return None
  282. def _create_info(
  283. self,
  284. path: Path,
  285. relative_path: str,
  286. profile: str,
  287. is_default: bool
  288. ) -> AssetInfo:
  289. """Erstellt ein AssetInfo-Objekt."""
  290. stat = path.stat()
  291. asset_type = self._detect_type(path, relative_path)
  292. mime_type, _ = mimetypes.guess_type(str(path))
  293. return AssetInfo(
  294. path=path,
  295. relative_path=relative_path,
  296. profile=profile,
  297. is_default=is_default,
  298. asset_type=asset_type,
  299. size=stat.st_size,
  300. modified=stat.st_mtime,
  301. mime_type=mime_type,
  302. )
  303. def _detect_type(self, path: Path, relative_path: str) -> AssetType:
  304. """Erkennt den Asset-Typ basierend auf Pfad und Endung."""
  305. # Musik-Verzeichnis hat Vorrang
  306. if relative_path.startswith("music/"):
  307. return AssetType.MUSIC
  308. extension = path.suffix.lower()
  309. return _EXTENSION_MAP.get(extension, AssetType.UNKNOWN)
  310. def _cache_info(self, asset_path: str, info: AssetInfo) -> None:
  311. """Fügt Info zum Cache hinzu."""
  312. if self._cache_enabled:
  313. with self._lock:
  314. self._cache[asset_path] = CacheEntry(info, time.time())
  315. def exists(self, asset_path: str) -> bool:
  316. """
  317. Prüft, ob ein Asset existiert.
  318. Args:
  319. asset_path: Relativer Pfad zum Asset
  320. Returns:
  321. True wenn das Asset existiert
  322. """
  323. return self.get_info(asset_path) is not None
  324. def get_audio(self, filename: str) -> Path | None:
  325. """
  326. Gibt den Pfad zu einer Audio-Datei zurück.
  327. Args:
  328. filename: Dateiname (z.B. "success.wav")
  329. Returns:
  330. Pfad oder None
  331. """
  332. return self.get(f"audio/{filename}")
  333. def get_music(self, filename: str) -> Path | None:
  334. """
  335. Gibt den Pfad zu einer Musik-Datei zurück.
  336. Args:
  337. filename: Dateiname (z.B. "song.mp3")
  338. Returns:
  339. Pfad oder None
  340. """
  341. return self.get(f"music/{filename}")
  342. def get_image(self, filename: str) -> Path | None:
  343. """
  344. Gibt den Pfad zu einer Bild-Datei zurück.
  345. Args:
  346. filename: Dateiname (z.B. "logo.png")
  347. Returns:
  348. Pfad oder None
  349. """
  350. return self.get(f"images/{filename}")
  351. def list_assets(
  352. self,
  353. asset_type: AssetType | None = None,
  354. directory: str | None = None,
  355. include_default: bool = True,
  356. ) -> Iterator[AssetInfo]:
  357. """
  358. Listet alle Assets auf.
  359. Args:
  360. asset_type: Optional: Nur Assets dieses Typs
  361. directory: Optional: Nur in diesem Verzeichnis
  362. include_default: Default-Assets einschließen
  363. Yields:
  364. AssetInfo für jedes gefundene Asset
  365. """
  366. seen: set[str] = set()
  367. # Zuerst Profil scannen
  368. profile_dir = self.profile_dir
  369. if directory:
  370. profile_dir = profile_dir / directory
  371. if profile_dir.exists():
  372. for path in profile_dir.rglob("*"):
  373. if path.is_file():
  374. relative = str(path.relative_to(self._base_dir / self._profile))
  375. info = self._create_info(path, relative, self._profile, False)
  376. if asset_type is None or info.asset_type == asset_type:
  377. seen.add(relative)
  378. yield info
  379. # Dann Default (wenn gewünscht)
  380. if include_default and self._profile != self.DEFAULT_PROFILE:
  381. default_dir = self.default_dir
  382. if directory:
  383. default_dir = default_dir / directory
  384. if default_dir.exists():
  385. for path in default_dir.rglob("*"):
  386. if path.is_file():
  387. relative = str(path.relative_to(self._base_dir / self.DEFAULT_PROFILE))
  388. # Überspringe wenn bereits im Profil gefunden
  389. if relative in seen:
  390. continue
  391. info = self._create_info(path, relative, self.DEFAULT_PROFILE, True)
  392. if asset_type is None or info.asset_type == asset_type:
  393. yield info
  394. def list_audio(self) -> Iterator[AssetInfo]:
  395. """Listet alle Audio-Assets."""
  396. return self.list_assets(AssetType.AUDIO, "audio")
  397. def list_music(self) -> Iterator[AssetInfo]:
  398. """Listet alle Musik-Assets."""
  399. return self.list_assets(AssetType.MUSIC, "music")
  400. def list_images(self) -> Iterator[AssetInfo]:
  401. """Listet alle Bild-Assets."""
  402. return self.list_assets(AssetType.IMAGE, "images")
  403. def list_profiles(self) -> list[str]:
  404. """
  405. Listet alle verfügbaren Profile auf.
  406. Returns:
  407. Liste der Profil-Namen
  408. """
  409. profiles = []
  410. if self._base_dir.exists():
  411. for path in self._base_dir.iterdir():
  412. if path.is_dir() and not path.name.startswith("."):
  413. profiles.append(path.name)
  414. # "default" sollte immer zuerst sein
  415. if self.DEFAULT_PROFILE in profiles:
  416. profiles.remove(self.DEFAULT_PROFILE)
  417. profiles.insert(0, self.DEFAULT_PROFILE)
  418. return profiles
  419. def copy_to_profile(
  420. self,
  421. asset_path: str,
  422. target_profile: str | None = None
  423. ) -> Path | None:
  424. """
  425. Kopiert ein Default-Asset in ein Profil.
  426. Nützlich um ein Default-Asset anzupassen ohne das Original zu ändern.
  427. Args:
  428. asset_path: Relativer Pfad zum Asset
  429. target_profile: Ziel-Profil (Standard: aktuelles Profil)
  430. Returns:
  431. Pfad zur kopierten Datei oder None
  432. """
  433. import shutil
  434. target_profile = target_profile or self._profile
  435. # Hole Info (muss aus Default kommen)
  436. info = self.get_info(asset_path)
  437. if info is None:
  438. pwarn(f"Asset nicht gefunden: {asset_path}")
  439. return None
  440. if not info.is_default and info.profile == target_profile:
  441. pdebug(f"Asset bereits im Profil: {asset_path}")
  442. return info.path
  443. # Ziel-Pfad
  444. target_path = self._base_dir / target_profile / asset_path
  445. target_path.parent.mkdir(parents=True, exist_ok=True)
  446. try:
  447. shutil.copy2(info.path, target_path)
  448. pinfo(f"Asset kopiert: {asset_path} -> {target_profile}")
  449. # Cache invalidieren
  450. with self._lock:
  451. if asset_path in self._cache:
  452. del self._cache[asset_path]
  453. return target_path
  454. except Exception as e:
  455. perror(f"Fehler beim Kopieren: {e}")
  456. return None
  457. def clear_cache(self) -> None:
  458. """Leert den Asset-Cache."""
  459. with self._lock:
  460. self._cache.clear()
  461. pdebug("Asset-Cache geleert")
  462. def get_stats(self) -> dict:
  463. """
  464. Gibt Statistiken über die Assets zurück.
  465. Returns:
  466. Dictionary mit Statistiken
  467. """
  468. stats = {
  469. "profile": self._profile,
  470. "base_dir": str(self._base_dir),
  471. "cache_enabled": self._cache_enabled,
  472. "cache_size": len(self._cache),
  473. "profiles": self.list_profiles(),
  474. "counts": {
  475. "audio": 0,
  476. "music": 0,
  477. "images": 0,
  478. "other": 0,
  479. "total": 0,
  480. },
  481. "sizes": {
  482. "profile": 0,
  483. "default": 0,
  484. "total": 0,
  485. }
  486. }
  487. for info in self.list_assets():
  488. stats["counts"]["total"] += 1
  489. stats["sizes"]["total"] += info.size
  490. if info.is_default:
  491. stats["sizes"]["default"] += info.size
  492. else:
  493. stats["sizes"]["profile"] += info.size
  494. if info.asset_type == AssetType.AUDIO:
  495. stats["counts"]["audio"] += 1
  496. elif info.asset_type == AssetType.MUSIC:
  497. stats["counts"]["music"] += 1
  498. elif info.asset_type == AssetType.IMAGE:
  499. stats["counts"]["images"] += 1
  500. else:
  501. stats["counts"]["other"] += 1
  502. return stats
  503. def __repr__(self) -> str:
  504. return f"AssetManager(base_dir='{self._base_dir}', profile='{self._profile}')"