tinyjukebox 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,568 @@
1
+ """Targets a tag can be linked to: albums (a folder with mp3 files and an m3u playlist), internet radio stations
2
+ and entries of Spotify."""
3
+
4
+ import logging
5
+ import os
6
+ import re
7
+ import urllib.parse
8
+ from abc import ABC, abstractmethod
9
+ from typing import Any
10
+
11
+ import mutagen
12
+
13
+ from tinyjukebox.core.logging import get_child
14
+ from tinyjukebox.data.common import TargetType, UpdateJson, extra_fields, is_valid_timestamp, now
15
+ from tinyjukebox.metadata import mp3_info
16
+
17
+ __all__ = [
18
+ "MAX_ALBUM_TITLE_LENGTH",
19
+ "MAX_STATION_NAME_LENGTH",
20
+ "MAX_STREAM_URL_LENGTH",
21
+ "SPOTIFY_KINDS",
22
+ "ATarget",
23
+ "Target",
24
+ "TargetAlbum",
25
+ "TargetRadio",
26
+ "TargetSpotify",
27
+ "check_album_title",
28
+ "check_spotify_link",
29
+ "check_station_name",
30
+ "check_stream_url",
31
+ "is_mp3_filename",
32
+ "read_playlist",
33
+ "spotify_kind",
34
+ "spotify_link",
35
+ ]
36
+
37
+ MAX_ALBUM_TITLE_LENGTH = 200
38
+ MAX_STATION_NAME_LENGTH = 100
39
+ MAX_STREAM_URL_LENGTH = 2000
40
+
41
+ # the fields each kind of target knows: a field beyond them was written by a newer version and is kept (extra_fields)
42
+ _COMMON_FIELDS = ("target-id", "target-type", "created", "cover")
43
+ _ALBUM_FIELDS = (*_COMMON_FIELDS, "album-folder", "playlist", "title")
44
+ _RADIO_FIELDS = (*_COMMON_FIELDS, "name", "url")
45
+ _SPOTIFY_FIELDS = (*_COMMON_FIELDS, "name", "uri")
46
+
47
+
48
+ def is_mp3_filename(name: str) -> bool:
49
+ return name.lower().endswith(".mp3")
50
+
51
+
52
+ def check_album_title(title: object) -> str:
53
+ """The title without surrounding blanks. :raises ValueError: if it is empty, too long or has control
54
+ characters."""
55
+ if not isinstance(title, str) or not title.strip():
56
+ raise ValueError("title must not be empty.")
57
+ title = title.strip()
58
+ if len(title) > MAX_ALBUM_TITLE_LENGTH:
59
+ raise ValueError(f"title must not be longer than {MAX_ALBUM_TITLE_LENGTH} characters.")
60
+ if not title.isprintable():
61
+ raise ValueError("title must not contain control characters.")
62
+ return title
63
+
64
+
65
+ def read_playlist(filename: str, playlist_name: str | None = None) -> list[str]:
66
+ """Entries of an m3u playlist: comments/directives (#EXTM3U, #EXTINF) and the playlist itself are skipped.
67
+
68
+ :raises OSError: if the playlist cannot be read.
69
+ """
70
+ with open(filename, "rb") as infile:
71
+ raw = infile.read()
72
+ try:
73
+ content = raw.decode("utf-8-sig")
74
+ except UnicodeDecodeError:
75
+ content = raw.decode("cp1252", errors="replace") # playlists saved on windows
76
+ playlist_name = playlist_name or os.path.basename(filename)
77
+ entries = (line.strip() for line in content.splitlines())
78
+ # 'ls > playlist.m3u' lists the playlist itself
79
+ return [entry for entry in entries if entry and not entry.startswith("#") and entry != playlist_name]
80
+
81
+
82
+ class ATarget(ABC):
83
+ """Base class of all targets."""
84
+
85
+ def __init__(self, target_id: str | int, target_type: str, logger: logging.Logger, update_json: UpdateJson) -> None:
86
+ self._logger = get_child(logger, f"{type(self).__name__}.{target_id}")
87
+ self.target_id: str | None = str(target_id)
88
+ self.target_type: TargetType | None = TargetType[target_type]
89
+ self._update_json = update_json
90
+ self.created: str | None = None
91
+ self.extra: dict[str, Any] = {} # fields of a newer version (extra_fields)
92
+
93
+ @abstractmethod
94
+ def sanity_check(self) -> tuple[bool, str]:
95
+ """(passed, error messages separated by newlines)"""
96
+
97
+ @abstractmethod
98
+ def to_dict(self) -> dict[str, Any]: ...
99
+
100
+ @abstractmethod
101
+ def to_text(self, compact: bool = True) -> str: ...
102
+
103
+
104
+ class TargetAlbum(ATarget):
105
+ """Entry of ``targets.json``::
106
+
107
+ {"target-id": "7", "target-type": "album", "album-folder": "Album A", "playlist": "playlist.m3u",
108
+ "created": "...", "title": "Album A (Hörspiel)", "cover": "album-1a2b3c4d.jpg"}
109
+
110
+ ``album-folder`` is relative to the albums folder (``root_folder``). ``title`` is missing in entries that were
111
+ made before albums had one: their title comes from the tracks or the folder
112
+ (:meth:`tinyjukebox.control.CatalogControl.album_title`). ``cover`` is missing for an album without a cover of
113
+ its own; the image is kept in the images folder (:mod:`tinyjukebox.images`).
114
+ """
115
+
116
+ def __init__(
117
+ self, target_dict: dict[str, Any], root_folder: str, logger: logging.Logger, update_json: UpdateJson
118
+ ) -> None:
119
+ for key in ("target-id", "target-type"):
120
+ if key not in target_dict:
121
+ logger.error("TargetAlbum.__init__ - '%s' not in dict: %s.", key, target_dict)
122
+ raise ValueError(f"TargetAlbum.__init__ - '{key}' not in dict: {target_dict}.")
123
+ super().__init__(target_dict["target-id"], target_dict["target-type"], logger, update_json)
124
+ self.root_folder: str | None = os.path.normpath(root_folder)
125
+ self.incomplete = False # files of the album are still being fetched from the library (tinyjukebox.sync)
126
+ self.load(target_dict)
127
+ self._logger.debug("__init__ - full_album_folder: %s", self.full_album_folder)
128
+
129
+ def load(self, target_dict: dict[str, Any]) -> None:
130
+ """Take over id, folder, playlist, title, cover and time stamp of ``target_dict`` (an entry of
131
+ ``targets.json``) - nothing is written."""
132
+ self.target_id = str(target_dict["target-id"])
133
+ self.extra = extra_fields(target_dict, _ALBUM_FIELDS)
134
+ title = target_dict.get("title")
135
+ self.title: str | None = title.strip() or None if isinstance(title, str) else None
136
+ self.cover: str | None = target_dict.get("cover") or None
137
+ self.album_folder: str | None = os.path.normpath(target_dict["album-folder"])
138
+ self.full_album_folder: str | None = os.path.join(str(self.root_folder), self.album_folder)
139
+ self.playlist: str | None = target_dict["playlist"]
140
+ self.created = target_dict.get("created") or now()
141
+ self._tracks: list[str] | None = None
142
+ self._tracks_of: tuple[int, int] | None = None # the state of the playlist that ``_tracks`` was read from
143
+
144
+ def set_title(self, title: object) -> None:
145
+ """Change the title. :raises ValueError: for an invalid title - nothing is changed then."""
146
+ new_title = check_album_title(title)
147
+ if new_title == self.title:
148
+ return
149
+ self._logger.info("set_title - '%s' -> '%s'", self.title, new_title)
150
+ self.title = new_title
151
+ self._update_json()
152
+
153
+ def set_cover(self, cover: str | None) -> None:
154
+ """``cover``: name of an image in the images folder, None for no cover of its own."""
155
+ if cover == self.cover:
156
+ return
157
+ self._logger.info("set_cover - '%s' -> '%s'", self.cover, cover)
158
+ self.cover = cover
159
+ self._update_json()
160
+
161
+ def get_tracks(self, force_update: bool = False) -> list[str]:
162
+ """Paths of all tracks in playlist order. The playlist is read again whenever it changed - an album may get
163
+ its files after it was listed, e.g. from the library - and with ``force_update``."""
164
+ state = self._playlist_state()
165
+ if self._tracks is None or force_update or state != self._tracks_of:
166
+ self._tracks = self._read_playlist()
167
+ self._tracks_of = state
168
+ return self._tracks
169
+
170
+ def _playlist_state(self) -> tuple[int, int] | None:
171
+ """Changes whenever the playlist is written; None without a playlist."""
172
+ if self.full_album_folder is None or self.playlist is None:
173
+ return None
174
+ try:
175
+ stat = os.stat(os.path.join(self.full_album_folder, self.playlist))
176
+ except OSError:
177
+ return None
178
+ return stat.st_mtime_ns, stat.st_size
179
+
180
+ def _read_playlist(self) -> list[str]:
181
+ if self.full_album_folder is None or self.playlist is None:
182
+ self._logger.warning("_read_playlist - full_album_folder or playlist is None")
183
+ return []
184
+ try:
185
+ entries = read_playlist(os.path.join(self.full_album_folder, self.playlist), self.playlist)
186
+ except OSError as e:
187
+ self._logger.warning("_read_playlist - failed to read playlist: %s", e)
188
+ return []
189
+ tracks = [os.path.join(self.full_album_folder, entry) for entry in entries]
190
+ self._logger.debug("_read_playlist - %s", tracks)
191
+ return tracks
192
+
193
+ def sanity_check(self) -> tuple[bool, str]:
194
+ fields_ok, fields_err = self.sanity_check_fields()
195
+ files_ok, files_err = self.sanity_check_files()
196
+ tracks_ok, tracks_err = self.sanity_check_tracks() if files_ok else (True, "")
197
+ if fields_ok and files_ok and tracks_ok:
198
+ return True, ""
199
+ errors = [
200
+ err for ok, err in ((fields_ok, fields_err), (files_ok, files_err), (tracks_ok, tracks_err)) if not ok
201
+ ]
202
+ return False, "\n".join(errors)
203
+
204
+ def sanity_check_fields(self) -> tuple[bool, str]:
205
+ errors = []
206
+ if not is_valid_timestamp(self.created):
207
+ errors.append(f"sanity_check - created timestamp error '{self.created}'")
208
+ if self.target_type != TargetType.album:
209
+ value = self.target_type.name if isinstance(self.target_type, TargetType) else self.target_type
210
+ errors.append(f"sanity_check - target type not set or not expected value '{value}'")
211
+ if not self.target_id:
212
+ errors.append("sanity_check - target-id not set.")
213
+ for message in errors:
214
+ self._logger.warning(message)
215
+ return not errors, "\n".join(errors)
216
+
217
+ def sanity_check_files(self) -> tuple[bool, str]:
218
+ # plain path checks - glob would treat '[...]' in album or file names as patterns
219
+ folder = self.full_album_folder
220
+ if folder is None or not os.path.isdir(folder):
221
+ return False, f"sanity_check - full_album_folder '{folder}' not found."
222
+ if self.playlist is not None and not os.path.isfile(os.path.join(folder, self.playlist)):
223
+ return False, f"sanity_check - playlist not found in full_album_folder '{folder}'."
224
+ if not any(is_mp3_filename(name) for name in os.listdir(folder)):
225
+ return False, f"sanity_check - no mp3-files found in full_album_folder '{folder}'."
226
+ return True, ""
227
+
228
+ def sanity_check_tracks(self) -> tuple[bool, str]:
229
+ tracks = self.get_tracks()
230
+ if not tracks:
231
+ return False, f"sanity_check - playlist {self.playlist} contains no tracks."
232
+ errors = []
233
+ for track in tracks:
234
+ if not os.path.exists(track):
235
+ errors.append(f"sanity_check - file {track} not found.")
236
+ continue
237
+ try:
238
+ mp3_info(track)
239
+ except mutagen.MutagenError:
240
+ errors.append(f"sanity_check - file {track} not valid MP3.")
241
+ return not errors, "\n".join(errors)
242
+
243
+ def to_dict(self) -> dict[str, Any]:
244
+ result = {
245
+ "target-id": self.target_id,
246
+ "target-type": self.target_type.name if self.target_type is not None else None,
247
+ "created": self.created,
248
+ "album-folder": self.album_folder,
249
+ "playlist": self.playlist,
250
+ }
251
+ if self.title is not None:
252
+ result["title"] = self.title
253
+ if self.cover is not None:
254
+ result["cover"] = self.cover
255
+ return {**result, **self.extra}
256
+
257
+ def to_text(self, compact: bool = True) -> str:
258
+ target_type = self.target_type.name if self.target_type is not None else "None"
259
+ text = (
260
+ f"id: {self.target_id}, type: {target_type}, created: {self.created}, "
261
+ f"album-folder: {self.album_folder}, playlist: {self.playlist}"
262
+ )
263
+ if self.title is not None:
264
+ text += f", title: {self.title}"
265
+ if not compact:
266
+ tracks = [os.path.relpath(t, self.root_folder) if self.root_folder else t for t in self.get_tracks()]
267
+ text += f", track-count: {len(tracks)}, tracks: {tracks}"
268
+ return text
269
+
270
+ @classmethod
271
+ def create_target(
272
+ cls, target_dict: dict[str, Any], root_folder: str, logger: logging.Logger, update_json: UpdateJson | None
273
+ ) -> "TargetAlbum":
274
+ """A new album - :raises ValueError: if the dict is not an album or the album fails the sanity check."""
275
+ if target_dict.get("target-type") != TargetType.album.name:
276
+ logger.error("TargetAlbum.create_target - wrong target type in %s.", target_dict)
277
+ raise ValueError(f"TargetAlbum.create_target - wrong target type in {target_dict}.")
278
+ if update_json is None:
279
+ logger.error("TargetAlbum.create_target - update_json method is None")
280
+ raise ValueError("TargetAlbum.create_target - update_json method is None")
281
+ if "title" in target_dict:
282
+ target_dict["title"] = check_album_title(target_dict["title"])
283
+ album = TargetAlbum(target_dict, root_folder, logger, update_json)
284
+ sane, err = album.sanity_check()
285
+ if not sane:
286
+ message = f"TargetAlbum.create_target - sanity check failed on dict '{target_dict}': {err}"
287
+ album._logger.error(message)
288
+ raise ValueError(message)
289
+ return album
290
+
291
+
292
+ def check_station_name(name: object) -> str:
293
+ """The name without surrounding blanks. :raises ValueError: if it is empty or too long."""
294
+ if not isinstance(name, str) or not name.strip():
295
+ raise ValueError("name must not be empty.")
296
+ if len(name.strip()) > MAX_STATION_NAME_LENGTH:
297
+ raise ValueError(f"name must not be longer than {MAX_STATION_NAME_LENGTH} characters.")
298
+ return name.strip()
299
+
300
+
301
+ def check_stream_url(url: object) -> str:
302
+ """The url without surrounding blanks. :raises ValueError: if it is no http(s) url with a host."""
303
+ if not isinstance(url, str) or not url.strip():
304
+ raise ValueError("url must not be empty.")
305
+ url = url.strip()
306
+ if len(url) > MAX_STREAM_URL_LENGTH:
307
+ raise ValueError(f"url must not be longer than {MAX_STREAM_URL_LENGTH} characters.")
308
+ if any(c.isspace() or not c.isprintable() for c in url):
309
+ raise ValueError("url must not contain blanks or control characters.")
310
+ try:
311
+ parts = urllib.parse.urlsplit(url)
312
+ host = parts.hostname
313
+ except ValueError:
314
+ raise ValueError(f"invalid url '{url}'.") from None
315
+ if parts.scheme.lower() not in ("http", "https") or not host:
316
+ raise ValueError("url must start with http:// or https:// and contain a host.")
317
+ return url
318
+
319
+
320
+ class TargetRadio(ATarget):
321
+ """Entry of ``targets.json``::
322
+
323
+ {"target-id": "12", "target-type": "radio", "name": "FM4", "url": "https://...", "created": "...",
324
+ "cover": "station-1a2b3c4d.jpg"}
325
+
326
+ ``url`` is the stream itself or an m3u/pls playlist that lists the stream (see
327
+ :func:`tinyjukebox.albumplayer.radioplayer.resolve_stream_url`). ``cover`` is missing for a station without a
328
+ cover; the image is kept in the images folder (:mod:`tinyjukebox.images`).
329
+ """
330
+
331
+ def __init__(self, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson) -> None:
332
+ for key in ("target-id", "target-type"):
333
+ if key not in target_dict:
334
+ logger.error("TargetRadio.__init__ - '%s' not in dict: %s.", key, target_dict)
335
+ raise ValueError(f"TargetRadio.__init__ - '{key}' not in dict: {target_dict}.")
336
+ super().__init__(target_dict["target-id"], target_dict["target-type"], logger, update_json)
337
+ self.load(target_dict)
338
+
339
+ def load(self, target_dict: dict[str, Any]) -> None:
340
+ """Take over id, name, url, cover and time stamp of ``target_dict`` (an entry of ``targets.json``) - nothing
341
+ is written."""
342
+ self.target_id = str(target_dict["target-id"])
343
+ self.extra = extra_fields(target_dict, _RADIO_FIELDS)
344
+ self.name: str = target_dict.get("name", "")
345
+ self.url: str = target_dict.get("url", "")
346
+ self.cover: str | None = target_dict.get("cover") or None
347
+ self.created = target_dict.get("created") or now()
348
+
349
+ def update(self, name: str | None = None, url: str | None = None) -> None:
350
+ """Change name and/or url (``None``: unchanged). :raises ValueError: for invalid values - nothing is
351
+ changed then."""
352
+ new_name = self.name if name is None else check_station_name(name)
353
+ new_url = self.url if url is None else check_stream_url(url)
354
+ if (new_name, new_url) == (self.name, self.url):
355
+ return
356
+ self._logger.info("update - name: '%s' -> '%s', url: '%s' -> '%s'", self.name, new_name, self.url, new_url)
357
+ self.name, self.url = new_name, new_url
358
+ self._update_json()
359
+
360
+ def set_cover(self, cover: str | None) -> None:
361
+ """``cover``: name of an image in the images folder, None for no cover."""
362
+ if cover == self.cover:
363
+ return
364
+ self._logger.info("set_cover - '%s' -> '%s'", self.cover, cover)
365
+ self.cover = cover
366
+ self._update_json()
367
+
368
+ def sanity_check(self) -> tuple[bool, str]:
369
+ errors = []
370
+ if not is_valid_timestamp(self.created):
371
+ errors.append(f"sanity_check - created timestamp error '{self.created}'")
372
+ if self.target_type != TargetType.radio:
373
+ value = self.target_type.name if isinstance(self.target_type, TargetType) else self.target_type
374
+ errors.append(f"sanity_check - target type not set or not expected value '{value}'")
375
+ if not self.target_id:
376
+ errors.append("sanity_check - target-id not set.")
377
+ for check, value in ((check_station_name, self.name), (check_stream_url, self.url)):
378
+ try:
379
+ check(value)
380
+ except ValueError as e:
381
+ errors.append(f"sanity_check - {e}")
382
+ for message in errors:
383
+ self._logger.warning(message)
384
+ return not errors, "\n".join(errors)
385
+
386
+ def to_dict(self) -> dict[str, Any]:
387
+ result = {
388
+ "target-id": self.target_id,
389
+ "target-type": self.target_type.name if self.target_type is not None else None,
390
+ "created": self.created,
391
+ "name": self.name,
392
+ "url": self.url,
393
+ }
394
+ if self.cover is not None:
395
+ result["cover"] = self.cover
396
+ return {**result, **self.extra}
397
+
398
+ def to_text(self, compact: bool = True) -> str:
399
+ target_type = self.target_type.name if self.target_type is not None else "None"
400
+ return f"id: {self.target_id}, type: {target_type}, created: {self.created}, name: {self.name}, url: {self.url}"
401
+
402
+ @classmethod
403
+ def create_target(
404
+ cls, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson | None
405
+ ) -> "TargetRadio":
406
+ """A new radio station - :raises ValueError: if the dict is not a radio station or name/url are invalid."""
407
+ if target_dict.get("target-type") != TargetType.radio.name:
408
+ logger.error("TargetRadio.create_target - wrong target type in %s.", target_dict)
409
+ raise ValueError(f"TargetRadio.create_target - wrong target type in {target_dict}.")
410
+ if update_json is None:
411
+ logger.error("TargetRadio.create_target - update_json method is None")
412
+ raise ValueError("TargetRadio.create_target - update_json method is None")
413
+ target_dict = dict(
414
+ target_dict, name=check_station_name(target_dict.get("name")), url=check_stream_url(target_dict.get("url"))
415
+ )
416
+ return TargetRadio(target_dict, logger, update_json)
417
+
418
+
419
+ SPOTIFY_KINDS = ("album", "playlist", "track", "episode", "show", "audiobook", "artist")
420
+ """What an entry of Spotify can be: ``show`` is a podcast."""
421
+ SPOTIFY_HOSTS = ("open.spotify.com", "play.spotify.com")
422
+ _SPOTIFY_ID = re.compile(r"^[A-Za-z0-9]{1,64}$")
423
+ SPOTIFY_URI = re.compile(rf"^spotify:({'|'.join(SPOTIFY_KINDS)}):[A-Za-z0-9]{{1,64}}$")
424
+
425
+
426
+ def check_spotify_link(link: object) -> str:
427
+ """The address ``spotify:<kind>:<id>`` of a link of Spotify: ``https://open.spotify.com/<kind>/<id>`` as the
428
+ Spotify app shares it (also with ``intl-de/``, ``embed/`` or ``?si=…``) or ``spotify:<kind>:<id>``.
429
+
430
+ :raises ValueError: for anything else.
431
+ """
432
+ if not isinstance(link, str) or not link.strip():
433
+ raise ValueError("link must not be empty.")
434
+ link = link.strip()
435
+ if link.lower().startswith("spotify:"):
436
+ parts = link.split(":")
437
+ else:
438
+ try:
439
+ split = urllib.parse.urlsplit(link)
440
+ except ValueError:
441
+ raise ValueError(f"invalid link '{link}'.") from None
442
+ if split.scheme.lower() not in ("http", "https") or (split.hostname or "").lower() not in SPOTIFY_HOSTS:
443
+ raise ValueError("the link must be one of open.spotify.com, as the Spotify app shares it.")
444
+ path = [part for part in split.path.split("/") if part and part != "embed" and not part.startswith("intl-")]
445
+ parts = ["spotify", *path]
446
+ if len(parts) != 3 or parts[1].lower() not in SPOTIFY_KINDS or not _SPOTIFY_ID.match(parts[2]):
447
+ raise ValueError(f"'{link}' is no album, playlist, track, episode, podcast, audiobook or artist of Spotify.")
448
+ return f"spotify:{parts[1].lower()}:{parts[2]}"
449
+
450
+
451
+ def spotify_kind(uri: str) -> str:
452
+ """The kind of an address ``spotify:<kind>:<id>`` (``show``: a podcast)."""
453
+ return uri.split(":")[1]
454
+
455
+
456
+ def spotify_link(uri: str) -> str:
457
+ """The link of Spotify for the address ``spotify:<kind>:<id>``."""
458
+ _, kind, spotify_id = uri.split(":")
459
+ return f"https://open.spotify.com/{kind}/{spotify_id}"
460
+
461
+
462
+ class TargetSpotify(ATarget):
463
+ """Entry of ``targets.json``::
464
+
465
+ {"target-id": "14", "target-type": "spotify", "name": "Die drei ???", "uri": "spotify:playlist:...",
466
+ "created": "...", "cover": "spotify-1a2b3c4d.jpg"}
467
+
468
+ ``uri`` is the address that Spotify plays (:func:`check_spotify_link`). ``cover`` is missing for an entry without a
469
+ cover; the image is kept in the images folder (:mod:`tinyjukebox.images`).
470
+ """
471
+
472
+ def __init__(self, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson) -> None:
473
+ for key in ("target-id", "target-type"):
474
+ if key not in target_dict:
475
+ logger.error("TargetSpotify.__init__ - '%s' not in dict: %s.", key, target_dict)
476
+ raise ValueError(f"TargetSpotify.__init__ - '{key}' not in dict: {target_dict}.")
477
+ super().__init__(target_dict["target-id"], target_dict["target-type"], logger, update_json)
478
+ self.load(target_dict)
479
+
480
+ def load(self, target_dict: dict[str, Any]) -> None:
481
+ """Take over id, name, uri, cover and time stamp of ``target_dict`` (an entry of ``targets.json``) - nothing
482
+ is written."""
483
+ self.target_id = str(target_dict["target-id"])
484
+ self.extra = extra_fields(target_dict, _SPOTIFY_FIELDS)
485
+ self.name: str = target_dict.get("name", "")
486
+ self.uri: str = target_dict.get("uri", "")
487
+ self.cover: str | None = target_dict.get("cover") or None
488
+ self.created = target_dict.get("created") or now()
489
+
490
+ @property
491
+ def kind(self) -> str:
492
+ return spotify_kind(self.uri) if SPOTIFY_URI.match(self.uri) else ""
493
+
494
+ def update(self, name: str | None = None, uri: str | None = None) -> None:
495
+ """Change name and/or address (``None``: unchanged). :raises ValueError: for invalid values - nothing is
496
+ changed then."""
497
+ new_name = self.name if name is None else check_station_name(name)
498
+ new_uri = self.uri if uri is None else check_spotify_link(uri)
499
+ if (new_name, new_uri) == (self.name, self.uri):
500
+ return
501
+ self._logger.info("update - name: '%s' -> '%s', uri: '%s' -> '%s'", self.name, new_name, self.uri, new_uri)
502
+ self.name, self.uri = new_name, new_uri
503
+ self._update_json()
504
+
505
+ def set_cover(self, cover: str | None) -> None:
506
+ """``cover``: name of an image in the images folder, None for no cover."""
507
+ if cover == self.cover:
508
+ return
509
+ self._logger.info("set_cover - '%s' -> '%s'", self.cover, cover)
510
+ self.cover = cover
511
+ self._update_json()
512
+
513
+ def sanity_check(self) -> tuple[bool, str]:
514
+ errors = []
515
+ if not is_valid_timestamp(self.created):
516
+ errors.append(f"sanity_check - created timestamp error '{self.created}'")
517
+ if self.target_type != TargetType.spotify:
518
+ value = self.target_type.name if isinstance(self.target_type, TargetType) else self.target_type
519
+ errors.append(f"sanity_check - target type not set or not expected value '{value}'")
520
+ if not self.target_id:
521
+ errors.append("sanity_check - target-id not set.")
522
+ try:
523
+ check_station_name(self.name)
524
+ except ValueError as e:
525
+ errors.append(f"sanity_check - {e}")
526
+ if not SPOTIFY_URI.match(self.uri):
527
+ errors.append(f"sanity_check - '{self.uri}' is no address of Spotify.")
528
+ for message in errors:
529
+ self._logger.warning(message)
530
+ return not errors, "\n".join(errors)
531
+
532
+ def to_dict(self) -> dict[str, Any]:
533
+ result = {
534
+ "target-id": self.target_id,
535
+ "target-type": self.target_type.name if self.target_type is not None else None,
536
+ "created": self.created,
537
+ "name": self.name,
538
+ "uri": self.uri,
539
+ }
540
+ if self.cover is not None:
541
+ result["cover"] = self.cover
542
+ return {**result, **self.extra}
543
+
544
+ def to_text(self, compact: bool = True) -> str:
545
+ target_type = self.target_type.name if self.target_type is not None else "None"
546
+ return f"id: {self.target_id}, type: {target_type}, created: {self.created}, name: {self.name}, uri: {self.uri}"
547
+
548
+ @classmethod
549
+ def create_target(
550
+ cls, target_dict: dict[str, Any], logger: logging.Logger, update_json: UpdateJson | None
551
+ ) -> "TargetSpotify":
552
+ """A new entry - :raises ValueError: if the dict is not an entry of Spotify or name/uri are invalid."""
553
+ if target_dict.get("target-type") != TargetType.spotify.name:
554
+ logger.error("TargetSpotify.create_target - wrong target type in %s.", target_dict)
555
+ raise ValueError(f"TargetSpotify.create_target - wrong target type in {target_dict}.")
556
+ if update_json is None:
557
+ logger.error("TargetSpotify.create_target - update_json method is None")
558
+ raise ValueError("TargetSpotify.create_target - update_json method is None")
559
+ target_dict = dict(
560
+ target_dict,
561
+ name=check_station_name(target_dict.get("name")),
562
+ uri=check_spotify_link(target_dict.get("uri")),
563
+ )
564
+ return TargetSpotify(target_dict, logger, update_json)
565
+
566
+
567
+ Target = TargetAlbum | TargetRadio | TargetSpotify
568
+ """Every target that can be linked to a tag."""
@@ -0,0 +1,88 @@
1
+ """Album folders: find new albums in the albums folder, add them to the targets, order playlists."""
2
+
3
+ import logging
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from tinyjukebox.core.logging import get_child
10
+ from tinyjukebox.data.common import TargetType
11
+ from tinyjukebox.data.target import TargetAlbum
12
+ from tinyjukebox.data.targets import Targets
13
+
14
+ __all__ = [
15
+ "add_album_to_targets",
16
+ "add_new_albums_to_targets",
17
+ "natural_key",
18
+ "playlist_order",
19
+ "scan_for_album_folders",
20
+ "scan_for_new_albums",
21
+ "write_playlist",
22
+ ]
23
+
24
+
25
+ def scan_for_album_folders(logger: logging.Logger, rootfolder: str, playlistfilename: str) -> list[str]:
26
+ """All folders below ``rootfolder`` that contain a playlist - relative to ``rootfolder``, sorted."""
27
+ my_logger = get_child(logger, "scan_for_albums")
28
+ root = Path(rootfolder)
29
+ # hidden folders hold what is not an album yet: uploads in progress, albums that boxes are sending
30
+ folders = (path.parent.relative_to(root) for path in root.rglob(playlistfilename))
31
+ albums = sorted(str(folder) for folder in folders if not any(part.startswith((".", "@")) for part in folder.parts))
32
+ my_logger.info("rootfolder: %s, playlist: %s, found album folders: %s", rootfolder, playlistfilename, albums)
33
+ return albums
34
+
35
+
36
+ def scan_for_new_albums(targets: Targets, logger: logging.Logger, rootfolder: str, playlistfilename: str) -> list[str]:
37
+ """Album folders that are not yet known as target."""
38
+ my_logger = get_child(logger, "scan_for_new_albums")
39
+ existing_folders = {album.album_folder for album in targets.albums()}
40
+ new_albums = sorted(set(scan_for_album_folders(logger, rootfolder, playlistfilename)) - existing_folders)
41
+ my_logger.info("known albums: %s, new albums: %s", sorted(f for f in existing_folders if f), new_albums)
42
+ return new_albums
43
+
44
+
45
+ def add_album_to_targets(
46
+ targets: Targets, logger: logging.Logger, folder: str, playlistfilename: str, title: str | None = None
47
+ ) -> TargetAlbum:
48
+ """:raises ValueError: if the album is not valid (see :meth:`TargetAlbum.create_target`)."""
49
+ get_child(logger, "add_album_to_targets").info("adding album in folder '%s'", folder)
50
+ target = {"target-type": TargetType.album.name, "album-folder": folder, "playlist": playlistfilename}
51
+ if title is not None:
52
+ target["title"] = title
53
+ album = targets.add_target(target)
54
+ assert isinstance(album, TargetAlbum) # noqa: S101 - created from an album dict
55
+ return album
56
+
57
+
58
+ def add_new_albums_to_targets(
59
+ targets: Targets, logger: logging.Logger, rootfolder: str, playlistfilename: str
60
+ ) -> list[TargetAlbum]:
61
+ my_logger = get_child(logger, "add_new_albums_to_targets")
62
+ return [
63
+ add_album_to_targets(targets, my_logger, folder, playlistfilename)
64
+ for folder in scan_for_new_albums(targets, my_logger, rootfolder, playlistfilename)
65
+ ]
66
+
67
+
68
+ def natural_key(text: str) -> list[int | str]:
69
+ """Sort key for file names: '2' before '10'."""
70
+ return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", text)]
71
+
72
+
73
+ def playlist_order(files: list[dict[str, Any]]) -> list[str]:
74
+ """Order of the tracks (``files``: track infos, see :func:`tinyjukebox.metadata.read_track`).
75
+
76
+ By disc/track number if every file has a track number and the numbers are unique - otherwise by file name
77
+ (natural order).
78
+ """
79
+ numbers = [(f["discnumber"] or 1, f["tracknumber"]) for f in files]
80
+ if files and all(n[1] is not None for n in numbers) and len(set(numbers)) == len(numbers):
81
+ return [f["filename"] for f in sorted(files, key=lambda f: (f["discnumber"] or 1, f["tracknumber"]))]
82
+ return sorted((f["filename"] for f in files), key=natural_key)
83
+
84
+
85
+ def write_playlist(folder: str, playlist_filename: str, tracks: list[str]) -> None:
86
+ """Write the m3u playlist (one file name per line)."""
87
+ with open(os.path.join(folder, playlist_filename), "w", encoding="utf-8") as f:
88
+ f.write("".join(name + "\n" for name in tracks))