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
tinyjukebox/cli.py ADDED
@@ -0,0 +1,1076 @@
1
+ """Commands of the interactive command line (core UI) - text front end of :class:`~tinyjukebox.control.BoxControl`.
2
+
3
+ Output goes to the console with ``print``/``more``: this is the user interface, not logging.
4
+ """
5
+
6
+ import functools
7
+ import os
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ import tinyjukebox.core.ui.tools
12
+ from tinyjukebox.control import BoxControl
13
+ from tinyjukebox.errors import ApiError
14
+ from tinyjukebox.sync import acting_as
15
+
16
+ __all__ = ["CLI"]
17
+
18
+ AddCommand = Callable[[str, Callable[[str], None]], None]
19
+
20
+
21
+ def _confirm() -> bool:
22
+ return tinyjukebox.core.ui.tools.get_yes_no() == "y"
23
+
24
+
25
+ def _target_text(tag: dict[str, Any]) -> str:
26
+ """What the tag is linked to, for the console."""
27
+ if tag["radio_id"] is not None:
28
+ return f"radio station {tag['radio_id']}"
29
+ if tag["spotify_id"] is not None:
30
+ return f"entry of Spotify {tag['spotify_id']}"
31
+ return f"album {tag['album_id'] or '-'}"
32
+
33
+
34
+ def _at_the_console(command: Callable[[str], None]) -> Callable[[str], None]:
35
+ """What the command changes is noted as entered on the console (tinyjukebox.sync)."""
36
+
37
+ @functools.wraps(command)
38
+ def wrapped(args: str) -> None:
39
+ with acting_as("console"):
40
+ command(args)
41
+
42
+ return wrapped
43
+
44
+
45
+ class CLI:
46
+ def __init__(self, add_ui_command: AddCommand, control: BoxControl) -> None:
47
+ self._control = control
48
+ for name in [
49
+ "list_tags",
50
+ "list_albums",
51
+ "list_activities",
52
+ "unmapped_tags",
53
+ "unmapped_albums",
54
+ "list_folders",
55
+ "unadded_folders",
56
+ "sanity_checks",
57
+ "rebuild_activity_json",
58
+ "set_tag_name",
59
+ "link_tag_album",
60
+ "link_current_tag",
61
+ "unlink_tag",
62
+ "remove_tag",
63
+ "track_prev",
64
+ "track_next",
65
+ "track_pause",
66
+ "track_play",
67
+ "restart_album",
68
+ "set_volume",
69
+ "get_volume",
70
+ "get_volume_limits",
71
+ "set_volume_limits",
72
+ "bluetooth_info",
73
+ "bluetooth_pair",
74
+ "bluetooth_unpair",
75
+ "album_info",
76
+ "system_state",
77
+ "add_album",
78
+ "add_new_albums",
79
+ "set_album_title",
80
+ "set_album_cover",
81
+ "list_radios",
82
+ "add_radio",
83
+ "set_radio_name",
84
+ "set_radio_url",
85
+ "set_radio_cover",
86
+ "remove_radio",
87
+ "test_radio",
88
+ "stop_radio_test",
89
+ "link_tag_radio",
90
+ "link_current_tag_radio",
91
+ "list_spotify",
92
+ "add_spotify",
93
+ "set_spotify_name",
94
+ "set_spotify_link",
95
+ "set_spotify_cover",
96
+ "remove_spotify",
97
+ "link_tag_spotify",
98
+ "link_current_tag_spotify",
99
+ "test_spotify",
100
+ "spotify_account",
101
+ "set_spotify_key",
102
+ "connect_spotify",
103
+ "disconnect_spotify",
104
+ "set_spotify_speaker",
105
+ "spotify_player",
106
+ "update_spotify_player",
107
+ "library_info",
108
+ "set_library",
109
+ "unlink_library",
110
+ "system_info",
111
+ "backup",
112
+ "restore",
113
+ ]:
114
+ add_ui_command(name, _at_the_console(getattr(self, "cmd_" + name)))
115
+
116
+ # --- lists -------------------------------------------------------------------------------------------------
117
+
118
+ def cmd_list_tags(self, args: str) -> None:
119
+ """list_tags - list of all tags"""
120
+ output = "list of all tags:\n"
121
+ for tag in self._control.list_tags():
122
+ if tag["radio_id"]:
123
+ target = f"radio: {tag['radio_id']}"
124
+ elif tag["spotify_id"]:
125
+ target = f"spotify: {tag['spotify_id']}"
126
+ else:
127
+ target = f"album: {tag['album_id'] or '-'}"
128
+ output += f"[{tag['uid']}] '{tag['name']}' {target} created: {tag['created']}\n"
129
+ tinyjukebox.core.ui.tools.more(output)
130
+
131
+ def _album_lines(self, unmapped_only: bool) -> str:
132
+ return "".join(
133
+ f"[{a['id']}] {self._control.album_text(a['id'])}\n" for a in self._control.list_albums(unmapped_only)
134
+ )
135
+
136
+ def cmd_list_albums(self, args: str) -> None:
137
+ """list_albums - list of all albums"""
138
+ tinyjukebox.core.ui.tools.more("list of all albums:\n" + self._album_lines(False))
139
+
140
+ def cmd_list_activities(self, args: str) -> None:
141
+ """list_activities - list of all activities"""
142
+ output = "list of all activities:\n"
143
+ for a in self._control.list_activities():
144
+ output += (
145
+ f"[{a['tag_uid']}] album: {a['album_id']}, track id: {a['track_id']}, time: {a['track_time']}, "
146
+ f"finished: {a['finished']}\n"
147
+ )
148
+ tinyjukebox.core.ui.tools.more(output)
149
+
150
+ def cmd_unmapped_tags(self, args: str) -> None:
151
+ """unmapped_tags - list of all tags that are not linked to an album or radio station"""
152
+ output = "list of all unmapped tags:\n"
153
+ for tag in self._control.list_tags(unmapped_only=True):
154
+ output += f"[{tag['uid']}] '{tag['name']}' created: {tag['created']}\n"
155
+ tinyjukebox.core.ui.tools.more(output)
156
+
157
+ def cmd_unmapped_albums(self, args: str) -> None:
158
+ """unmapped_albums - list of all albums that are not linked to a tag"""
159
+ tinyjukebox.core.ui.tools.more("list of all unmapped albums:\n" + self._album_lines(True))
160
+
161
+ def cmd_list_folders(self, args: str) -> None:
162
+ """list_folders - list of all folders with a playlist in albums-folder (added and not yet added albums)"""
163
+ output = "list of all album folders:\n" + "".join(f"{folder}\n" for folder in self._control.album_folders())
164
+ tinyjukebox.core.ui.tools.more(output)
165
+
166
+ def cmd_unadded_folders(self, args: str) -> None:
167
+ """unadded_folders - list of all folders with a playlist in albums-folder that are not added as album"""
168
+ folders = self._control.new_album_folders()
169
+ tinyjukebox.core.ui.tools.more("list of all unadded folders:\n" + "".join(f"{folder}\n" for folder in folders))
170
+
171
+ # --- maintenance -------------------------------------------------------------------------------------------
172
+
173
+ def cmd_sanity_checks(self, args: str) -> None:
174
+ """sanity_checks - check tags, albums and activities"""
175
+ print("running sanity checks:")
176
+ result = self._control.sanity_checks()
177
+ for message in result["messages"]:
178
+ print(f" - {message}")
179
+ print(f"success: {result['passed']}")
180
+
181
+ def cmd_rebuild_activity_json(self, args: str) -> None:
182
+ """rebuild_activity_json - rebuild the activities: all albums start from the beginning."""
183
+ print("rebuilding activity json - all album states would be reset to start.")
184
+ if not _confirm():
185
+ print("canceled.")
186
+ return
187
+ self._control.rebuild_activities()
188
+ print("finished rebuilding activity json.")
189
+
190
+ # --- tags --------------------------------------------------------------------------------------------------
191
+
192
+ def cmd_set_tag_name(self, args: str) -> None:
193
+ """set_tag_name - sets the name of a tag: SET_TAG_NAME UID this is the new name"""
194
+ words = list(tinyjukebox.core.ui.tools.parse(args))
195
+ if len(words) < 2:
196
+ print(f"wrong number of arguments (got {len(words)}, expected at least 2)")
197
+ return
198
+ uid, name = words[0], " ".join(words[1:])
199
+ try:
200
+ self._control.set_tag_name(uid, name)
201
+ except ApiError:
202
+ print(f"unknown uid '{uid}'")
203
+ return
204
+ print(f"[{uid}] updated name to '{name}'")
205
+
206
+ def _link_tag_album(self, tag_uid: str, album_id: str) -> None:
207
+ try:
208
+ tag = self._control.get_tag(tag_uid)
209
+ except ApiError:
210
+ print(f"unknown tag-uid {tag_uid}.")
211
+ return
212
+ try:
213
+ album = self._control.album_text(album_id)
214
+ except ApiError:
215
+ print(f"unknown album-id {album_id}.")
216
+ return
217
+ print(f"linking tag '{tag['uid']}' ('{tag['name']}') with album '{album}'.")
218
+ if not _confirm():
219
+ print("canceled.")
220
+ return
221
+ result = self._control.link_tag_album(tag_uid, album_id)
222
+ print(f"link result: tag {result['uid']} -> album {result['album_id']}")
223
+ if result["present"]:
224
+ print("the tag is on the box - the album starts now.")
225
+
226
+ def cmd_link_tag_album(self, args: str) -> None:
227
+ """link_tag_album - links a tag with an album.
228
+
229
+ LINK_TAG_ALBUM TAG-UID ALBUM-ID - link a specific tag with an album
230
+ LINK_TAG_ALBUM AUTO ALBUM-ID - link the unlinked tag on the box with an album
231
+ """
232
+ words = tinyjukebox.core.ui.tools.parse(args)
233
+ if len(words) != 2:
234
+ print(f"wrong number of arguments (got {len(words)}, expected 2)")
235
+ return
236
+ tag_uid = self._tag_uid_or_auto(words[0])
237
+ if tag_uid is not None:
238
+ self._link_tag_album(tag_uid, words[1])
239
+
240
+ def _tag_uid_or_auto(self, word: str) -> str | None:
241
+ """``word`` or - for AUTO - the unlinked tag on the box (None if there is none)."""
242
+ if word.upper() != "AUTO":
243
+ return word
244
+ tag_uid = self._control.current_unlinked_tag()
245
+ if tag_uid is None:
246
+ print("No unlinked tag currently present.")
247
+ else:
248
+ print(f"Using currently present unlinked tag: {tag_uid}")
249
+ return tag_uid
250
+
251
+ def cmd_link_current_tag(self, args: str) -> None:
252
+ """link_current_tag - links the unlinked tag on the box with an album.
253
+
254
+ LINK_CURRENT_TAG - show the albums that are not linked yet
255
+ LINK_CURRENT_TAG ALBUM-ID - link the unlinked tag on the box with the album
256
+ """
257
+ current_uid = self._control.current_unlinked_tag()
258
+ if current_uid is None:
259
+ print("No unlinked tag currently present.")
260
+ return
261
+ print(f"Using currently present unlinked tag: {current_uid}")
262
+ words = tinyjukebox.core.ui.tools.parse(args)
263
+ if not words:
264
+ self.cmd_unmapped_albums("")
265
+ elif len(words) == 1:
266
+ self._link_tag_album(current_uid, words[0])
267
+ else:
268
+ print(f"wrong number of arguments (got {len(words)}, expected 0 or 1)")
269
+
270
+ def cmd_unlink_tag(self, args: str) -> None:
271
+ """unlink_tag - removes the link of a tag to its album or radio station (the position is kept).
272
+
273
+ UNLINK_TAG TAG-UID - unlink a specific tag
274
+ UNLINK_TAG CURRENT - unlink the tag on the box (the playback stops)
275
+ """
276
+ words = tinyjukebox.core.ui.tools.parse(args)
277
+ if len(words) != 1:
278
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
279
+ return
280
+ uid = "current" if words[0].upper() == "CURRENT" else words[0]
281
+ try:
282
+ tag = self._control.get_tag(uid)
283
+ except ApiError as e:
284
+ print(e.message)
285
+ return
286
+ if tag["album_id"] is None and tag["radio_id"] is None and tag["spotify_id"] is None:
287
+ print(f"tag '{tag['uid']}' is not linked.")
288
+ return
289
+ target = _target_text(tag)
290
+ print(f"unlinking tag '{tag['uid']}' ('{tag['name']}') from {target}.")
291
+ if not _confirm():
292
+ print("canceled.")
293
+ return
294
+ result = self._control.unlink_tag(str(tag["uid"]))
295
+ print(f"unlinked tag {result['uid']}.")
296
+ if result["present"]:
297
+ print("the tag is on the box - the playback stops.")
298
+
299
+ def cmd_remove_tag(self, args: str) -> None:
300
+ """remove_tag - remove a tag with its activity and card: REMOVE_TAG UID"""
301
+ words = tinyjukebox.core.ui.tools.parse(args)
302
+ if len(words) != 1:
303
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
304
+ return
305
+ tag_uid = words[0]
306
+ try:
307
+ tag = self._control.get_tag(tag_uid)
308
+ except ApiError:
309
+ print(f"unknown tag-uid {tag_uid}.")
310
+ return
311
+ print(f"removing tag {tag['uid']} ('{tag['name']}', album: {tag['album_id']}) from system.")
312
+ if not _confirm():
313
+ print("canceled.")
314
+ return
315
+ self._control.remove_tag(tag_uid)
316
+ print(f"removed tag uid: {tag_uid}")
317
+
318
+ # --- player / volume ---------------------------------------------------------------------------------------
319
+
320
+ def _album_command(self, text: str, command: Callable[[], None]) -> None:
321
+ try:
322
+ command()
323
+ except ApiError:
324
+ print("currently, no album is active.")
325
+ return
326
+ print(text)
327
+
328
+ def cmd_track_prev(self, args: str) -> None:
329
+ """track_prev - play the previous track"""
330
+ self._album_command("playing previous track", self._control.track_prev)
331
+
332
+ def cmd_track_next(self, args: str) -> None:
333
+ """track_next - play the next track"""
334
+ self._album_command("playing next track", self._control.track_next)
335
+
336
+ def cmd_track_pause(self, args: str) -> None:
337
+ """track_pause - pause the album or radio station (the tag stays on the box)"""
338
+ self._album_command("paused", self._control.pause)
339
+
340
+ def cmd_track_play(self, args: str) -> None:
341
+ """track_play - end the pause"""
342
+ self._album_command("playing", self._control.play)
343
+
344
+ def cmd_restart_album(self, args: str) -> None:
345
+ """restart_album - play the album from the beginning"""
346
+ self._album_command("restarting album", self._control.restart_album)
347
+
348
+ def cmd_set_volume(self, args: str) -> None:
349
+ """set_volume - set the volume in %: SET_VOLUME [0-100]"""
350
+ words = tinyjukebox.core.ui.tools.parse(args)
351
+ if len(words) != 1:
352
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
353
+ return
354
+ try:
355
+ target_volume = float(words[0])
356
+ except ValueError:
357
+ print("argument must be a valid number.")
358
+ return
359
+ if not 0 <= target_volume <= 100:
360
+ print(f"target volume must be 0-100. got {target_volume}.")
361
+ return
362
+ volume = self._control.set_volume(target_volume)
363
+ print(f"volume of the {volume['output']} set to {volume['volume']} (max-volume: {volume['max_volume']}).")
364
+
365
+ def cmd_get_volume(self, args: str) -> None:
366
+ """get_volume - current volume in % (of the headset while it is connected)"""
367
+ volume = self._control.get_volume()
368
+ print(f"volume of the {volume['output']} is set to: {volume['volume']:0.0f}")
369
+
370
+ def cmd_get_volume_limits(self, args: str) -> None:
371
+ """get_volume_limits - start value and maximum of the volume of speaker and headphones"""
372
+ for name, profile in self._control.get_volume()["profiles"].items():
373
+ print(f"{name}: start {profile['default_volume']:0.0f}, maximum {profile['max_volume']:0.0f}")
374
+
375
+ def cmd_set_volume_limits(self, args: str) -> None:
376
+ """set_volume_limits - set start value and maximum of a volume in % (kept over restarts):
377
+ SET_VOLUME_LIMITS speaker|headphones START MAXIMUM"""
378
+ words = tinyjukebox.core.ui.tools.parse(args)
379
+ if len(words) != 3:
380
+ print(f"wrong number of arguments (got {len(words)}, expected 3)")
381
+ return
382
+ try:
383
+ start, maximum = float(words[1]), float(words[2])
384
+ except ValueError:
385
+ print("start value and maximum must be valid numbers.")
386
+ return
387
+ try:
388
+ self._control.set_volume_limits(words[0].lower(), start, maximum)
389
+ except ApiError as e:
390
+ print(e.message)
391
+ return
392
+ print(f"{words[0].lower()}: start {start:0.0f}, maximum {maximum:0.0f}")
393
+
394
+ # --- library and version -----------------------------------------------------------------------------------
395
+
396
+ def cmd_library_info(self, args: str) -> None:
397
+ """library_info - the synchronization with the library: address, connection, changes not confirmed yet"""
398
+ try:
399
+ info = self._control.library_info()
400
+ except ApiError as e:
401
+ print(e.message)
402
+ return
403
+ if not info["library"]:
404
+ print(f"box {info['box']}: no library - this box works for itself.")
405
+ return
406
+ state = (
407
+ "from the next start of the box"
408
+ if not info["attached"]
409
+ else "connected"
410
+ if info["connected"]
411
+ else f"not reachable ({info['alone']})"
412
+ )
413
+ print(f"box {info['box']} '{info['name'] or ''}', library {info['library']}: {state}, fit: {info['fit']}")
414
+ print(f"changes not confirmed yet: {info['outbox']}, new targets not settled: {len(info['unsettled'])}")
415
+
416
+ def cmd_set_library(self, args: str) -> None:
417
+ """set_library - address of the library, for the first contact with the bootstrap password; takes effect
418
+ with the next start: SET_LIBRARY https://HOST:PORT [BOOTSTRAP-PASSWORD]"""
419
+ words = tinyjukebox.core.ui.tools.parse(args)
420
+ if len(words) not in (1, 2):
421
+ print(f"wrong number of arguments (got {len(words)}, expected 1 or 2)")
422
+ return
423
+ try:
424
+ self._control.set_library(words[0], words[1] if len(words) == 2 else None)
425
+ print("done. A box that had no library works with it from its next start.")
426
+ except ApiError as e:
427
+ print(e.message)
428
+
429
+ def cmd_unlink_library(self, args: str) -> None:
430
+ """unlink_library - leave the library: the box keeps what is on its card and works for itself. Changes not
431
+ confirmed yet never reach the library, albums that are not on the card cannot be fetched any more:
432
+ UNLINK_LIBRARY YES"""
433
+ try:
434
+ info = self._control.library_info()
435
+ if tinyjukebox.core.ui.tools.parse(args) != ("YES",):
436
+ print(
437
+ f"changes not confirmed yet: {info['outbox']}, albums not completely on the card: {info['absent']}"
438
+ )
439
+ print("they are lost for the library and for this box. To unlink: UNLINK_LIBRARY YES")
440
+ return
441
+ self._control.unlink_library()
442
+ print("done. This box works for itself.")
443
+ except ApiError as e:
444
+ print(e.message)
445
+
446
+ def cmd_system_info(self, args: str) -> None:
447
+ """system_info - version of the box software, slots and update mode (changes: tinyjukebox-slot in a shell)"""
448
+ info = self._control.system_info()
449
+ if not info["available"]:
450
+ print(f"version {info['version']} - this box was set up without slots.")
451
+ return
452
+ print(f"version {info['version']}, active slot: {info['active']}, update mode: {info['mode']}")
453
+ for name, slot in info["slots"].items():
454
+ print(f" {name}: {slot['version']} {slot['state']}")
455
+ if info["problem"]:
456
+ print(f"problem: {info['problem']}")
457
+
458
+ # --- bluetooth headset -------------------------------------------------------------------------------------
459
+
460
+ def cmd_bluetooth_info(self, args: str) -> None:
461
+ """bluetooth_info - the paired headset: connected, battery, volume, start value and maximum"""
462
+ info = self._control.bluetooth_info()
463
+ if not info["available"]:
464
+ print("bluetooth is not available.")
465
+ return
466
+ limits = f"start {info['default_volume']:0.0f}, maximum {info['max_volume']:0.0f}"
467
+ if info["pairing"]:
468
+ print("searching for a headset in pairing mode ...")
469
+ elif info["pairing_result"] is not None:
470
+ print(f"last search: {info['pairing_result']}")
471
+ device = info["device"]
472
+ if device is None:
473
+ print(f"no headset is paired (volume: {limits}).")
474
+ return
475
+ battery = "-" if info["battery"] is None else f"{info['battery']} %"
476
+ volume = "-" if info["volume"] is None else f"{info['volume']:0.0f}"
477
+ print(
478
+ f"[{device['address']}] '{device['name']}' connected: {info['connected']}, battery: {battery}, "
479
+ f"volume: {volume} ({limits})"
480
+ )
481
+
482
+ def cmd_bluetooth_pair(self, args: str) -> None:
483
+ """bluetooth_pair - search a headset in pairing mode and pair it (replaces the paired one)"""
484
+ try:
485
+ self._control.bluetooth_pair()
486
+ except ApiError as e:
487
+ print(e.message)
488
+ return
489
+ print("searching for a headset in pairing mode for up to a minute - bluetooth_info shows the result.")
490
+
491
+ def cmd_bluetooth_unpair(self, args: str) -> None:
492
+ """bluetooth_unpair - remove the paired headset"""
493
+ device = self._control.bluetooth_info()["device"]
494
+ if device is None:
495
+ print("no headset is paired.")
496
+ return
497
+ print(f"removing the pairing of '{device['name']}' ({device['address']}).")
498
+ if not _confirm():
499
+ print("canceled.")
500
+ return
501
+ try:
502
+ self._control.bluetooth_unpair()
503
+ except ApiError as e:
504
+ print(e.message)
505
+ return
506
+ print("pairing removed.")
507
+
508
+ def cmd_album_info(self, args: str) -> None:
509
+ """album_info - information on the playing album or radio station"""
510
+ info = self._control.album_info()
511
+ if info is None:
512
+ print("currently, no album is active.")
513
+ return
514
+ if info.get("target_type") == "radio":
515
+ error = f" error: {info['error']}" if info["error"] else ""
516
+ test = " (test)" if info["target_id"] == "test" else ""
517
+ print(
518
+ f"[{info['target_id']}] radio{test} '{info['name']}' url: {info['url']} state: {info['state']}{error}"
519
+ )
520
+ return
521
+ if info.get("target_type") == "speaker":
522
+ print(f"Spotify speaker: '{info['name'] or '-'}' state: {info['state']}")
523
+ return
524
+ if info.get("target_type") == "spotify":
525
+ error = f" error: {info['error']}" if info["error"] else ""
526
+ test = " (test)" if info["test"] else ""
527
+ track = info["track"]
528
+ print(f"[{info['target_id']}] Spotify{test} '{info['name']}' {info['uri']} state: {info['state']}{error}")
529
+ print(f"track: [{track['track_id']}] '{track['title'] or '-'}' @ {track['pos']:.1f}s")
530
+ return
531
+ output = (
532
+ f"[{info['target_id']}] album_folder: {info['album_folder']} playlist: {info['playlist']} "
533
+ f"state: {info['state']}\n"
534
+ )
535
+ track = info["track"]
536
+ if track["track_id"] is not None:
537
+ output += (
538
+ f"track: [{track['track_id']}] '{track['filename']}' @ {track['pos']:.1f}s / {track['length']:.1f}s\n"
539
+ )
540
+ output += "tracks:\n" + "".join(f" - {t}\n" for t in info["tracks"])
541
+ tinyjukebox.core.ui.tools.more(output)
542
+
543
+ def cmd_system_state(self, args: str) -> None:
544
+ """system_state - state of the box"""
545
+ state = self._control.state()
546
+ print(f"system state is: {getattr(state, 'value', state)}.")
547
+
548
+ # --- albums ------------------------------------------------------------------------------------------------
549
+
550
+ def cmd_add_album(self, args: str) -> None:
551
+ """add_album - add the album in a folder (relative to albums-folder) with its title.
552
+
553
+ ADD_ALBUM FOLDER this is the title - a folder with blanks in quotes: ADD_ALBUM "my folder" this is the title
554
+ """
555
+ words = tinyjukebox.core.ui.tools.parse(args)
556
+ if not words:
557
+ print("folder must be provided.")
558
+ return
559
+ folder, title = words[0], " ".join(words[1:])
560
+ if not title:
561
+ print("title must be provided: ADD_ALBUM FOLDER this is the title (a folder with blanks in quotes).")
562
+ return
563
+ print(f"adding album '{folder}' with the title '{title}' to system.")
564
+ if not _confirm():
565
+ print("canceled.")
566
+ return
567
+ try:
568
+ album = self._control.add_album(folder, title)
569
+ except ApiError as e:
570
+ print(f"adding album failed: {e.message}")
571
+ return
572
+ print(f"added album with id '{album['id']}'.")
573
+
574
+ def cmd_add_new_albums(self, args: str) -> None:
575
+ """add_new_albums - add all folders with a playlist in albums-folder that are not yet known. Every album gets
576
+ the title that is shown: the album that its first track names, otherwise the name of the folder. Change it
577
+ with SET_ALBUM_TITLE."""
578
+ new_albums = self._control.new_albums()
579
+ if not new_albums:
580
+ print("no new albums found.")
581
+ return
582
+ print(f"found {len(new_albums)} new albums:")
583
+ for album in new_albums:
584
+ print(f" - {album['folder']} -> title: {album['title']}")
585
+ if not _confirm():
586
+ print("canceled.")
587
+ return
588
+ result = self._control.add_new_albums({album["folder"]: album["title"] for album in new_albums})
589
+ for album in result["added"]:
590
+ print(f"added [{album['id']}] {album['folder']} - '{album['title']}'")
591
+ for skipped in result["skipped"]:
592
+ print(f"skipped '{skipped['folder']}': {skipped['error']}")
593
+
594
+ def cmd_set_album_title(self, args: str) -> None:
595
+ """set_album_title - sets the title of an album: SET_ALBUM_TITLE ALBUM-ID this is the new title"""
596
+ words = tinyjukebox.core.ui.tools.parse(args)
597
+ if len(words) < 2:
598
+ print(f"wrong number of arguments (got {len(words)}, expected at least 2)")
599
+ return
600
+ try:
601
+ album = self._control.update_album(words[0], " ".join(words[1:]))
602
+ except ApiError as e:
603
+ print(f"changing album failed: {e.message}")
604
+ return
605
+ print(f"[{album['id']}] updated title to '{album['title']}'")
606
+
607
+ @staticmethod
608
+ def _id_and_image(args: str) -> tuple[str, bytes] | None:
609
+ """The arguments ``ID FILE``: the id and the content of the file - None (and a message) if they are wrong."""
610
+ words = tinyjukebox.core.ui.tools.parse(args)
611
+ if len(words) < 2:
612
+ print(f"wrong number of arguments (got {len(words)}, expected at least 2)")
613
+ return None
614
+ filename = os.path.expanduser(" ".join(words[1:]))
615
+ try:
616
+ with open(filename, "rb") as infile:
617
+ return words[0], infile.read()
618
+ except OSError as e:
619
+ print(f"cannot read '{filename}': {e.strerror}")
620
+ return None
621
+
622
+ def cmd_set_album_cover(self, args: str) -> None:
623
+ """set_album_cover - makes an image file the cover of an album: SET_ALBUM_COVER ALBUM-ID FILE"""
624
+ given = self._id_and_image(args)
625
+ if given is None:
626
+ return
627
+ try:
628
+ album = self._control.set_cover(*given)
629
+ except ApiError as e:
630
+ print(f"changing album failed: {e.message}")
631
+ return
632
+ print(f"[{album['id']}] '{album['title']}' has a new cover.")
633
+
634
+ def cmd_backup(self, args: str) -> None:
635
+ """backup - writes a backup of the data into a folder, with ALBUMS also the albums: BACKUP FOLDER [ALBUMS]"""
636
+ words = args.split()
637
+ albums = len(words) > 1 and words[-1].lower() == "albums"
638
+ folder = os.path.expanduser(args.strip()[: -len("albums")].strip() if albums else args.strip())
639
+ if not folder:
640
+ print("expected: BACKUP FOLDER [ALBUMS]")
641
+ return
642
+ if not os.path.isdir(folder):
643
+ print(f"'{folder}' is no folder.")
644
+ return
645
+ try:
646
+ target, result = self._control.backup(albums).save(folder)
647
+ except OSError as e:
648
+ print(f"the backup failed: {e}")
649
+ return
650
+ print(f"backup written to {target}: {result['files']} files, {result['bytes']} bytes.")
651
+
652
+ def cmd_restore(self, args: str) -> None:
653
+ """restore - puts a backup back: RESTORE FILE unpacks it beside the data, RESTORE CONFIRM replaces the data by
654
+ it and starts the box again, RESTORE CANCEL leaves the data as it is"""
655
+ restore = self._control.restore
656
+ word = args.strip()
657
+ if restore is None:
658
+ print("no restore here.")
659
+ return
660
+ if not word:
661
+ print("expected: RESTORE FILE, then RESTORE CONFIRM or RESTORE CANCEL")
662
+ return
663
+ try:
664
+ if word.lower() == "confirm":
665
+ restore.confirm()
666
+ print("the data is replaced: the box starts again.")
667
+ elif word.lower() == "cancel":
668
+ restore.discard()
669
+ print("the data stays as it is.")
670
+ else:
671
+ about = restore.stage(os.path.expanduser(word))
672
+ what = (
673
+ "with the albums - all albums are deleted and replaced" if about["albums"] else "without the albums"
674
+ )
675
+ print(
676
+ f"backup of {about['created']} (version {about['version']}, {about['files']} files), {what}.\n"
677
+ "RESTORE CONFIRM replaces all data by it, RESTORE CANCEL leaves the data as it is."
678
+ )
679
+ except (ApiError, ValueError, OSError) as e:
680
+ print(f"the backup is not put back: {getattr(e, 'message', None) or e}")
681
+
682
+ # --- radio stations ----------------------------------------------------------------------------------------
683
+
684
+ def cmd_list_radios(self, args: str) -> None:
685
+ """list_radios - list of all radio stations"""
686
+ output = "list of all radio stations:\n"
687
+ for radio in self._control.list_radios():
688
+ tags = ", ".join(t["uid"] for t in radio["tags"]) or "-"
689
+ output += f"[{radio['id']}] '{radio['name']}' url: {radio['url']} tags: {tags}\n"
690
+ tinyjukebox.core.ui.tools.more(output)
691
+
692
+ def cmd_add_radio(self, args: str) -> None:
693
+ """add_radio - add a radio station (url of the stream or of an m3u/pls playlist): ADD_RADIO URL NAME"""
694
+ words = tinyjukebox.core.ui.tools.parse(args)
695
+ if len(words) < 2:
696
+ print(f"wrong number of arguments (got {len(words)}, expected at least 2)")
697
+ return
698
+ try:
699
+ radio = self._control.add_radio(" ".join(words[1:]), words[0])
700
+ except ApiError as e:
701
+ print(f"adding radio station failed: {e.message}")
702
+ return
703
+ print(f"added radio station [{radio['id']}] '{radio['name']}'.")
704
+
705
+ def _update_radio(self, args: str, field: str) -> None:
706
+ words = tinyjukebox.core.ui.tools.parse(args)
707
+ if len(words) < 2:
708
+ print(f"wrong number of arguments (got {len(words)}, expected at least 2)")
709
+ return
710
+ radio_id, value = words[0], " ".join(words[1:])
711
+ try:
712
+ radio = self._control.update_radio(radio_id, **{field: value})
713
+ except ApiError as e:
714
+ print(f"changing radio station failed: {e.message}")
715
+ return
716
+ print(f"[{radio['id']}] updated {field} to '{radio[field]}'")
717
+
718
+ def cmd_set_radio_name(self, args: str) -> None:
719
+ """set_radio_name - sets the name of a radio station: SET_RADIO_NAME RADIO-ID this is the new name"""
720
+ self._update_radio(args, "name")
721
+
722
+ def cmd_set_radio_url(self, args: str) -> None:
723
+ """set_radio_url - sets the url of a radio station (a playing station restarts): SET_RADIO_URL RADIO-ID URL"""
724
+ self._update_radio(args, "url")
725
+
726
+ def cmd_set_radio_cover(self, args: str) -> None:
727
+ """set_radio_cover - makes an image file the cover of a radio station: SET_RADIO_COVER RADIO-ID FILE"""
728
+ given = self._id_and_image(args)
729
+ if given is None:
730
+ return
731
+ try:
732
+ radio = self._control.set_radio_cover(*given)
733
+ except ApiError as e:
734
+ print(f"changing radio station failed: {e.message}")
735
+ return
736
+ print(f"[{radio['id']}] '{radio['name']}' has a new cover.")
737
+
738
+ def cmd_remove_radio(self, args: str) -> None:
739
+ """remove_radio - remove a radio station that is not linked to a tag: REMOVE_RADIO RADIO-ID"""
740
+ words = tinyjukebox.core.ui.tools.parse(args)
741
+ if len(words) != 1:
742
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
743
+ return
744
+ try:
745
+ radio = self._control.get_radio(words[0])
746
+ except ApiError as e:
747
+ print(e.message)
748
+ return
749
+ print(f"removing radio station [{radio['id']}] '{radio['name']}'.")
750
+ if not _confirm():
751
+ print("canceled.")
752
+ return
753
+ try:
754
+ self._control.delete_radio(radio["id"])
755
+ except ApiError as e:
756
+ tags = ", ".join(t["uid"] for t in e.details.get("tags", []))
757
+ print(f"removing radio station failed: {e.message}" + (f" ({tags})" if tags else ""))
758
+ return
759
+ print(f"removed radio station [{radio['id']}].")
760
+
761
+ def cmd_test_radio(self, args: str) -> None:
762
+ """test_radio - play a radio station or url for a minute - only while no tag is on the box.
763
+
764
+ TEST_RADIO RADIO-ID - test a saved radio station
765
+ TEST_RADIO URL - test a stream or m3u/pls playlist (e.g. before adding it)
766
+ """
767
+ words = tinyjukebox.core.ui.tools.parse(args)
768
+ if len(words) != 1:
769
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
770
+ return
771
+ url, name = words[0], None
772
+ if "://" not in url:
773
+ try:
774
+ radio = self._control.get_radio(url)
775
+ except ApiError as e:
776
+ print(e.message)
777
+ return
778
+ url, name = radio["url"], radio["name"]
779
+ try:
780
+ self._control.test_radio(url, name)
781
+ except ApiError as e:
782
+ print(e.message)
783
+ return
784
+ print(f"testing '{name or url}' for a minute - album_info shows the state, stop_radio_test stops it.")
785
+
786
+ def cmd_stop_radio_test(self, args: str) -> None:
787
+ """stop_radio_test - stop the radio test"""
788
+ try:
789
+ self._control.stop_radio_test()
790
+ except ApiError as e:
791
+ print(e.message)
792
+ return
793
+ print("radio test stopped.")
794
+
795
+ def _link_tag_radio(self, tag_uid: str, radio_id: str) -> None:
796
+ try:
797
+ tag = self._control.get_tag(tag_uid)
798
+ radio = self._control.get_radio(radio_id)
799
+ except ApiError as e:
800
+ print(e.message)
801
+ return
802
+ print(f"linking tag '{tag['uid']}' ('{tag['name']}') with radio station '{radio['name']}'.")
803
+ if not _confirm():
804
+ print("canceled.")
805
+ return
806
+ result = self._control.link_tag_radio(tag_uid, radio_id)
807
+ print(f"link result: tag {result['uid']} -> radio station {result['radio_id']}")
808
+ if result["present"]:
809
+ print("the tag is on the box - the radio station starts now.")
810
+
811
+ def cmd_link_tag_radio(self, args: str) -> None:
812
+ """link_tag_radio - links a tag with a radio station.
813
+
814
+ LINK_TAG_RADIO TAG-UID RADIO-ID - link a specific tag with a radio station
815
+ LINK_TAG_RADIO AUTO RADIO-ID - link the unlinked tag on the box with a radio station
816
+ """
817
+ words = tinyjukebox.core.ui.tools.parse(args)
818
+ if len(words) != 2:
819
+ print(f"wrong number of arguments (got {len(words)}, expected 2)")
820
+ return
821
+ tag_uid = self._tag_uid_or_auto(words[0])
822
+ if tag_uid is not None:
823
+ self._link_tag_radio(tag_uid, words[1])
824
+
825
+ # --- entries of Spotify -------------------------------------------------------------------------------------
826
+
827
+ def cmd_list_spotify(self, args: str) -> None:
828
+ """list_spotify - list of all entries of Spotify"""
829
+ output = "list of all entries of Spotify:\n"
830
+ for entry in self._control.list_spotify():
831
+ tags = ", ".join(t["uid"] for t in entry["tags"]) or "-"
832
+ output += f"[{entry['id']}] '{entry['name']}' {entry['kind']} {entry['link']} tags: {tags}\n"
833
+ tinyjukebox.core.ui.tools.more(output)
834
+
835
+ def cmd_add_spotify(self, args: str) -> None:
836
+ """add_spotify - add an entry of Spotify from its link (name and cover come from Spotify unless a name is
837
+ given): ADD_SPOTIFY LINK [NAME]"""
838
+ words = tinyjukebox.core.ui.tools.parse(args)
839
+ if not words:
840
+ print("wrong number of arguments (got 0, expected at least 1)")
841
+ return
842
+ try:
843
+ entry = self._control.add_spotify(words[0], " ".join(words[1:]) or None)
844
+ except ApiError as e:
845
+ print(f"adding entry of Spotify failed: {e.message}")
846
+ return
847
+ print(f"added entry of Spotify [{entry['id']}] '{entry['name']}' ({entry['kind']}).")
848
+
849
+ def _update_spotify(self, args: str, field: str) -> None:
850
+ words = tinyjukebox.core.ui.tools.parse(args)
851
+ if len(words) < 2:
852
+ print(f"wrong number of arguments (got {len(words)}, expected 2)")
853
+ return
854
+ spotify_id, value = words[0], " ".join(words[1:])
855
+ try:
856
+ entry = self._control.update_spotify(spotify_id, **{field: value})
857
+ except ApiError as e:
858
+ print(f"changing entry of Spotify failed: {e.message}")
859
+ return
860
+ print(f"[{entry['id']}] updated {field} to '{entry[field]}'")
861
+
862
+ def cmd_set_spotify_name(self, args: str) -> None:
863
+ """set_spotify_name - sets the name of an entry of Spotify: SET_SPOTIFY_NAME SPOTIFY-ID this is the new name"""
864
+ self._update_spotify(args, "name")
865
+
866
+ def cmd_set_spotify_link(self, args: str) -> None:
867
+ """set_spotify_link - sets the link of an entry of Spotify (a playing entry restarts): SET_SPOTIFY_LINK
868
+ SPOTIFY-ID LINK"""
869
+ self._update_spotify(args, "link")
870
+
871
+ def cmd_set_spotify_cover(self, args: str) -> None:
872
+ """set_spotify_cover - makes an image file the cover of an entry of Spotify: SET_SPOTIFY_COVER SPOTIFY-ID
873
+ FILE"""
874
+ given = self._id_and_image(args)
875
+ if given is None:
876
+ return
877
+ try:
878
+ entry = self._control.set_spotify_cover(*given)
879
+ except ApiError as e:
880
+ print(f"changing entry of Spotify failed: {e.message}")
881
+ return
882
+ print(f"[{entry['id']}] '{entry['name']}' has a new cover.")
883
+
884
+ def cmd_remove_spotify(self, args: str) -> None:
885
+ """remove_spotify - remove an entry of Spotify that is not linked to a tag: REMOVE_SPOTIFY SPOTIFY-ID"""
886
+ words = tinyjukebox.core.ui.tools.parse(args)
887
+ if len(words) != 1:
888
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
889
+ return
890
+ try:
891
+ entry = self._control.get_spotify(words[0])
892
+ except ApiError as e:
893
+ print(e.message)
894
+ return
895
+ print(f"removing entry of Spotify [{entry['id']}] '{entry['name']}'.")
896
+ if not _confirm():
897
+ print("canceled.")
898
+ return
899
+ try:
900
+ self._control.delete_spotify(entry["id"])
901
+ except ApiError as e:
902
+ tags = ", ".join(t["uid"] for t in e.details.get("tags", []))
903
+ print(f"removing entry of Spotify failed: {e.message}" + (f" ({tags})" if tags else ""))
904
+ return
905
+ print(f"removed entry of Spotify [{entry['id']}].")
906
+
907
+ def _link_tag_spotify(self, tag_uid: str, spotify_id: str) -> None:
908
+ try:
909
+ tag = self._control.get_tag(tag_uid)
910
+ entry = self._control.get_spotify(spotify_id)
911
+ except ApiError as e:
912
+ print(e.message)
913
+ return
914
+ print(f"linking tag '{tag['uid']}' ('{tag['name']}') with entry of Spotify '{entry['name']}'.")
915
+ if not _confirm():
916
+ print("canceled.")
917
+ return
918
+ result = self._control.link_tag_spotify(tag_uid, spotify_id)
919
+ print(f"link result: tag {result['uid']} -> entry of Spotify {result['spotify_id']}")
920
+ if result["present"]:
921
+ print("the tag is on the box - the entry starts now.")
922
+
923
+ def cmd_link_tag_spotify(self, args: str) -> None:
924
+ """link_tag_spotify - links a tag with an entry of Spotify.
925
+
926
+ LINK_TAG_SPOTIFY TAG-UID SPOTIFY-ID - link a specific tag with an entry of Spotify
927
+ LINK_TAG_SPOTIFY AUTO SPOTIFY-ID - link the unlinked tag on the box with an entry of Spotify
928
+ """
929
+ words = tinyjukebox.core.ui.tools.parse(args)
930
+ if len(words) != 2:
931
+ print(f"wrong number of arguments (got {len(words)}, expected 2)")
932
+ return
933
+ tag_uid = self._tag_uid_or_auto(words[0])
934
+ if tag_uid is not None:
935
+ self._link_tag_spotify(tag_uid, words[1])
936
+
937
+ def cmd_link_current_tag_spotify(self, args: str) -> None:
938
+ """link_current_tag_spotify - links the unlinked tag on the box with an entry of Spotify.
939
+
940
+ LINK_CURRENT_TAG_SPOTIFY - show the entries of Spotify
941
+ LINK_CURRENT_TAG_SPOTIFY SPOTIFY-ID - link the unlinked tag on the box with the entry
942
+ """
943
+ current_uid = self._control.current_unlinked_tag()
944
+ if current_uid is None:
945
+ print("No unlinked tag currently present.")
946
+ return
947
+ print(f"Using currently present unlinked tag: {current_uid}")
948
+ words = tinyjukebox.core.ui.tools.parse(args)
949
+ if not words:
950
+ self.cmd_list_spotify("")
951
+ elif len(words) == 1:
952
+ self._link_tag_spotify(current_uid, words[0])
953
+ else:
954
+ print(f"wrong number of arguments (got {len(words)}, expected 0 or 1)")
955
+
956
+ def cmd_test_spotify(self, args: str) -> None:
957
+ """test_spotify - play an entry of Spotify for a minute - only while no tag is on the box.
958
+
959
+ TEST_SPOTIFY SPOTIFY-ID - test a saved entry
960
+ TEST_SPOTIFY LINK - test a link of Spotify (e.g. before adding it)
961
+ """
962
+ words = tinyjukebox.core.ui.tools.parse(args)
963
+ if len(words) != 1:
964
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
965
+ return
966
+ saved = ":" not in words[0] and "/" not in words[0]
967
+ try:
968
+ result = self._control.test_spotify(words[0] if saved else None, None if saved else words[0])
969
+ except ApiError as e:
970
+ print(e.message)
971
+ return
972
+ print(f"testing '{result['name']}' for a minute - album_info shows the state, stop_radio_test stops it.")
973
+
974
+ def cmd_spotify_account(self, args: str) -> None:
975
+ """spotify_account - the account of Spotify of this box: key, login, speaker"""
976
+ try:
977
+ info = self._control.spotify_account()
978
+ except ApiError as e:
979
+ print(e.message)
980
+ return
981
+ login = (
982
+ "waiting to be chosen in the Spotify app"
983
+ if info["connecting"]
984
+ else f"connected since {info['connected_since']}"
985
+ if info["connected"]
986
+ else "not connected"
987
+ )
988
+ print(f"key: {'entered' if info['key'] else 'missing'}, {login}, speaker: {'on' if info['speaker'] else 'off'}")
989
+
990
+ def cmd_set_spotify_key(self, args: str) -> None:
991
+ """set_spotify_key - enter the Soloist API key of developer.spotify.com: SET_SPOTIFY_KEY KEY"""
992
+ words = tinyjukebox.core.ui.tools.parse(args)
993
+ if len(words) != 1:
994
+ print(f"wrong number of arguments (got {len(words)}, expected 1)")
995
+ return
996
+ try:
997
+ self._control.set_spotify_key(words[0])
998
+ except ApiError as e:
999
+ print(e.message)
1000
+ return
1001
+ print("key stored - the box fetches the player of Spotify if it has none (spotify_player).")
1002
+
1003
+ def cmd_connect_spotify(self, args: str) -> None:
1004
+ """connect_spotify - the box waits a few minutes to be chosen as a speaker in the Spotify app"""
1005
+ try:
1006
+ self._control.connect_spotify()
1007
+ except ApiError as e:
1008
+ print(e.message)
1009
+ return
1010
+ print("choose the box in the Spotify app now - spotify_account shows the result.")
1011
+
1012
+ def cmd_disconnect_spotify(self, args: str) -> None:
1013
+ """disconnect_spotify - remove the account of Spotify from the box"""
1014
+ if not _confirm():
1015
+ print("canceled.")
1016
+ return
1017
+ try:
1018
+ self._control.disconnect_spotify()
1019
+ except ApiError as e:
1020
+ print(e.message)
1021
+ return
1022
+ print("the box is not connected to Spotify any more.")
1023
+
1024
+ def cmd_set_spotify_speaker(self, args: str) -> None:
1025
+ """set_spotify_speaker - the box is a Spotify speaker while no tag is on it: SET_SPOTIFY_SPEAKER ON|OFF"""
1026
+ words = tinyjukebox.core.ui.tools.parse(args)
1027
+ if len(words) != 1 or words[0].lower() not in ("on", "off"):
1028
+ print("expected ON or OFF")
1029
+ return
1030
+ try:
1031
+ self._control.set_spotify_speaker(words[0].lower() == "on")
1032
+ except ApiError as e:
1033
+ print(e.message)
1034
+ return
1035
+ print(f"Spotify speaker while no tag is on the box: {words[0].lower()}")
1036
+
1037
+ def cmd_spotify_player(self, args: str) -> None:
1038
+ """spotify_player - state and age of the player of Spotify (Soloist)"""
1039
+ try:
1040
+ info = self._control.spotify_player()
1041
+ except ApiError as e:
1042
+ print(e.message)
1043
+ return
1044
+ print(f"state: {info['state']}" + (f" ({info['error']})" if info["error"] else ""))
1045
+ print(f"build: {info['version'] or '-'}, built: {info['built'] or '-'}, last day: {info['last_day'] or '-'}")
1046
+ print(f"asked Spotify: {info['asked'] or '-'} ({info['result'] or '-'})")
1047
+ if info["missing"]:
1048
+ print(f"missing packages: {', '.join(info['missing'])}")
1049
+
1050
+ def cmd_update_spotify_player(self, args: str) -> None:
1051
+ """update_spotify_player - ask Spotify at once for a new build of the player"""
1052
+ try:
1053
+ self._control.update_spotify_player()
1054
+ except ApiError as e:
1055
+ print(e.message)
1056
+ return
1057
+ print("asking Spotify - spotify_player shows the result.")
1058
+
1059
+ def cmd_link_current_tag_radio(self, args: str) -> None:
1060
+ """link_current_tag_radio - links the unlinked tag on the box with a radio station.
1061
+
1062
+ LINK_CURRENT_TAG_RADIO - show the radio stations
1063
+ LINK_CURRENT_TAG_RADIO RADIO-ID - link the unlinked tag on the box with the radio station
1064
+ """
1065
+ current_uid = self._control.current_unlinked_tag()
1066
+ if current_uid is None:
1067
+ print("No unlinked tag currently present.")
1068
+ return
1069
+ print(f"Using currently present unlinked tag: {current_uid}")
1070
+ words = tinyjukebox.core.ui.tools.parse(args)
1071
+ if not words:
1072
+ self.cmd_list_radios("")
1073
+ elif len(words) == 1:
1074
+ self._link_tag_radio(current_uid, words[0])
1075
+ else:
1076
+ print(f"wrong number of arguments (got {len(words)}, expected 0 or 1)")