tinyjukebox 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. tinyjukebox/__init__.py +7 -0
  2. tinyjukebox/albumplayer/__init__.py +1 -0
  3. tinyjukebox/albumplayer/aalbumplayer.py +402 -0
  4. tinyjukebox/albumplayer/clock.py +39 -0
  5. tinyjukebox/albumplayer/dummyplayer.py +49 -0
  6. tinyjukebox/albumplayer/player_interface.py +97 -0
  7. tinyjukebox/albumplayer/radioplayer.py +391 -0
  8. tinyjukebox/albumplayer/simpleplayer.py +419 -0
  9. tinyjukebox/albumplayer/spotifyplayer.py +671 -0
  10. tinyjukebox/backup.py +176 -0
  11. tinyjukebox/backups.py +340 -0
  12. tinyjukebox/cli.py +1076 -0
  13. tinyjukebox/control.py +1386 -0
  14. tinyjukebox/core/__init__.py +1 -0
  15. tinyjukebox/core/config.py +100 -0
  16. tinyjukebox/core/logging/__init__.py +113 -0
  17. tinyjukebox/core/microservice.py +303 -0
  18. tinyjukebox/core/pubsub/__init__.py +15 -0
  19. tinyjukebox/core/pubsub/base.py +196 -0
  20. tinyjukebox/core/pubsub/localqueue.py +102 -0
  21. tinyjukebox/core/pubsub/statistics.py +57 -0
  22. tinyjukebox/core/py.typed +0 -0
  23. tinyjukebox/core/schema/__init__.py +5 -0
  24. tinyjukebox/core/schema/logger.py +40 -0
  25. tinyjukebox/core/schema/microservice.py +48 -0
  26. tinyjukebox/core/threads.py +39 -0
  27. tinyjukebox/core/ui/__init__.py +5 -0
  28. tinyjukebox/core/ui/cli.py +120 -0
  29. tinyjukebox/core/ui/tools.py +73 -0
  30. tinyjukebox/core/unexpected.py +46 -0
  31. tinyjukebox/data/__init__.py +0 -0
  32. tinyjukebox/data/activities.py +175 -0
  33. tinyjukebox/data/activity.py +138 -0
  34. tinyjukebox/data/common.py +91 -0
  35. tinyjukebox/data/degraded.py +39 -0
  36. tinyjukebox/data/jsondata.py +218 -0
  37. tinyjukebox/data/sanitycheck.py +71 -0
  38. tinyjukebox/data/tag.py +98 -0
  39. tinyjukebox/data/tags.py +160 -0
  40. tinyjukebox/data/target.py +568 -0
  41. tinyjukebox/data/targetalbumtools.py +88 -0
  42. tinyjukebox/data/targets.py +256 -0
  43. tinyjukebox/data/volumelimits.py +68 -0
  44. tinyjukebox/data_folder.py +180 -0
  45. tinyjukebox/devices/__init__.py +1 -0
  46. tinyjukebox/devices/baseclasses/__init__.py +1 -0
  47. tinyjukebox/devices/baseclasses/adriver.py +115 -0
  48. tinyjukebox/devices/baseclasses/aevents.py +47 -0
  49. tinyjukebox/devices/baseclasses/gpiolevel.py +16 -0
  50. tinyjukebox/devices/bluetooth/__init__.py +1 -0
  51. tinyjukebox/devices/bluetooth/avrcp_input.py +118 -0
  52. tinyjukebox/devices/bluetooth/bluez.py +446 -0
  53. tinyjukebox/devices/bluetooth/link.py +396 -0
  54. tinyjukebox/devices/bluetooth/virtual.py +155 -0
  55. tinyjukebox/devices/drivermanager/__init__.py +1 -0
  56. tinyjukebox/devices/drivermanager/driverfactory.py +75 -0
  57. tinyjukebox/devices/drivermanager/drivermanager.py +151 -0
  58. tinyjukebox/devices/drivers/__init__.py +31 -0
  59. tinyjukebox/devices/drivers/bluetooth.py +257 -0
  60. tinyjukebox/devices/drivers/input.py +165 -0
  61. tinyjukebox/devices/drivers/rfid.py +297 -0
  62. tinyjukebox/devices/drivers/rgbled.py +215 -0
  63. tinyjukebox/devices/py.typed +0 -0
  64. tinyjukebox/devices/schema/__init__.py +1 -0
  65. tinyjukebox/devices/schema/adriver.py +33 -0
  66. tinyjukebox/devices/schema/bluetooth.py +31 -0
  67. tinyjukebox/devices/schema/drivermanager.py +41 -0
  68. tinyjukebox/devices/schema/input.py +23 -0
  69. tinyjukebox/devices/schema/rfid.py +44 -0
  70. tinyjukebox/devices/schema/rgbled.py +26 -0
  71. tinyjukebox/diagnostics.py +179 -0
  72. tinyjukebox/errors.py +91 -0
  73. tinyjukebox/headset.py +296 -0
  74. tinyjukebox/images.py +133 -0
  75. tinyjukebox/latency.py +43 -0
  76. tinyjukebox/library/__init__.py +2 -0
  77. tinyjukebox/library/diagnostics.py +183 -0
  78. tinyjukebox/library/journal.py +94 -0
  79. tinyjukebox/library/releases.py +348 -0
  80. tinyjukebox/library/schema.py +98 -0
  81. tinyjukebox/library/service.py +1115 -0
  82. tinyjukebox/library/statistics.py +288 -0
  83. tinyjukebox/library/store.py +1386 -0
  84. tinyjukebox/messages.py +202 -0
  85. tinyjukebox/metadata.py +133 -0
  86. tinyjukebox/py.typed +0 -0
  87. tinyjukebox/restore.py +391 -0
  88. tinyjukebox/rgbled.py +158 -0
  89. tinyjukebox/schema/__init__.py +1 -0
  90. tinyjukebox/schema/activities.py +27 -0
  91. tinyjukebox/schema/tags.py +54 -0
  92. tinyjukebox/schema/targets.py +83 -0
  93. tinyjukebox/schema/tinyjukebox.py +217 -0
  94. tinyjukebox/schema/volume.py +28 -0
  95. tinyjukebox/slots.py +234 -0
  96. tinyjukebox/spotify/__init__.py +1 -0
  97. tinyjukebox/spotify/account.py +647 -0
  98. tinyjukebox/spotify/engine.py +568 -0
  99. tinyjukebox/spotify/oembed.py +65 -0
  100. tinyjukebox/spotify/soloist.py +198 -0
  101. tinyjukebox/spotify/sound.py +316 -0
  102. tinyjukebox/stm/__init__.py +0 -0
  103. tinyjukebox/stm/base.py +174 -0
  104. tinyjukebox/stm/playradio.py +237 -0
  105. tinyjukebox/stm/playspotify.py +418 -0
  106. tinyjukebox/stm/stmoperation.py +769 -0
  107. tinyjukebox/stm/stmplayalbum.py +655 -0
  108. tinyjukebox/stm/stmvolume.py +221 -0
  109. tinyjukebox/sync/__init__.py +42 -0
  110. tinyjukebox/sync/box.py +486 -0
  111. tinyjukebox/sync/client.py +189 -0
  112. tinyjukebox/sync/data.py +489 -0
  113. tinyjukebox/sync/files.py +447 -0
  114. tinyjukebox/sync/handshake.py +42 -0
  115. tinyjukebox/sync/link.py +379 -0
  116. tinyjukebox/sync/manifest.py +162 -0
  117. tinyjukebox/sync/util.py +137 -0
  118. tinyjukebox/tinyjukebox.py +680 -0
  119. tinyjukebox/tools/__init__.py +1 -0
  120. tinyjukebox/tools/backup.py +173 -0
  121. tinyjukebox/tools/fill_library.py +120 -0
  122. tinyjukebox/tools/import_cards.py +274 -0
  123. tinyjukebox/tools/import_covers.py +138 -0
  124. tinyjukebox/tools/migrate_box01.py +151 -0
  125. tinyjukebox/volumemanager.py +355 -0
  126. tinyjukebox/web/__init__.py +1 -0
  127. tinyjukebox/web/auth.py +373 -0
  128. tinyjukebox/web/certificates.py +111 -0
  129. tinyjukebox/web/logbuffer.py +62 -0
  130. tinyjukebox/web/server.py +1237 -0
  131. tinyjukebox/web/uploads.py +262 -0
  132. tinyjukebox/web/users.py +106 -0
  133. tinyjukebox/web/web/app.js +3050 -0
  134. tinyjukebox/web/web/favicon.svg +6 -0
  135. tinyjukebox/web/web/help/de/admin.html +714 -0
  136. tinyjukebox/web/web/help/de/manual.html +1147 -0
  137. tinyjukebox/web/web/help/de/quick-guide.html +79 -0
  138. tinyjukebox/web/web/help/en/admin.html +701 -0
  139. tinyjukebox/web/web/help/en/manual.html +1126 -0
  140. tinyjukebox/web/web/help/en/quick-guide.html +79 -0
  141. tinyjukebox/web/web/help/help.css +25 -0
  142. tinyjukebox/web/web/help/images/box-2026.jpg +0 -0
  143. tinyjukebox/web/web/help/images/box-back.jpg +0 -0
  144. tinyjukebox/web/web/help/images/lid-top.jpg +0 -0
  145. tinyjukebox/web/web/help/images/logo-mark.svg +38 -0
  146. tinyjukebox/web/web/index.html +867 -0
  147. tinyjukebox/web/web/style.css +412 -0
  148. tinyjukebox/web/web/texts/de.json +892 -0
  149. tinyjukebox/web/web/texts/en.json +892 -0
  150. tinyjukebox-0.47.0.dist-info/METADATA +236 -0
  151. tinyjukebox-0.47.0.dist-info/RECORD +154 -0
  152. tinyjukebox-0.47.0.dist-info/WHEEL +4 -0
  153. tinyjukebox-0.47.0.dist-info/entry_points.txt +9 -0
  154. tinyjukebox-0.47.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,218 @@
1
+ """JSON file with schema validation and a backup copy of the previous version."""
2
+
3
+ import contextlib
4
+ import json
5
+ import logging
6
+ import os
7
+ import threading
8
+ import time
9
+ from typing import Any
10
+
11
+ import jsonschema
12
+
13
+ from tinyjukebox.core.logging import get_child
14
+ from tinyjukebox.data import degraded
15
+
16
+ __all__ = ["JsonData", "tolerant"]
17
+
18
+
19
+ def tolerant(schema: Any) -> Any:
20
+ """``schema`` without ``"additionalProperties": false``: a field that a newer version added is accepted."""
21
+ if isinstance(schema, list):
22
+ return [tolerant(item) for item in schema]
23
+ if not isinstance(schema, dict):
24
+ return schema
25
+ return {
26
+ key: tolerant(value) for key, value in schema.items() if not (key == "additionalProperties" and value is False)
27
+ }
28
+
29
+
30
+ class JsonData:
31
+ """Content of a JSON file (``data``), validated against ``schema``.
32
+
33
+ Every write moves the current file to ``backup_filename`` first. If the regular file is missing, empty or
34
+ invalid on start, the backup is used and written back to the regular file. Without a usable file and backup,
35
+ ``FileNotFoundError`` is raised.
36
+
37
+ ``data`` may be changed by the owner; :meth:`store_data` writes it (only if it changed). :meth:`store` replaces
38
+ and writes the data in one step - the collections use it, so that concurrent writers never see half built data.
39
+
40
+ ``entries``: the file is a collection - an object whose values are entries of the same kind (tags, targets,
41
+ activities), and the box works with what it can read of it (docs/developer/library.md, section 4.21):
42
+
43
+ - Every entry is checked by itself. One that is damaged, or of a kind this version does not know, is set aside
44
+ (``aside``) instead of making the whole file unusable: it is not in ``data``, and it is written back unchanged
45
+ with every write - this version never deletes what a newer one wrote. An entry of ``data`` with the same key
46
+ replaces it.
47
+ - A field this version does not know is accepted; the owner keeps it (``extra`` of the entries).
48
+ - If neither the file nor its backup can be read, both are kept under a name of their own (``.damaged-<time>``)
49
+ and the collection starts empty instead of not starting.
50
+
51
+ What is set aside is noted in :mod:`tinyjukebox.data.degraded`.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ filename: str,
57
+ backup_filename: str,
58
+ schema: dict[str, Any],
59
+ logger: logging.Logger,
60
+ entries: bool = False,
61
+ ) -> None:
62
+ self._logger = get_child(logger, f"{type(self).__name__}.{filename}")
63
+ self.filename = filename
64
+ self.backup_filename = backup_filename
65
+ self._entries = entries
66
+ self._schema = tolerant(schema) if entries else schema
67
+ self._lock = threading.RLock()
68
+ self.last_string: str | None = None
69
+ self.aside: dict[str, Any] = {}
70
+ self.started_empty = False # neither the file nor its backup could be read
71
+ self._source = os.path.basename(filename)
72
+
73
+ with self._lock:
74
+ read = self._read_data(self.filename)
75
+ if read is None:
76
+ self._logger.error("failed to load regular file - trying the backup file instead.")
77
+ read = self._read_data(self.backup_filename)
78
+ if read is None and not entries:
79
+ raise FileNotFoundError(f"neither '{filename}' nor its backup '{backup_filename}' can be loaded.")
80
+ if read is None:
81
+ read = self._start_empty()
82
+ self.data: dict[str, Any] = read[0]
83
+ self.aside = read[1]
84
+ self._logger.info("rewriting regular file with the content of the backup file.")
85
+ self.store_data(force_write=True, skip_backup=True)
86
+ else:
87
+ self.data, self.aside = read
88
+ self.last_string = self._serialize()
89
+ if entries:
90
+ self._note_aside()
91
+ self._logger.debug("__init__ - %s", self.data)
92
+
93
+ def _read_data(self, filename: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
94
+ """The data of the file and the entries set aside - or None if the file cannot be used at all."""
95
+ self._logger.info("_read_data - from file %s", filename)
96
+ try:
97
+ with open(filename, encoding="utf-8") as infile:
98
+ data = json.load(infile)
99
+ except FileNotFoundError:
100
+ self._logger.error("_read_data - file %s not found.", filename)
101
+ return None
102
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
103
+ self._logger.error("_read_data - file %s is not valid json: %s", filename, e)
104
+ return None
105
+ if self._entries:
106
+ if not isinstance(data, dict):
107
+ self._logger.error("_read_data - file %s holds no object.", filename)
108
+ return None
109
+ return self.split(data)
110
+ if not self._validate_data(data):
111
+ self._logger.error("_read_data - content of file %s is not valid.", filename)
112
+ return None
113
+ return data, {}
114
+
115
+ def split(self, data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
116
+ """The entries of a collection that this version can use, and the ones it sets aside."""
117
+ valid, aside = {}, {}
118
+ for key, entry in data.items():
119
+ error = self.entry_error(entry)
120
+ if error is None:
121
+ valid[key] = entry
122
+ else:
123
+ self._logger.warning("entry '%s' set aside: %s", key, error)
124
+ aside[key] = entry
125
+ return valid, aside
126
+
127
+ def entry_error(self, entry: Any) -> str | None:
128
+ """Why an entry of a collection cannot be used - None if it can."""
129
+ try:
130
+ jsonschema.validate(entry, self._schema["additionalProperties"])
131
+ except jsonschema.ValidationError as e:
132
+ return e.message
133
+ return None
134
+
135
+ def set_aside(self, key: str, entry: Any, why: str) -> None:
136
+ """The owner cannot use an entry that passed the schema: it is kept like a damaged one."""
137
+ with self._lock:
138
+ self._logger.warning("entry '%s' set aside: %s", key, why)
139
+ self.data.pop(key, None)
140
+ self.aside[key] = entry
141
+ self._note_aside()
142
+
143
+ def _note_aside(self) -> None:
144
+ message = "{}: entry '{}' cannot be used by this version and is left out"
145
+ degraded.replace(self._source, {key: message.format(self._source, key) for key in self.aside})
146
+
147
+ def _start_empty(self) -> tuple[dict[str, Any], dict[str, Any]]:
148
+ """Neither file nor backup can be read: both stay as they are under a name of their own - somebody may repair
149
+ them - and the collection starts empty. Nothing is overwritten."""
150
+ self.started_empty = True
151
+ stamp = time.strftime("%Y%m%d-%H%M%S")
152
+ kept = [name for name in (self.filename, self.backup_filename) if os.path.exists(name)]
153
+ for name in kept:
154
+ os.replace(name, f"{name}.damaged-{stamp}")
155
+ if not kept: # nothing was lost: e.g. the positions of a box that has never played
156
+ self._logger.warning("%s: neither the file nor its backup exists; starting without entries", self._source)
157
+ return {}, {}
158
+ message = f"{self._source}: neither the file nor its backup can be read - kept as .damaged-{stamp}"
159
+ self._logger.error("%s; starting without its entries", message)
160
+ degraded.note(self._source + " (file)", "", message)
161
+ return {}, {}
162
+
163
+ def _validate_data(self, data: Any) -> bool:
164
+ try:
165
+ jsonschema.validate(data, self._schema)
166
+ except jsonschema.ValidationError as e:
167
+ self._logger.error("json validation error: %s", e.message)
168
+ return False
169
+ except jsonschema.SchemaError as e:
170
+ self._logger.error("json schema error: %s", e.message)
171
+ return False
172
+ return True
173
+
174
+ def _serialize(self) -> str:
175
+ aside = {key: entry for key, entry in self.aside.items() if key not in self.data}
176
+ return json.dumps({**self.data, **aside}, indent=4)
177
+
178
+ def store(self, data: dict[str, Any], aside: dict[str, Any] | None = None) -> None:
179
+ """Replace ``data`` - and the entries set aside, if given - and write it, atomically with respect to other
180
+ writers."""
181
+ with self._lock:
182
+ previous, self.data = self.data, data
183
+ previous_aside = self.aside
184
+ if aside is not None:
185
+ self.aside = aside
186
+ try:
187
+ self.store_data()
188
+ except ValueError:
189
+ self.data, self.aside = previous, previous_aside
190
+ raise
191
+ if aside is not None and self._entries:
192
+ self._note_aside()
193
+
194
+ def store_data(self, force_write: bool = False, skip_backup: bool = False) -> None:
195
+ """Write ``data`` if it changed (or ``force_write``); the previous file becomes the backup file.
196
+
197
+ :raises ValueError: if ``data`` does not match the schema.
198
+ """
199
+ with self._lock:
200
+ if not self._validate_data(self.data):
201
+ raise ValueError(f"store_data - data for '{self.filename}' does not match the schema.")
202
+ new_string = self._serialize()
203
+ if not force_write and new_string == self.last_string:
204
+ self._logger.debug("store_data - skipped, data not changed.")
205
+ return
206
+ # the box is switched off by pulling the plug: the new content is on the disk before it replaces anything -
207
+ # otherwise the file can be empty after a power loss
208
+ temp = self.filename + ".tmp"
209
+ with open(temp, "w", encoding="utf-8") as outfile:
210
+ outfile.write(new_string)
211
+ outfile.flush()
212
+ os.fsync(outfile.fileno())
213
+ if not skip_backup:
214
+ with contextlib.suppress(FileNotFoundError): # the file does not exist yet
215
+ os.replace(self.filename, self.backup_filename)
216
+ os.replace(temp, self.filename) # the regular file is always complete - or missing (-> backup)
217
+ self.last_string = new_string
218
+ self._logger.debug("store_data - written to %s.", self.filename)
@@ -0,0 +1,71 @@
1
+ """Consistency checks of tags, targets and activities."""
2
+
3
+ import logging
4
+
5
+ from tinyjukebox.core.logging import get_child
6
+ from tinyjukebox.data.activities import Activities
7
+ from tinyjukebox.data.common import is_unmapped
8
+ from tinyjukebox.data.tags import Tags
9
+ from tinyjukebox.data.targets import Targets
10
+
11
+ __all__ = ["SanityCheck"]
12
+
13
+
14
+ class SanityCheck:
15
+ """Checks every collection and the references between them; every problem is logged as warning."""
16
+
17
+ def __init__(self, logger: logging.Logger, targets: Targets, tags: Tags, activities: Activities) -> None:
18
+ self._logger = get_child(logger, type(self).__name__)
19
+ self.targets = targets
20
+ self.tags = tags
21
+ self.activities = activities
22
+
23
+ def sanity_check(self) -> bool:
24
+ targets_passed, targets_errors = self.targets.sanity_check()
25
+ if not targets_passed:
26
+ self._logger.warning("sanity_check - targets: %s", targets_errors)
27
+ # evaluate all checks (no short circuit) so that every problem gets logged
28
+ results = [
29
+ self.tags.sanity_check(),
30
+ targets_passed,
31
+ self.activities.sanity_check(),
32
+ self._cross_check_tags_and_activity(),
33
+ self._cross_check_tags_and_targets(),
34
+ ]
35
+ passed = all(results)
36
+ if not passed:
37
+ self._logger.warning("sanity_check - sanity check failed")
38
+ return passed
39
+
40
+ def _cross_check_tags_and_activity(self) -> bool:
41
+ uids_tags = {tag.tag_uid for tag in self.tags.all()}
42
+ uids_activity = set(self.activities.all())
43
+ only_in_activity = uids_activity - uids_tags
44
+ only_in_tags = uids_tags - uids_activity
45
+ if only_in_activity:
46
+ self._logger.warning(
47
+ "_cross_check_jsons - activity json contains uids that are not in tags json:%s", only_in_activity
48
+ )
49
+ if only_in_tags:
50
+ # activities are created on first playback - missing entries are expected for new or unmapped tags
51
+ self._logger.info(
52
+ "_cross_check_jsons - tags json contains uids that are not in activity json:%s", only_in_tags
53
+ )
54
+ return not only_in_activity
55
+
56
+ def _cross_check_tags_and_targets(self) -> bool:
57
+ passed = True
58
+ for tag in self.tags.all():
59
+ if is_unmapped(tag.target_id):
60
+ continue
61
+ target = self.targets.targets.get(str(tag.target_id))
62
+ if target is None or target.target_type != tag.target_type:
63
+ passed = False
64
+ target_type = tag.target_type.name if tag.target_type is not None else None
65
+ self._logger.warning(
66
+ "_cross_check_jsons - tag '%s' contains not existing %s target_id '%s'",
67
+ tag.tag_uid,
68
+ target_type,
69
+ tag.target_id,
70
+ )
71
+ return passed
@@ -0,0 +1,98 @@
1
+ """An rfid tag and the target (album) it is linked to."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from tinyjukebox.core.logging import get_child
7
+ from tinyjukebox.data.common import NO_TARGET, TargetType, UpdateJson, extra_fields, is_valid_timestamp, now
8
+
9
+ __all__ = ["Tag"]
10
+
11
+
12
+ class Tag:
13
+ """Entry of ``tags.json``::
14
+
15
+ {"tag": {"uid": "1:2:3:4", "name": "Album A"}, "target": {"type": "album", "id": "7"}, "created": "..."}
16
+
17
+ ``target_id`` is ``-1`` as long as the tag is not linked to an album. Every change calls ``update_json`` of the
18
+ owning collection.
19
+ """
20
+
21
+ def __init__(self, logger: logging.Logger, tag: dict[str, Any], update_json: UpdateJson | None) -> None:
22
+ uid = tag.get("tag", {}).get("uid")
23
+ if not isinstance(uid, str) or not uid:
24
+ message = f"Tag.__init__ - tag.uid must be a non empty string (dict: {tag})."
25
+ logger.error(message)
26
+ raise ValueError(message)
27
+ self.tag_uid: str | None = uid
28
+ self._logger = get_child(logger, f"{type(self).__name__}.{uid}")
29
+ self._logger.debug("__init__ - %s", tag)
30
+ self._update_json = update_json
31
+ self.load(tag)
32
+
33
+ def load(self, tag: dict[str, Any]) -> None:
34
+ """Take over name, target and time stamp of ``tag`` (an entry of ``tags.json``) - nothing is written."""
35
+ self.tag_name: str = tag["tag"].get("name", "")
36
+ target = tag.get("target", {})
37
+ # fields of a newer version, on each level: kept and written back
38
+ self._extra = (extra_fields(tag, ("tag", "target", "created")), extra_fields(tag["tag"], ("uid", "name")),
39
+ extra_fields(target, ("type", "id"))) # fmt: skip
40
+ type_name = str(target.get("type") or "album").lower()
41
+ try:
42
+ self.target_type: TargetType | None = TargetType[type_name]
43
+ except KeyError:
44
+ self._logger.error("__init__ - unknown target type %s.", type_name)
45
+ raise ValueError(f"unknown target type {type_name}.") from None
46
+ self.target_id: int | str | None = target.get("id", NO_TARGET)
47
+ self.created: str | None = tag.get("created") or now()
48
+
49
+ def _changed(self) -> None:
50
+ if self._update_json is not None:
51
+ self._update_json()
52
+
53
+ def set_target_type(self, target_type: TargetType) -> None:
54
+ if not isinstance(target_type, TargetType):
55
+ raise ValueError("set_target_type - target_type must be a TargetType.")
56
+ self.target_type = target_type
57
+ self._changed()
58
+
59
+ def set_target_id(self, target_id: int | str | None) -> None:
60
+ if target_id is None or not str(target_id):
61
+ self._logger.warning("set_target_id - target_id is None or empty.")
62
+ return
63
+ self.target_id = target_id
64
+ self._changed()
65
+
66
+ def set_tag_name(self, tag_name: str) -> None:
67
+ self.tag_name = tag_name
68
+ self._changed()
69
+
70
+ @classmethod
71
+ def create_tag(cls, logger: logging.Logger, tag_uid: str, update_json: UpdateJson | None) -> "Tag":
72
+ return Tag(logger, {"tag": {"uid": tag_uid}}, update_json)
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ extra, extra_tag, extra_target = self._extra
76
+ target_type = self.target_type.name if self.target_type is not None else None
77
+ return {
78
+ "tag": {"uid": self.tag_uid, "name": self.tag_name, **extra_tag},
79
+ "target": {"type": target_type, "id": self.target_id, **extra_target},
80
+ "created": self.created,
81
+ **extra,
82
+ }
83
+
84
+ def sanity_check(self) -> bool:
85
+ result = True
86
+ if not is_valid_timestamp(self.created):
87
+ self._logger.warning("sanity_check - created timestamp error '%s'", self.created)
88
+ result = False
89
+ if not self.tag_uid:
90
+ self._logger.warning("sanity_check - tag_uid not set.")
91
+ result = False
92
+ return result
93
+
94
+ def to_text(self) -> str:
95
+ target_type = self.target_type.name if self.target_type is not None else "None"
96
+ return (
97
+ f"tag: {self.tag_uid}/'{self.tag_name}' -> target: {target_type}/{self.target_id}. created: {self.created}"
98
+ )
@@ -0,0 +1,160 @@
1
+ """All known rfid tags (``tags.json``)."""
2
+
3
+ import logging
4
+ import threading
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from tinyjukebox.core.logging import get_child
9
+ from tinyjukebox.data.common import Listener, Mirror, TargetType, is_unmapped
10
+ from tinyjukebox.data.jsondata import JsonData
11
+ from tinyjukebox.data.tag import Tag
12
+ from tinyjukebox.schema.tags import schema
13
+
14
+ __all__ = ["Tags"]
15
+
16
+
17
+ class Tags:
18
+ """Tags by uid. Changes are written to the json file immediately.
19
+
20
+ Tags are changed by the state machine (new tags), the rest api and the command line - concurrently. ``_lock``
21
+ serializes changes of the collection and the writing of the file; it is never held while waiting for anything
22
+ else. Readers in other threads use :meth:`all` (a snapshot) instead of iterating ``tags``.
23
+ """
24
+
25
+ def __init__(self, filename: str, logger: logging.Logger) -> None:
26
+ self._logger = get_child(logger, type(self).__name__)
27
+ self._lock = threading.RLock()
28
+ self._json = JsonData(filename, filename + "_", schema, self._logger, entries=True)
29
+ self._mirror = Mirror()
30
+ self.tags: dict[str, Tag] = {}
31
+ for key, tag in list(self._json.data.items()):
32
+ try:
33
+ self.tags[key] = Tag(self._logger, tag, self._update_json)
34
+ except ValueError as e: # a tag this version cannot use does not keep the box from starting
35
+ self._json.set_aside(key, tag, str(e))
36
+
37
+ @property
38
+ def unavailable(self) -> set[str]:
39
+ """The uids of tags this version cannot use (docs/developer/library.md, section 4.21): known, but not
40
+ playable here - never taken for new tags."""
41
+ with self._lock:
42
+ return set(self._json.aside) - set(self.tags)
43
+
44
+ def _to_dict(self) -> dict[str, Any]:
45
+ return {key: tag.to_dict() for key, tag in self.tags.items()}
46
+
47
+ def _update_json(self) -> None:
48
+ with self._lock:
49
+ data = self._to_dict()
50
+ self._json.store(data)
51
+ self._mirror.changed(data)
52
+
53
+ def listen(self, listener: Listener) -> None:
54
+ """``listener(uid, entry)`` is called for every tag that changes from now on (``None``: deleted)."""
55
+ with self._lock:
56
+ self._mirror.listen(listener, self._to_dict())
57
+
58
+ def sync(self, get: Callable[[], dict[str, Any]]) -> bool:
59
+ """Make the tags what ``get()`` returns (the content of ``tags.json``), keeping the tags that stay. The
60
+ listener is not called. Returns whether something changed."""
61
+ with self._lock:
62
+ data, aside = self._json.split(get())
63
+ if data == self._to_dict() and aside == self._json.aside:
64
+ return False
65
+ for key in [key for key in self.tags if key not in data]:
66
+ del self.tags[key]
67
+ for key, entry in data.items():
68
+ try:
69
+ if key in self.tags:
70
+ self.tags[key].load(entry)
71
+ else:
72
+ self.tags[key] = Tag(self._logger, entry, self._update_json)
73
+ except ValueError as e:
74
+ self._logger.warning("sync - tag '%s' set aside: %s", key, e)
75
+ self.tags.pop(key, None)
76
+ aside[key] = entry
77
+ data = self._to_dict()
78
+ self._json.store(data, aside)
79
+ self._mirror.replaced(data)
80
+ return True
81
+
82
+ def all(self) -> list[Tag]:
83
+ """Snapshot of all tags."""
84
+ with self._lock:
85
+ return list(self.tags.values())
86
+
87
+ def add_tag(self, uid: str | None) -> Tag:
88
+ """Add an unmapped tag. :raises ValueError: if the uid is empty or already known."""
89
+ if not uid:
90
+ message = "add_tag - uid must not be None or empty."
91
+ self._logger.error(message)
92
+ raise ValueError(message)
93
+ with self._lock:
94
+ if uid in self.tags or uid in self._json.aside:
95
+ message = f"add_tag - tag already exists with uid '{uid}'"
96
+ self._logger.error(message)
97
+ raise ValueError(message)
98
+ tag = Tag.create_tag(self._logger, uid, self._update_json)
99
+ self.tags[uid] = tag
100
+ self._update_json()
101
+ return tag
102
+
103
+ def delete_tag(self, uid: str | None) -> None:
104
+ with self._lock:
105
+ if uid not in self.tags:
106
+ self._logger.error("delete_tag - unknown tag %s.", uid)
107
+ raise ValueError(f"delete_tag - unknown tag {uid}.")
108
+ del self.tags[uid]
109
+ self._update_json()
110
+
111
+ def get_tag(self, uid: str | None) -> Tag:
112
+ """:raises ValueError: for unknown uids."""
113
+ tag = self.tags.get(uid) if uid is not None else None
114
+ if tag is None:
115
+ self._logger.warning("get_tag - unknown tag %s.", uid)
116
+ raise ValueError(f"get_tag - unknown tag {uid}.")
117
+ return tag
118
+
119
+ def get_used_target_ids(self, target_type: object) -> list[int | str]:
120
+ """Distinct ids of all targets of ``target_type`` linked to a tag - numeric ids sorted numerically first."""
121
+ if not isinstance(target_type, TargetType):
122
+ self._logger.error("get_used_target_ids - target_type must be a TargetType, got %r.", target_type)
123
+ raise ValueError("get_used_target_ids - target_type must be a TargetType.")
124
+ target_ids = {
125
+ tag.target_id
126
+ for tag in self.all()
127
+ if tag.target_type == target_type and tag.target_id is not None and not is_unmapped(tag.target_id)
128
+ }
129
+ return sorted(target_ids, key=lambda i: (0, int(i), "") if str(i).isdigit() else (1, 0, str(i)))
130
+
131
+ def assign_target(self, tag_uid: str, target_type: object, target_id: int | str | None) -> None:
132
+ """Link the tag with a target. :raises ValueError: for unknown tags or invalid target type/id."""
133
+ try:
134
+ tag = self.get_tag(tag_uid)
135
+ except ValueError:
136
+ self._logger.error("assign_target - unknown tag_uid %s.", tag_uid)
137
+ raise
138
+ if target_id is None or not str(target_id):
139
+ self._logger.error("assign_target - target_id is None or empty")
140
+ raise ValueError("assign_target - target_id is None or empty")
141
+ if not isinstance(target_type, TargetType):
142
+ self._logger.error("assign_target - target_type must be a TargetType, got %r.", target_type)
143
+ raise ValueError("assign_target - target_type must be a TargetType.")
144
+ with self._lock:
145
+ if tag.target_type != target_type:
146
+ tag.set_target_type(target_type)
147
+ tag.set_target_id(target_id)
148
+
149
+ def sanity_check(self) -> bool:
150
+ result = True
151
+ for key, tag in list(self.tags.items()):
152
+ if key != tag.tag_uid:
153
+ self._logger.warning("sanity_check - key '%s' != tag.tag_uid '%s'", key, tag.tag_uid)
154
+ result = False
155
+ if not tag.sanity_check():
156
+ result = False
157
+ return result
158
+
159
+ def to_text(self) -> str:
160
+ return "\n".join(f"key: {key}, tag: {tag.to_text()}" for key, tag in list(self.tags.items()))