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
|
@@ -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
|
tinyjukebox/data/tag.py
ADDED
|
@@ -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
|
+
)
|
tinyjukebox/data/tags.py
ADDED
|
@@ -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()))
|