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,73 @@
|
|
|
1
|
+
"""Console helpers for UI commands: paging, yes/no questions and argument parsing."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
__all__ = ["get_yes_no", "getch", "more", "parse"]
|
|
7
|
+
|
|
8
|
+
PAGE_SIZE = 35
|
|
9
|
+
_PAGER_TEXT = " --- ENTER: next page / SPACE: next line / ESC: abort ---"
|
|
10
|
+
_ESC, _SPACE, _ENTER = "\x1b", " ", ("\r", "\n")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def getch() -> str:
|
|
14
|
+
"""Read a single character from stdin without echo (falls back to line input if stdin is not a terminal)."""
|
|
15
|
+
if not sys.stdin.isatty():
|
|
16
|
+
line = sys.stdin.readline()
|
|
17
|
+
return line[0] if line else _ESC
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
import msvcrt # noqa: PLC0415 - platform specific
|
|
20
|
+
|
|
21
|
+
return msvcrt.getwch()
|
|
22
|
+
|
|
23
|
+
import termios # noqa: PLC0415 - platform specific
|
|
24
|
+
import tty # noqa: PLC0415 - platform specific
|
|
25
|
+
|
|
26
|
+
fd = sys.stdin.fileno()
|
|
27
|
+
old_settings = termios.tcgetattr(fd)
|
|
28
|
+
try:
|
|
29
|
+
tty.setraw(fd)
|
|
30
|
+
return sys.stdin.read(1)
|
|
31
|
+
finally:
|
|
32
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def more(text: str, page_size: int = PAGE_SIZE) -> None:
|
|
36
|
+
"""Print ``text`` page by page (only paged if stdout is a terminal)."""
|
|
37
|
+
lines = text.split("\n")
|
|
38
|
+
if not sys.stdout.isatty():
|
|
39
|
+
print(text)
|
|
40
|
+
return
|
|
41
|
+
remaining = page_size
|
|
42
|
+
for line in lines:
|
|
43
|
+
print(line)
|
|
44
|
+
remaining -= 1
|
|
45
|
+
if remaining == 0:
|
|
46
|
+
print(_PAGER_TEXT, end="\r", flush=True)
|
|
47
|
+
while True:
|
|
48
|
+
char = getch()
|
|
49
|
+
if char == _ESC:
|
|
50
|
+
print(" " * len(_PAGER_TEXT), end="\r")
|
|
51
|
+
return
|
|
52
|
+
if char == _SPACE:
|
|
53
|
+
remaining = 1
|
|
54
|
+
break
|
|
55
|
+
if char in _ENTER:
|
|
56
|
+
remaining = page_size
|
|
57
|
+
break
|
|
58
|
+
print(" " * len(_PAGER_TEXT), end="\r")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_yes_no() -> str:
|
|
62
|
+
"""Ask 'continue (y/n)' until the user answers; returns ``"y"`` or ``"n"``."""
|
|
63
|
+
while True:
|
|
64
|
+
print("continue (y/n):")
|
|
65
|
+
char = getch().lower()
|
|
66
|
+
if char in ("y", "n"):
|
|
67
|
+
return char
|
|
68
|
+
print(f"unexpected character '{char}'")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse(arg: str) -> tuple[str, ...]:
|
|
72
|
+
"""Split a command argument string like a shell does (quotes group words)."""
|
|
73
|
+
return tuple(shlex.split(arg))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The central place for exceptions that nobody expected.
|
|
2
|
+
|
|
3
|
+
A broken album, a dead radio station or a library that cannot be reached are expected errors and are handled where
|
|
4
|
+
they occur. What arrives here was caught at one of the places that keep the box alive whatever happens: a thread, a
|
|
5
|
+
state machine, the dispatch of the messages, a request of the web page. It was logged there already; the health check
|
|
6
|
+
of a new version (:mod:`tinyjukebox.slots`) listens here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
__all__ = ["Listener", "report", "subscribe", "unsubscribe"]
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
Listener = Callable[[BaseException, str], None]
|
|
18
|
+
|
|
19
|
+
_lock = threading.Lock()
|
|
20
|
+
_listeners: list[Listener] = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def subscribe(listener: Listener) -> None:
|
|
24
|
+
"""``listener(error, where)`` is called for every unexpected exception from now on."""
|
|
25
|
+
with _lock:
|
|
26
|
+
_listeners.append(listener)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unsubscribe(listener: Listener) -> None:
|
|
30
|
+
with _lock:
|
|
31
|
+
if listener in _listeners:
|
|
32
|
+
_listeners.remove(listener)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def report(error: BaseException, where: str) -> None:
|
|
36
|
+
"""Tell the listeners. ``where``: the place that caught the exception, e.g. ``thread RestApi``.
|
|
37
|
+
|
|
38
|
+
A listener that fails is logged and ends nothing: the callers are the places that keep the box alive - the
|
|
39
|
+
dispatch of the messages, a state machine - and a listener writes files, which fails on a full or read-only card."""
|
|
40
|
+
with _lock:
|
|
41
|
+
listeners = list(_listeners)
|
|
42
|
+
for listener in listeners:
|
|
43
|
+
try:
|
|
44
|
+
listener(error, where)
|
|
45
|
+
except Exception:
|
|
46
|
+
_logger.exception("a listener of unexpected exceptions failed (%s)", where)
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Activities of all tags (activities json file)."""
|
|
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.activity import ActivityAlbum
|
|
10
|
+
from tinyjukebox.data.common import Listener, Mirror, TargetType, UpdateJson, is_unmapped
|
|
11
|
+
from tinyjukebox.data.jsondata import JsonData
|
|
12
|
+
from tinyjukebox.data.tag import Tag
|
|
13
|
+
from tinyjukebox.data.tags import Tags
|
|
14
|
+
from tinyjukebox.schema.activities import schema
|
|
15
|
+
|
|
16
|
+
__all__ = ["Activities", "create_activity_factory", "load_activity_factory"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_activity_factory(tag: Any, update_json: UpdateJson | None, logger: logging.Logger) -> ActivityAlbum:
|
|
20
|
+
"""New activity for a linked tag. :raises ValueError: for missing, unlinked or unsupported tags."""
|
|
21
|
+
factory_logger = get_child(logger, "create_activity_factory")
|
|
22
|
+
if tag is None or not hasattr(tag, "target_id"):
|
|
23
|
+
factory_logger.error("tag not provided or not a tag: %s", tag)
|
|
24
|
+
raise ValueError("tag not provided or not a tag.")
|
|
25
|
+
if is_unmapped(tag.target_id):
|
|
26
|
+
factory_logger.error("target id of tag must be set.")
|
|
27
|
+
raise ValueError("target id of tag must be set.")
|
|
28
|
+
if tag.target_type not in (TargetType.album, TargetType.spotify):
|
|
29
|
+
factory_logger.error("unknown target type: %s.", tag.target_type)
|
|
30
|
+
raise ValueError(f"unknown target type: {tag.target_type}.")
|
|
31
|
+
return ActivityAlbum.create_activity(tag, update_json, logger)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_activity_factory(
|
|
35
|
+
activity: dict[str, Any] | None, update_json: UpdateJson | None, logger: logging.Logger
|
|
36
|
+
) -> ActivityAlbum:
|
|
37
|
+
"""Activity from the json file. :raises ValueError: for invalid entries."""
|
|
38
|
+
factory_logger = get_child(logger, "load_activity_factory")
|
|
39
|
+
if activity is None or "target-type" not in activity:
|
|
40
|
+
factory_logger.error("expected dict with key 'target-type', got %s", activity)
|
|
41
|
+
raise ValueError("expected dict with key 'target-type'")
|
|
42
|
+
if activity["target-type"] not in (TargetType.album.name, TargetType.spotify.name):
|
|
43
|
+
factory_logger.error("unknown target type: %s.", activity["target-type"])
|
|
44
|
+
raise ValueError(f"unknown target type: {activity['target-type']}.")
|
|
45
|
+
return ActivityAlbum(activity, update_json, logger)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Activities:
|
|
49
|
+
"""Activities by tag uid. The file is written on every change of an activity (see
|
|
50
|
+
:class:`~tinyjukebox.data.tags.Tags` for the locking rules).
|
|
51
|
+
|
|
52
|
+
A file that neither it nor its backup can be read is not fatal: the activities are rebuilt from the tags (all
|
|
53
|
+
albums start from the beginning).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, filename: str, tags: Tags, logger: logging.Logger) -> None:
|
|
57
|
+
self._logger = get_child(logger, type(self).__name__)
|
|
58
|
+
self._lock = threading.RLock()
|
|
59
|
+
self._tags = tags
|
|
60
|
+
self._filename = filename
|
|
61
|
+
self._mirror = Mirror()
|
|
62
|
+
self.activities: dict[str, ActivityAlbum] = {}
|
|
63
|
+
self._json = JsonData(filename, filename + "_", schema, self._logger, entries=True)
|
|
64
|
+
if self._json.started_empty:
|
|
65
|
+
self._logger.warning("__init__ - no usable activities file - rebuilding.")
|
|
66
|
+
self.rebuild_activities()
|
|
67
|
+
return
|
|
68
|
+
for key, activity in list(self._json.data.items()):
|
|
69
|
+
try:
|
|
70
|
+
self.activities[key] = load_activity_factory(activity, self._update_json, self._logger)
|
|
71
|
+
except ValueError as e: # a position this version cannot use does not keep the box from starting
|
|
72
|
+
self._json.set_aside(key, activity, str(e))
|
|
73
|
+
|
|
74
|
+
def rebuild_activities(self) -> None:
|
|
75
|
+
"""New activities (start of the album) for all tags linked to an album or an entry of Spotify - radio
|
|
76
|
+
stations have none."""
|
|
77
|
+
self._logger.info("rebuild_activities")
|
|
78
|
+
with self._lock:
|
|
79
|
+
self.activities = {
|
|
80
|
+
tag.tag_uid: create_activity_factory(tag, self._update_json, self._logger)
|
|
81
|
+
for tag in self._tags.all()
|
|
82
|
+
if tag.tag_uid is not None
|
|
83
|
+
and tag.target_type in (TargetType.album, TargetType.spotify)
|
|
84
|
+
and not is_unmapped(tag.target_id)
|
|
85
|
+
}
|
|
86
|
+
self._update_json()
|
|
87
|
+
|
|
88
|
+
def _to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {key: activity.to_dict() for key, activity in self.activities.items()}
|
|
90
|
+
|
|
91
|
+
def _update_json(self) -> None:
|
|
92
|
+
with self._lock:
|
|
93
|
+
data = self._to_dict()
|
|
94
|
+
self._json.store(data)
|
|
95
|
+
self._mirror.changed(data)
|
|
96
|
+
|
|
97
|
+
def listen(self, listener: Listener) -> None:
|
|
98
|
+
"""``listener(tag_uid, entry)`` is called for every activity that changes from now on (``None``: deleted)."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._mirror.listen(listener, self._to_dict())
|
|
101
|
+
|
|
102
|
+
def sync(self, get: Callable[[], dict[str, Any]]) -> bool:
|
|
103
|
+
"""Make the activities what ``get()`` returns (the content of the activities json file), keeping the
|
|
104
|
+
activities that stay. The listener is not called. Returns whether something changed."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
data, aside = self._json.split(get())
|
|
107
|
+
if data == self._to_dict() and aside == self._json.aside:
|
|
108
|
+
return False
|
|
109
|
+
for key in [key for key in self.activities if key not in data]:
|
|
110
|
+
del self.activities[key]
|
|
111
|
+
for key, entry in data.items():
|
|
112
|
+
try:
|
|
113
|
+
if key in self.activities:
|
|
114
|
+
self.activities[key].load(entry)
|
|
115
|
+
else:
|
|
116
|
+
self.activities[key] = load_activity_factory(entry, self._update_json, self._logger)
|
|
117
|
+
except ValueError as e:
|
|
118
|
+
self._logger.warning("sync - activity '%s' set aside: %s", key, e)
|
|
119
|
+
self.activities.pop(key, None)
|
|
120
|
+
aside[key] = entry
|
|
121
|
+
data = self._to_dict()
|
|
122
|
+
self._json.store(data, aside)
|
|
123
|
+
self._mirror.replaced(data)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def all(self) -> dict[str, ActivityAlbum]:
|
|
127
|
+
"""Snapshot of all activities by tag uid."""
|
|
128
|
+
with self._lock:
|
|
129
|
+
return dict(self.activities)
|
|
130
|
+
|
|
131
|
+
def get_activity(self, tag_uid: str | None) -> ActivityAlbum:
|
|
132
|
+
""":raises ValueError: for unknown tag uids."""
|
|
133
|
+
activity = self.activities.get(tag_uid) if tag_uid is not None else None
|
|
134
|
+
if activity is None:
|
|
135
|
+
self._logger.warning("get_activity - unknown tag uid %s.", tag_uid)
|
|
136
|
+
raise ValueError(f"get_activity - unknown tag uid {tag_uid}.")
|
|
137
|
+
return activity
|
|
138
|
+
|
|
139
|
+
def add_activity(self, tag: Tag | None) -> ActivityAlbum:
|
|
140
|
+
"""New activity for the tag. :raises ValueError: if the tag is missing, unlinked or has an activity."""
|
|
141
|
+
if tag is None or tag.tag_uid is None:
|
|
142
|
+
self._logger.warning("add_activity - tag is None.")
|
|
143
|
+
raise ValueError("add_activity - tag is None.")
|
|
144
|
+
with self._lock:
|
|
145
|
+
if tag.tag_uid in self.activities:
|
|
146
|
+
self._logger.warning("add_activity - tag uid %s already exists.", tag.tag_uid)
|
|
147
|
+
raise ValueError(f"add_activity - tag uid {tag.tag_uid} already exists.")
|
|
148
|
+
activity = create_activity_factory(tag, self._update_json, self._logger)
|
|
149
|
+
self.activities[tag.tag_uid] = activity
|
|
150
|
+
self._update_json()
|
|
151
|
+
return activity
|
|
152
|
+
|
|
153
|
+
def delete_activity(self, tag_uid: str | None) -> None:
|
|
154
|
+
if tag_uid is None:
|
|
155
|
+
self._logger.warning("delete_activity - tag is None.")
|
|
156
|
+
raise ValueError("delete_activity - tag is None.")
|
|
157
|
+
with self._lock:
|
|
158
|
+
if tag_uid not in self.activities:
|
|
159
|
+
self._logger.warning("delete_activity - unknown tag uid %s.", tag_uid)
|
|
160
|
+
raise ValueError(f"delete_activity - unknown tag uid {tag_uid}.")
|
|
161
|
+
del self.activities[tag_uid]
|
|
162
|
+
self._update_json()
|
|
163
|
+
|
|
164
|
+
def sanity_check(self) -> bool:
|
|
165
|
+
result = True
|
|
166
|
+
for key, activity in self.all().items():
|
|
167
|
+
if key != activity.tag_uid:
|
|
168
|
+
self._logger.warning("sanity_check - key '%s' != activity.tag_uid '%s'", key, activity.tag_uid)
|
|
169
|
+
result = False
|
|
170
|
+
if not activity.sanity_check():
|
|
171
|
+
result = False
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
def to_text(self) -> str:
|
|
175
|
+
return "\n".join(f"key: {key}, activity: {activity.to_text()}" for key, activity in self.all().items())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Activities: the playback position of a tag's target (where to resume when the tag is placed again)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tinyjukebox.core.logging import get_child
|
|
8
|
+
from tinyjukebox.data.common import TargetType, UpdateJson, extra_fields, is_unmapped
|
|
9
|
+
|
|
10
|
+
__all__ = ["AActivity", "ActivityAlbum"]
|
|
11
|
+
|
|
12
|
+
_REQUIRED_ALBUM_KEYS = ("track-id", "track-time", "album-finished")
|
|
13
|
+
_FIELDS = ("tag-uid", "target-id", "target-type", "track-uri", *_REQUIRED_ALBUM_KEYS)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AActivity(ABC):
|
|
17
|
+
"""Base class of all activities."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
tag_uid: str,
|
|
22
|
+
target_id: str | int,
|
|
23
|
+
target_type: TargetType,
|
|
24
|
+
update_json: UpdateJson | None,
|
|
25
|
+
logger: logging.Logger,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._logger = get_child(logger, f"{type(self).__name__}.{tag_uid}-{target_id}")
|
|
28
|
+
self.tag_uid = tag_uid
|
|
29
|
+
self.target_id = target_id
|
|
30
|
+
self.target_type = target_type
|
|
31
|
+
if target_id is None or not str(target_id):
|
|
32
|
+
self._logger.error("__init__ - target id must not be empty.")
|
|
33
|
+
raise ValueError("target id must not be empty.")
|
|
34
|
+
if update_json is None or not callable(update_json):
|
|
35
|
+
self._logger.error("__init__ - update_json method not provided")
|
|
36
|
+
raise ValueError("update_json method not provided.")
|
|
37
|
+
self._update_json = update_json
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def to_dict(self) -> dict[str, Any]: ...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def to_text(self) -> str: ...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def sanity_check(self) -> bool: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ActivityAlbum(AActivity):
|
|
50
|
+
"""Entry of the activities json file - the position in an album or an entry of Spotify::
|
|
51
|
+
|
|
52
|
+
{"tag-uid": "1:2:3:4", "target-id": "7", "track-id": 2, "track-time": 31.4, "album-finished": false,
|
|
53
|
+
"target-type": "album"}
|
|
54
|
+
|
|
55
|
+
An entry of Spotify has ``"target-type": "spotify"`` and also ``track-uri``, the address of its track.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, activity: dict[str, Any] | None, update_json: UpdateJson | None, logger: logging.Logger) -> None:
|
|
59
|
+
if not isinstance(activity, dict) or "tag-uid" not in activity or "target-id" not in activity:
|
|
60
|
+
logger.error("ActivityAlbum.__init__ - activity must be a dict with tag-uid and target-id: %s", activity)
|
|
61
|
+
raise ValueError("activity must be a dict with tag-uid and target-id.")
|
|
62
|
+
target_type = TargetType.spotify if activity.get("target-type") == TargetType.spotify.name else TargetType.album
|
|
63
|
+
super().__init__(activity["tag-uid"], activity["target-id"], target_type, update_json, logger)
|
|
64
|
+
self.load(activity)
|
|
65
|
+
|
|
66
|
+
def load(self, activity: dict[str, Any]) -> None:
|
|
67
|
+
"""Take over target and position of ``activity`` (an entry of the activities json file) - nothing is
|
|
68
|
+
written."""
|
|
69
|
+
missing = [key for key in _REQUIRED_ALBUM_KEYS if key not in activity]
|
|
70
|
+
if missing:
|
|
71
|
+
self._logger.error("__init__ - activity misses %s (%s).", missing, activity)
|
|
72
|
+
raise ValueError(f"activity misses {missing}.")
|
|
73
|
+
self.target_id = activity["target-id"]
|
|
74
|
+
self.extra = extra_fields(activity, _FIELDS) # fields of a newer version: kept and written back
|
|
75
|
+
self.track_id: int = activity["track-id"]
|
|
76
|
+
self.track_time: float = activity["track-time"]
|
|
77
|
+
self.finished: bool = activity["album-finished"]
|
|
78
|
+
self.track_uri: str | None = activity.get("track-uri")
|
|
79
|
+
|
|
80
|
+
def update(self, track_id: int, track_time: float, album_finished: bool, track_uri: str | None = None) -> None:
|
|
81
|
+
"""Store the new position - the json file is written only if something changed."""
|
|
82
|
+
new = (track_id, track_time, album_finished, track_uri)
|
|
83
|
+
if new == (self.track_id, self.track_time, self.finished, self.track_uri):
|
|
84
|
+
self._logger.debug("update - unchanged")
|
|
85
|
+
return
|
|
86
|
+
self.track_id, self.track_time, self.finished, self.track_uri = new
|
|
87
|
+
self._update_json()
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, Any]:
|
|
90
|
+
result: dict[str, Any] = {
|
|
91
|
+
"tag-uid": self.tag_uid,
|
|
92
|
+
"target-id": self.target_id,
|
|
93
|
+
"track-id": self.track_id,
|
|
94
|
+
"track-time": self.track_time,
|
|
95
|
+
"album-finished": self.finished,
|
|
96
|
+
"target-type": self.target_type.name,
|
|
97
|
+
}
|
|
98
|
+
if self.track_uri is not None:
|
|
99
|
+
result["track-uri"] = self.track_uri
|
|
100
|
+
return {**result, **self.extra}
|
|
101
|
+
|
|
102
|
+
def to_text(self) -> str:
|
|
103
|
+
return (
|
|
104
|
+
f"target id: {self.target_id}, track id: {self.track_id}, time: {self.track_time}, "
|
|
105
|
+
f"finished: {self.finished}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def sanity_check(self) -> bool:
|
|
109
|
+
if self.track_time is not None and self.track_time < 0:
|
|
110
|
+
self._logger.warning("sanity_check - track time < 0 (%s)", self.track_time)
|
|
111
|
+
return False
|
|
112
|
+
if self.track_id is not None and self.track_id < 0:
|
|
113
|
+
self._logger.warning("sanity_check - track id < 0 (%s)", self.track_id)
|
|
114
|
+
return False
|
|
115
|
+
finished: object = self.finished # may have been set to anything
|
|
116
|
+
if not isinstance(finished, bool):
|
|
117
|
+
self._logger.warning("sanity_check - finished is not a bool (%s).", type(self.finished))
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def create_activity(cls, tag: Any, update_json: UpdateJson | None, logger: logging.Logger) -> "ActivityAlbum":
|
|
123
|
+
"""New activity (start of the album) for a linked tag. :raises ValueError: for missing or unlinked tags."""
|
|
124
|
+
target_id = getattr(tag, "target_id", None)
|
|
125
|
+
if tag is None or is_unmapped(target_id) or not hasattr(tag, "tag_uid"):
|
|
126
|
+
logger.error("ActivityAlbum.create_activity - tag must be linked to a target: %s", tag)
|
|
127
|
+
raise ValueError("tag must be linked to a target.")
|
|
128
|
+
activity = {
|
|
129
|
+
"tag-uid": tag.tag_uid,
|
|
130
|
+
"target-id": target_id,
|
|
131
|
+
"track-id": 0,
|
|
132
|
+
"track-time": 0,
|
|
133
|
+
"album-finished": False,
|
|
134
|
+
"target-type": TargetType.spotify.name
|
|
135
|
+
if getattr(tag, "target_type", None) == TargetType.spotify
|
|
136
|
+
else TargetType.album.name,
|
|
137
|
+
}
|
|
138
|
+
return ActivityAlbum(activity, update_json, logger)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Types and helpers shared by tags, targets and activities."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"NO_TARGET",
|
|
10
|
+
"TIMESTAMP_FORMAT",
|
|
11
|
+
"Listener",
|
|
12
|
+
"Mirror",
|
|
13
|
+
"TargetType",
|
|
14
|
+
"UpdateJson",
|
|
15
|
+
"extra_fields",
|
|
16
|
+
"is_unmapped",
|
|
17
|
+
"is_valid_timestamp",
|
|
18
|
+
"now",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
|
22
|
+
NO_TARGET = -1
|
|
23
|
+
"""target id of a tag that is not linked to a target yet (stored as -1 or "-1")."""
|
|
24
|
+
|
|
25
|
+
UpdateJson = Callable[[], None]
|
|
26
|
+
"""Callback of a collection: write the current state of all entries to the json file."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Listener = Callable[[str, Any], None]
|
|
30
|
+
"""Called with key and new value of an entry of a collection that changed (``None``: the entry is gone)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Mirror:
|
|
34
|
+
"""Tells a listener which entries of a collection changed - the synchronization with the library
|
|
35
|
+
(:mod:`tinyjukebox.sync`) records every change this way, whoever made it. Called under the lock of the
|
|
36
|
+
collection."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._listener: Listener | None = None
|
|
40
|
+
self._known: dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
def listen(self, listener: Listener, known: dict[str, Any]) -> None:
|
|
43
|
+
"""``known``: the entries as the listener knows them already."""
|
|
44
|
+
self._listener = listener
|
|
45
|
+
self._known = known
|
|
46
|
+
|
|
47
|
+
def changed(self, data: dict[str, Any]) -> None:
|
|
48
|
+
"""``data``: all entries as they are now."""
|
|
49
|
+
if self._listener is None:
|
|
50
|
+
return
|
|
51
|
+
known, self._known = self._known, data
|
|
52
|
+
for key in [*known, *(key for key in data if key not in known)]:
|
|
53
|
+
if known.get(key) != data.get(key):
|
|
54
|
+
self._listener(key, data.get(key))
|
|
55
|
+
|
|
56
|
+
def replaced(self, data: dict[str, Any]) -> None:
|
|
57
|
+
"""The entries were set by the one who listens: nothing to tell."""
|
|
58
|
+
self._known = data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TargetType(Enum):
|
|
62
|
+
album = "album"
|
|
63
|
+
radio = "radio"
|
|
64
|
+
spotify = "spotify"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def now() -> str:
|
|
68
|
+
"""Current UTC time in the timestamp format of the json files."""
|
|
69
|
+
return datetime.datetime.now(datetime.UTC).strftime(TIMESTAMP_FORMAT)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_valid_timestamp(value: object) -> bool:
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
datetime.datetime.strptime(value, TIMESTAMP_FORMAT)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_unmapped(target_id: object) -> bool:
|
|
83
|
+
"""True for the target id of a tag that is not linked (-1, "-1") and for missing ids."""
|
|
84
|
+
return target_id is None or str(target_id) == str(NO_TARGET)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extra_fields(entry: Mapping[str, Any], known: Iterable[str]) -> dict[str, Any]:
|
|
88
|
+
"""The fields of an entry that this version does not know - a newer version wrote them. They are kept and written
|
|
89
|
+
back as they are, so that this version never deletes them (docs/developer/library.md, section 4.21)."""
|
|
90
|
+
known = set(known)
|
|
91
|
+
return {key: value for key, value in entry.items() if key not in known}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""What the box could not load and works without (docs/developer/library.md, section 4.21).
|
|
2
|
+
|
|
3
|
+
A damaged or unknown entry of a json file, a damaged line of the journal, a file that cannot be read: the box leaves it
|
|
4
|
+
out and goes on with the rest instead of not starting. Each such case is noted here with a message, so that the log, the
|
|
5
|
+
page of the box and the library say that the box does not have everything. Noted per source (a file) and key (an
|
|
6
|
+
entry): loading the source again replaces its notes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
__all__ = ["clear", "note", "notes", "replace"]
|
|
12
|
+
|
|
13
|
+
_lock = threading.Lock()
|
|
14
|
+
_notes: dict[str, dict[str, str]] = {} # source -> key -> message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def note(source: str, key: str, message: str) -> None:
|
|
18
|
+
with _lock:
|
|
19
|
+
_notes.setdefault(source, {})[key] = message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def replace(source: str, messages: dict[str, str]) -> None:
|
|
23
|
+
"""The notes of ``source`` are these now (none: it is complete)."""
|
|
24
|
+
with _lock:
|
|
25
|
+
if messages:
|
|
26
|
+
_notes[source] = dict(messages)
|
|
27
|
+
else:
|
|
28
|
+
_notes.pop(source, None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def notes() -> list[str]:
|
|
32
|
+
with _lock:
|
|
33
|
+
return [message for source in sorted(_notes) for message in _notes[source].values()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clear() -> None:
|
|
37
|
+
"""Forget everything - for the tests, which run many boxes in one process."""
|
|
38
|
+
with _lock:
|
|
39
|
+
_notes.clear()
|