manager.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. # -*- coding: utf-8 -*-
  2. """
  3. Playlist-Manager.
  4. Verwaltet mehrere Playlists mit Speicherung und Laden.
  5. """
  6. import json
  7. import logging
  8. from datetime import datetime
  9. from pathlib import Path
  10. from typing import Any
  11. from trixy_core.music.playlist.playlist import Playlist, PlaylistItem, RepeatMode, ShuffleMode
  12. from trixy_core.music.track import Track, TrackMetadata
  13. class PlaylistManager:
  14. """
  15. Verwaltet mehrere Playlists.
  16. Bietet Funktionen zum Erstellen, Speichern, Laden und Suchen von Playlists.
  17. """
  18. def __init__(
  19. self,
  20. storage_path: Path | None = None,
  21. auto_save: bool = True,
  22. ) -> None:
  23. """
  24. Initialisiert den PlaylistManager.
  25. Args:
  26. storage_path: Pfad für Playlist-Speicherung
  27. auto_save: Automatisch speichern bei Änderungen
  28. """
  29. self.storage_path = storage_path or Path("./playlists")
  30. self.auto_save = auto_save
  31. self.logger = logging.getLogger(__name__)
  32. self._playlists: dict[str, Playlist] = {}
  33. self._favorites: Playlist | None = None
  34. # Storage-Verzeichnis erstellen
  35. if self.storage_path:
  36. self.storage_path.mkdir(parents=True, exist_ok=True)
  37. @property
  38. def count(self) -> int:
  39. """Anzahl der Playlists."""
  40. return len(self._playlists)
  41. @property
  42. def playlists(self) -> list[Playlist]:
  43. """Alle Playlists."""
  44. return list(self._playlists.values())
  45. @property
  46. def favorites(self) -> Playlist:
  47. """Favoriten-Playlist (wird automatisch erstellt)."""
  48. if not self._favorites:
  49. self._favorites = self.get("__favorites__")
  50. if not self._favorites:
  51. self._favorites = self.create(
  52. name="Favoriten",
  53. playlist_id="__favorites__",
  54. )
  55. return self._favorites
  56. # ==========================================================================
  57. # CRUD-Operationen
  58. # ==========================================================================
  59. def create(
  60. self,
  61. name: str,
  62. description: str = "",
  63. owner: str = "",
  64. playlist_id: str = "",
  65. ) -> Playlist:
  66. """
  67. Erstellt eine neue Playlist.
  68. Args:
  69. name: Name der Playlist
  70. description: Beschreibung
  71. owner: Eigentümer
  72. playlist_id: Optionale ID
  73. Returns:
  74. Erstellte Playlist
  75. """
  76. playlist = Playlist(
  77. id=playlist_id,
  78. name=name,
  79. description=description,
  80. owner=owner,
  81. )
  82. self._playlists[playlist.id] = playlist
  83. if self.auto_save:
  84. self.save_playlist(playlist)
  85. self.logger.debug(f"Playlist erstellt: {playlist.name} ({playlist.id})")
  86. return playlist
  87. def get(self, playlist_id: str) -> Playlist | None:
  88. """
  89. Holt Playlist nach ID.
  90. Args:
  91. playlist_id: Playlist-ID
  92. Returns:
  93. Playlist oder None
  94. """
  95. return self._playlists.get(playlist_id)
  96. def get_by_name(self, name: str) -> list[Playlist]:
  97. """
  98. Sucht Playlists nach Name.
  99. Args:
  100. name: Suchstring (case-insensitive)
  101. Returns:
  102. Liste passender Playlists
  103. """
  104. name_lower = name.lower()
  105. return [p for p in self._playlists.values() if name_lower in p.name.lower()]
  106. def delete(self, playlist_id: str) -> bool:
  107. """
  108. Löscht eine Playlist.
  109. Args:
  110. playlist_id: Playlist-ID
  111. Returns:
  112. True wenn gelöscht
  113. """
  114. if playlist_id in self._playlists:
  115. playlist = self._playlists.pop(playlist_id)
  116. # Datei löschen
  117. if self.storage_path:
  118. file_path = self.storage_path / f"{playlist_id}.json"
  119. if file_path.exists():
  120. file_path.unlink()
  121. self.logger.debug(f"Playlist gelöscht: {playlist.name}")
  122. return True
  123. return False
  124. def rename(self, playlist_id: str, new_name: str) -> bool:
  125. """
  126. Benennt eine Playlist um.
  127. Args:
  128. playlist_id: Playlist-ID
  129. new_name: Neuer Name
  130. Returns:
  131. True wenn erfolgreich
  132. """
  133. playlist = self.get(playlist_id)
  134. if playlist:
  135. playlist.name = new_name
  136. playlist.modified_at = datetime.now()
  137. if self.auto_save:
  138. self.save_playlist(playlist)
  139. return True
  140. return False
  141. def duplicate(self, playlist_id: str, new_name: str = "") -> Playlist | None:
  142. """
  143. Dupliziert eine Playlist.
  144. Args:
  145. playlist_id: ID der zu duplizierenden Playlist
  146. new_name: Name der Kopie
  147. Returns:
  148. Duplizierte Playlist oder None
  149. """
  150. original = self.get(playlist_id)
  151. if not original:
  152. return None
  153. copy = self.create(
  154. name=new_name or f"{original.name} (Kopie)",
  155. description=original.description,
  156. owner=original.owner,
  157. )
  158. # Tracks kopieren
  159. for item in original.items:
  160. copy.add(item.track, added_by=item.added_by)
  161. return copy
  162. # ==========================================================================
  163. # Speicherung
  164. # ==========================================================================
  165. def save_playlist(self, playlist: Playlist) -> bool:
  166. """
  167. Speichert eine Playlist.
  168. Args:
  169. playlist: Zu speichernde Playlist
  170. Returns:
  171. True wenn erfolgreich
  172. """
  173. if not self.storage_path:
  174. return False
  175. file_path = self.storage_path / f"{playlist.id}.json"
  176. try:
  177. data = self._playlist_to_dict(playlist)
  178. with open(file_path, "w", encoding="utf-8") as f:
  179. json.dump(data, f, indent=2, ensure_ascii=False)
  180. return True
  181. except Exception as e:
  182. self.logger.error(f"Fehler beim Speichern: {e}")
  183. return False
  184. def load_playlist(self, playlist_id: str) -> Playlist | None:
  185. """
  186. Lädt eine Playlist aus Datei.
  187. Args:
  188. playlist_id: Playlist-ID
  189. Returns:
  190. Geladene Playlist oder None
  191. """
  192. if not self.storage_path:
  193. return None
  194. file_path = self.storage_path / f"{playlist_id}.json"
  195. if not file_path.exists():
  196. return None
  197. try:
  198. with open(file_path, "r", encoding="utf-8") as f:
  199. data = json.load(f)
  200. playlist = self._dict_to_playlist(data)
  201. self._playlists[playlist.id] = playlist
  202. return playlist
  203. except Exception as e:
  204. self.logger.error(f"Fehler beim Laden: {e}")
  205. return None
  206. def load_all(self) -> int:
  207. """
  208. Lädt alle Playlists aus dem Storage-Verzeichnis.
  209. Returns:
  210. Anzahl der geladenen Playlists
  211. """
  212. if not self.storage_path or not self.storage_path.exists():
  213. return 0
  214. count = 0
  215. for file_path in self.storage_path.glob("*.json"):
  216. playlist_id = file_path.stem
  217. if self.load_playlist(playlist_id):
  218. count += 1
  219. self.logger.info(f"{count} Playlists geladen")
  220. return count
  221. def save_all(self) -> int:
  222. """
  223. Speichert alle Playlists.
  224. Returns:
  225. Anzahl der gespeicherten Playlists
  226. """
  227. count = 0
  228. for playlist in self._playlists.values():
  229. if self.save_playlist(playlist):
  230. count += 1
  231. return count
  232. # ==========================================================================
  233. # Serialisierung
  234. # ==========================================================================
  235. def _playlist_to_dict(self, playlist: Playlist) -> dict[str, Any]:
  236. """Konvertiert Playlist zu Dictionary für Speicherung."""
  237. return {
  238. "id": playlist.id,
  239. "name": playlist.name,
  240. "description": playlist.description,
  241. "owner": playlist.owner,
  242. "is_public": playlist.is_public,
  243. "repeat_mode": playlist.repeat_mode.value,
  244. "shuffle_mode": playlist.shuffle_mode.value,
  245. "artwork_url": playlist.artwork_url,
  246. "created_at": playlist.created_at.isoformat(),
  247. "modified_at": playlist.modified_at.isoformat(),
  248. "metadata": playlist.metadata,
  249. "items": [
  250. {
  251. "track": {
  252. "id": item.track.id,
  253. "uri": item.track.uri,
  254. "source_type": item.track.source_type,
  255. "source_id": item.track.source_id,
  256. "path": str(item.track.path) if item.track.path else None,
  257. "metadata": {
  258. "title": item.track.metadata.title,
  259. "artist": item.track.metadata.artist,
  260. "album": item.track.metadata.album,
  261. "duration_ms": item.track.metadata.duration_ms,
  262. "artwork_url": item.track.metadata.artwork_url,
  263. },
  264. },
  265. "position": item.position,
  266. "added_at": item.added_at.isoformat(),
  267. "added_by": item.added_by,
  268. }
  269. for item in playlist.items
  270. ],
  271. }
  272. def _dict_to_playlist(self, data: dict[str, Any]) -> Playlist:
  273. """Konvertiert Dictionary zu Playlist."""
  274. playlist = Playlist(
  275. id=data["id"],
  276. name=data["name"],
  277. description=data.get("description", ""),
  278. owner=data.get("owner", ""),
  279. is_public=data.get("is_public", False),
  280. repeat_mode=RepeatMode(data.get("repeat_mode", "off")),
  281. shuffle_mode=ShuffleMode(data.get("shuffle_mode", "off")),
  282. artwork_url=data.get("artwork_url", ""),
  283. metadata=data.get("metadata", {}),
  284. )
  285. # Zeitstempel
  286. if "created_at" in data:
  287. playlist.created_at = datetime.fromisoformat(data["created_at"])
  288. if "modified_at" in data:
  289. playlist.modified_at = datetime.fromisoformat(data["modified_at"])
  290. # Items laden
  291. for item_data in data.get("items", []):
  292. track_data = item_data["track"]
  293. metadata = TrackMetadata(
  294. title=track_data.get("metadata", {}).get("title", ""),
  295. artist=track_data.get("metadata", {}).get("artist", ""),
  296. album=track_data.get("metadata", {}).get("album", ""),
  297. duration_ms=track_data.get("metadata", {}).get("duration_ms", 0),
  298. artwork_url=track_data.get("metadata", {}).get("artwork_url", ""),
  299. )
  300. track = Track(
  301. id=track_data.get("id", ""),
  302. uri=track_data.get("uri", ""),
  303. source_type=track_data.get("source_type", ""),
  304. source_id=track_data.get("source_id", ""),
  305. path=Path(track_data["path"]) if track_data.get("path") else None,
  306. metadata=metadata,
  307. )
  308. item = PlaylistItem(
  309. track=track,
  310. position=item_data.get("position", 0),
  311. added_by=item_data.get("added_by", ""),
  312. )
  313. if "added_at" in item_data:
  314. item.added_at = datetime.fromisoformat(item_data["added_at"])
  315. playlist.items.append(item)
  316. return playlist
  317. # ==========================================================================
  318. # Hilfsmethoden
  319. # ==========================================================================
  320. def search(self, query: str) -> list[tuple[Playlist, list[Track]]]:
  321. """
  322. Sucht in allen Playlists nach Tracks.
  323. Args:
  324. query: Suchstring
  325. Returns:
  326. Liste von (Playlist, passende Tracks)
  327. """
  328. query_lower = query.lower()
  329. results = []
  330. for playlist in self._playlists.values():
  331. matching_tracks = []
  332. for item in playlist.items:
  333. if (query_lower in item.track.title.lower() or
  334. query_lower in item.track.artist.lower() or
  335. query_lower in item.track.metadata.album.lower()):
  336. matching_tracks.append(item.track)
  337. if matching_tracks:
  338. results.append((playlist, matching_tracks))
  339. return results
  340. def get_recent(self, limit: int = 10) -> list[Playlist]:
  341. """
  342. Holt kürzlich geänderte Playlists.
  343. Args:
  344. limit: Maximale Anzahl
  345. Returns:
  346. Liste der Playlists (neueste zuerst)
  347. """
  348. sorted_playlists = sorted(
  349. self._playlists.values(),
  350. key=lambda p: p.modified_at,
  351. reverse=True,
  352. )
  353. return sorted_playlists[:limit]
  354. def __contains__(self, playlist_id: str) -> bool:
  355. return playlist_id in self._playlists
  356. def __getitem__(self, playlist_id: str) -> Playlist:
  357. return self._playlists[playlist_id]
  358. def __iter__(self):
  359. return iter(self._playlists.values())
  360. def __len__(self) -> int:
  361. return len(self._playlists)