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.
- tinyjukebox/__init__.py +7 -0
- tinyjukebox/albumplayer/__init__.py +1 -0
- tinyjukebox/albumplayer/aalbumplayer.py +402 -0
- tinyjukebox/albumplayer/clock.py +39 -0
- tinyjukebox/albumplayer/dummyplayer.py +49 -0
- tinyjukebox/albumplayer/player_interface.py +97 -0
- tinyjukebox/albumplayer/radioplayer.py +391 -0
- tinyjukebox/albumplayer/simpleplayer.py +419 -0
- tinyjukebox/albumplayer/spotifyplayer.py +671 -0
- tinyjukebox/backup.py +176 -0
- tinyjukebox/backups.py +340 -0
- tinyjukebox/cli.py +1076 -0
- tinyjukebox/control.py +1386 -0
- tinyjukebox/core/__init__.py +1 -0
- tinyjukebox/core/config.py +100 -0
- tinyjukebox/core/logging/__init__.py +113 -0
- tinyjukebox/core/microservice.py +303 -0
- tinyjukebox/core/pubsub/__init__.py +15 -0
- tinyjukebox/core/pubsub/base.py +196 -0
- tinyjukebox/core/pubsub/localqueue.py +102 -0
- tinyjukebox/core/pubsub/statistics.py +57 -0
- tinyjukebox/core/py.typed +0 -0
- tinyjukebox/core/schema/__init__.py +5 -0
- tinyjukebox/core/schema/logger.py +40 -0
- tinyjukebox/core/schema/microservice.py +48 -0
- tinyjukebox/core/threads.py +39 -0
- tinyjukebox/core/ui/__init__.py +5 -0
- tinyjukebox/core/ui/cli.py +120 -0
- tinyjukebox/core/ui/tools.py +73 -0
- tinyjukebox/core/unexpected.py +46 -0
- tinyjukebox/data/__init__.py +0 -0
- tinyjukebox/data/activities.py +175 -0
- tinyjukebox/data/activity.py +138 -0
- tinyjukebox/data/common.py +91 -0
- tinyjukebox/data/degraded.py +39 -0
- tinyjukebox/data/jsondata.py +218 -0
- tinyjukebox/data/sanitycheck.py +71 -0
- tinyjukebox/data/tag.py +98 -0
- tinyjukebox/data/tags.py +160 -0
- tinyjukebox/data/target.py +568 -0
- tinyjukebox/data/targetalbumtools.py +88 -0
- tinyjukebox/data/targets.py +256 -0
- tinyjukebox/data/volumelimits.py +68 -0
- tinyjukebox/data_folder.py +180 -0
- tinyjukebox/devices/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/__init__.py +1 -0
- tinyjukebox/devices/baseclasses/adriver.py +115 -0
- tinyjukebox/devices/baseclasses/aevents.py +47 -0
- tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
- tinyjukebox/devices/bluetooth/__init__.py +1 -0
- tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
- tinyjukebox/devices/bluetooth/bluez.py +446 -0
- tinyjukebox/devices/bluetooth/link.py +396 -0
- tinyjukebox/devices/bluetooth/virtual.py +155 -0
- tinyjukebox/devices/drivermanager/__init__.py +1 -0
- tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
- tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
- tinyjukebox/devices/drivers/__init__.py +31 -0
- tinyjukebox/devices/drivers/bluetooth.py +257 -0
- tinyjukebox/devices/drivers/input.py +165 -0
- tinyjukebox/devices/drivers/rfid.py +297 -0
- tinyjukebox/devices/drivers/rgbled.py +215 -0
- tinyjukebox/devices/py.typed +0 -0
- tinyjukebox/devices/schema/__init__.py +1 -0
- tinyjukebox/devices/schema/adriver.py +33 -0
- tinyjukebox/devices/schema/bluetooth.py +31 -0
- tinyjukebox/devices/schema/drivermanager.py +41 -0
- tinyjukebox/devices/schema/input.py +23 -0
- tinyjukebox/devices/schema/rfid.py +44 -0
- tinyjukebox/devices/schema/rgbled.py +26 -0
- tinyjukebox/diagnostics.py +179 -0
- tinyjukebox/errors.py +91 -0
- tinyjukebox/headset.py +296 -0
- tinyjukebox/images.py +133 -0
- tinyjukebox/latency.py +43 -0
- tinyjukebox/library/__init__.py +2 -0
- tinyjukebox/library/diagnostics.py +183 -0
- tinyjukebox/library/journal.py +94 -0
- tinyjukebox/library/releases.py +348 -0
- tinyjukebox/library/schema.py +98 -0
- tinyjukebox/library/service.py +1115 -0
- tinyjukebox/library/statistics.py +288 -0
- tinyjukebox/library/store.py +1386 -0
- tinyjukebox/messages.py +202 -0
- tinyjukebox/metadata.py +133 -0
- tinyjukebox/py.typed +0 -0
- tinyjukebox/restore.py +391 -0
- tinyjukebox/rgbled.py +158 -0
- tinyjukebox/schema/__init__.py +1 -0
- tinyjukebox/schema/activities.py +27 -0
- tinyjukebox/schema/tags.py +54 -0
- tinyjukebox/schema/targets.py +83 -0
- tinyjukebox/schema/tinyjukebox.py +217 -0
- tinyjukebox/schema/volume.py +28 -0
- tinyjukebox/slots.py +234 -0
- tinyjukebox/spotify/__init__.py +1 -0
- tinyjukebox/spotify/account.py +647 -0
- tinyjukebox/spotify/engine.py +568 -0
- tinyjukebox/spotify/oembed.py +65 -0
- tinyjukebox/spotify/soloist.py +198 -0
- tinyjukebox/spotify/sound.py +316 -0
- tinyjukebox/stm/__init__.py +0 -0
- tinyjukebox/stm/base.py +174 -0
- tinyjukebox/stm/playradio.py +237 -0
- tinyjukebox/stm/playspotify.py +418 -0
- tinyjukebox/stm/stmoperation.py +769 -0
- tinyjukebox/stm/stmplayalbum.py +655 -0
- tinyjukebox/stm/stmvolume.py +221 -0
- tinyjukebox/sync/__init__.py +42 -0
- tinyjukebox/sync/box.py +486 -0
- tinyjukebox/sync/client.py +189 -0
- tinyjukebox/sync/data.py +489 -0
- tinyjukebox/sync/files.py +447 -0
- tinyjukebox/sync/handshake.py +42 -0
- tinyjukebox/sync/link.py +379 -0
- tinyjukebox/sync/manifest.py +162 -0
- tinyjukebox/sync/util.py +137 -0
- tinyjukebox/tinyjukebox.py +680 -0
- tinyjukebox/tools/__init__.py +1 -0
- tinyjukebox/tools/backup.py +173 -0
- tinyjukebox/tools/fill_library.py +120 -0
- tinyjukebox/tools/import_cards.py +274 -0
- tinyjukebox/tools/import_covers.py +138 -0
- tinyjukebox/tools/migrate_box01.py +151 -0
- tinyjukebox/volumemanager.py +355 -0
- tinyjukebox/web/__init__.py +1 -0
- tinyjukebox/web/auth.py +373 -0
- tinyjukebox/web/certificates.py +111 -0
- tinyjukebox/web/logbuffer.py +62 -0
- tinyjukebox/web/server.py +1237 -0
- tinyjukebox/web/uploads.py +262 -0
- tinyjukebox/web/users.py +106 -0
- tinyjukebox/web/web/app.js +3050 -0
- tinyjukebox/web/web/favicon.svg +6 -0
- tinyjukebox/web/web/help/de/admin.html +714 -0
- tinyjukebox/web/web/help/de/manual.html +1147 -0
- tinyjukebox/web/web/help/de/quick-guide.html +79 -0
- tinyjukebox/web/web/help/en/admin.html +701 -0
- tinyjukebox/web/web/help/en/manual.html +1126 -0
- tinyjukebox/web/web/help/en/quick-guide.html +79 -0
- tinyjukebox/web/web/help/help.css +25 -0
- tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
- tinyjukebox/web/web/help/images/box-back.jpg +0 -0
- tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
- tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
- tinyjukebox/web/web/index.html +867 -0
- tinyjukebox/web/web/style.css +412 -0
- tinyjukebox/web/web/texts/de.json +892 -0
- tinyjukebox/web/web/texts/en.json +892 -0
- tinyjukebox-0.47.0.dist-info/METADATA +236 -0
- tinyjukebox-0.47.0.dist-info/RECORD +154 -0
- tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
- tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
- 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)")
|