1
0

playlist_model.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. """Playlist list model backed by the firmware's playlist routes.
  2. Playlists are plain ``.txt`` files on the table's SD card (one SD-relative
  3. pattern path per line). We list them via ``/sand_playlists`` and read their
  4. contents from ``/sd/playlists/<name>.txt``.
  5. """
  6. import asyncio
  7. import logging
  8. from PySide6.QtCore import QAbstractListModel, Qt, Slot, QModelIndex
  9. from PySide6.QtQml import QmlElement
  10. from firmware_client import FirmwareClient
  11. QML_IMPORT_NAME = "DuneWeaver"
  12. QML_IMPORT_MAJOR_VERSION = 1
  13. logger = logging.getLogger("DuneWeaver.PlaylistModel")
  14. def _strip_txt(name):
  15. return name[:-4] if name.endswith(".txt") else name
  16. def _parse_playlist(text):
  17. """Parse a playlist file into a list of SD-relative pattern paths."""
  18. items = []
  19. for line in text.splitlines():
  20. line = line.strip()
  21. if not line or line.startswith("#"):
  22. continue
  23. items.append(line)
  24. return items
  25. @QmlElement
  26. class PlaylistModel(QAbstractListModel):
  27. """Model for playlists, sourced from the sand table over HTTP."""
  28. NameRole = Qt.UserRole + 1
  29. ItemCountRole = Qt.UserRole + 2
  30. def __init__(self):
  31. super().__init__()
  32. self._playlists = [] # [{name, itemCount}]
  33. self._contents = {} # name -> [raw pattern path, ...]
  34. self._client = FirmwareClient.instance()
  35. self._client.baseUrlChanged.connect(lambda _u: self.refresh())
  36. self.refresh()
  37. def roleNames(self):
  38. return {
  39. self.NameRole: b"name",
  40. self.ItemCountRole: b"itemCount",
  41. }
  42. def rowCount(self, parent=QModelIndex()):
  43. return len(self._playlists)
  44. def data(self, index, role):
  45. if not index.isValid() or index.row() >= len(self._playlists):
  46. return None
  47. playlist = self._playlists[index.row()]
  48. if role == self.NameRole:
  49. return playlist["name"]
  50. elif role == self.ItemCountRole:
  51. return playlist["itemCount"]
  52. return None
  53. # -------------------------------------------------------------- fetching
  54. @Slot()
  55. def refresh(self):
  56. try:
  57. asyncio.get_event_loop().create_task(self._fetch())
  58. except RuntimeError:
  59. logger.debug("No running loop yet; playlists will load once started")
  60. async def _fetch(self):
  61. if not self._client.base_url:
  62. self._apply([], {})
  63. return
  64. try:
  65. names = await self._client.playlists()
  66. except Exception as exc:
  67. logger.warning(f"Failed to fetch playlists: {exc}")
  68. return
  69. contents = {}
  70. playlists = []
  71. for raw_name in names:
  72. name = _strip_txt(str(raw_name).lstrip("/"))
  73. items = []
  74. try:
  75. text_bytes = await self._client.fetch_sd_file(f"/playlists/{name}.txt")
  76. items = _parse_playlist(text_bytes.decode("utf-8", errors="ignore"))
  77. except Exception as exc:
  78. logger.debug(f"Failed to read playlist {name}: {exc}")
  79. contents[name] = items
  80. playlists.append({"name": name, "itemCount": len(items)})
  81. playlists.sort(key=lambda x: x["name"].lower())
  82. self._apply(playlists, contents)
  83. def _apply(self, playlists, contents):
  84. self.beginResetModel()
  85. self._playlists = playlists
  86. self._contents = contents
  87. self.endResetModel()
  88. logger.info(f"Loaded {len(self._playlists)} playlists")
  89. # --------------------------------------------------------------- queries
  90. @Slot(str, result=list)
  91. def getPatternsForPlaylist(self, playlistName):
  92. """Cleaned pattern names for display (no path, no .thr)."""
  93. cleaned = []
  94. for pattern in self._contents.get(playlistName, []):
  95. clean = pattern.split("/")[-1]
  96. if clean.endswith(".thr"):
  97. clean = clean[:-4]
  98. cleaned.append(clean)
  99. return cleaned
  100. @Slot(str, result=list)
  101. def getRawPatternsForPlaylist(self, playlistName):
  102. """Raw SD-relative pattern paths (for API calls / editing)."""
  103. return list(self._contents.get(playlistName, []))
  104. @Slot(result=list)
  105. def getAllPlaylistNames(self):
  106. return sorted(self._contents.keys())