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,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()