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,202 @@
1
+ """Topics and payloads of the messages within the box: drivers, state machines and web page.
2
+
3
+ The messages are delivered within the process (:class:`~tinyjukebox.core.pubsub.LocalQueue`). Payloads are passed on
4
+ unchanged - they are Python objects and must therefore be immutable. The module has no hardware dependencies; the
5
+ drivers and the state machines import it alike.
6
+ """
7
+
8
+ import datetime
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum, StrEnum
11
+
12
+ __all__ = [
13
+ "BLUETOOTH_COMMAND",
14
+ "BLUETOOTH_STATE",
15
+ "BUTTON_HELD",
16
+ "BUTTON_PRESSED",
17
+ "BUTTON_RELEASED",
18
+ "JOURNAL",
19
+ "LED",
20
+ "PLAYBACK",
21
+ "RFID_POLL",
22
+ "RFID_STATE",
23
+ "RGB",
24
+ "BluetoothAction",
25
+ "BluetoothCommand",
26
+ "BluetoothDevice",
27
+ "BluetoothState",
28
+ "Button",
29
+ "Color",
30
+ "JournalEvent",
31
+ "LedPattern",
32
+ "PairingResult",
33
+ "PlaybackStatus",
34
+ "RfidState",
35
+ ]
36
+
37
+
38
+ class Button(StrEnum):
39
+ """The buttons of the box and of the bluetooth headset - value: name in the config (``drivers/button``) and in
40
+ the rest api. The box has no play/pause buttons; the headset sends them (and next/prev, volume-up/-down)."""
41
+
42
+ VOLUME_UP = "volume-up"
43
+ VOLUME_DOWN = "volume-down"
44
+ NEXT = "next"
45
+ PREV = "prev"
46
+ PLAY = "play"
47
+ PAUSE = "pause"
48
+
49
+
50
+ RGB = tuple[bool, bool, bool]
51
+
52
+
53
+ class Color(Enum):
54
+ """Named colors of the RGB LED - value: (red, green, blue)."""
55
+
56
+ BLACK = (False, False, False)
57
+ WHITE = (True, True, True)
58
+ RED = (True, False, False)
59
+ GREEN = (False, True, False)
60
+ BLUE = (False, False, True)
61
+ YELLOW = (True, True, False)
62
+ AQUA = (False, True, True)
63
+ MAGENTA = (True, False, True)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class LedPattern:
68
+ """What the LED shows: one color, or colors that alternate with the given delays (seconds each)."""
69
+
70
+ colors: tuple[RGB, ...]
71
+ delays: tuple[float, ...] = ()
72
+
73
+ def __post_init__(self) -> None:
74
+ if not self.colors:
75
+ raise ValueError("a pattern needs at least one color.")
76
+ if len(self.colors) > 1 and (len(self.delays) != len(self.colors) or min(self.delays) <= 0):
77
+ raise ValueError(f"alternating colors need one delay > 0 per color - got {self.delays}.")
78
+
79
+ @classmethod
80
+ def static(cls, color: Color | RGB) -> "LedPattern":
81
+ return cls((color.value if isinstance(color, Color) else color,))
82
+
83
+ @classmethod
84
+ def blink(cls, color_a: Color, color_b: Color, delay_a: float, delay_b: float) -> "LedPattern":
85
+ return cls((color_a.value, color_b.value), (delay_a, delay_b))
86
+
87
+
88
+ def _now() -> datetime.datetime:
89
+ return datetime.datetime.now(datetime.UTC)
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class RfidState:
94
+ """Result of a read of the RFID reader: ``uid`` of the tag or ``None`` if no tag is present."""
95
+
96
+ uid: str | None
97
+ # why the reader does not work at the moment of the change of the uid (rfid-reader cannot be started, has ended or
98
+ # is stuck) - the current one is in the state of the driver
99
+ error: str | None = None
100
+ timestamp: datetime.datetime = field(default_factory=_now, compare=False)
101
+
102
+ @property
103
+ def present(self) -> bool:
104
+ return self.uid is not None
105
+
106
+
107
+ RFID_STATE = "rfid/state"
108
+ """:class:`RfidState` - rfid driver (and the virtual tags of the web page) → STMOperation"""
109
+
110
+ RFID_POLL = "rfid/poll"
111
+ """``None`` - STMOperation → rfid driver: read the reader now and publish the state in any case"""
112
+
113
+ BUTTON_PRESSED = "button/pressed"
114
+ """:class:`Button` - input drivers, bluetooth driver (headset) and the buttons of the web page → STMVolume,
115
+ STMPlayAlbum, PlayRadio"""
116
+
117
+ BUTTON_HELD = "button/held"
118
+ """:class:`Button` - input drivers, after ``hold_timeout`` seconds → Headset (next + prev held: pairing)"""
119
+
120
+ BUTTON_RELEASED = "button/released"
121
+ """:class:`Button` - input drivers → Headset"""
122
+
123
+ LED = "led"
124
+ """:class:`LedPattern` - RGBLed → rgbled driver"""
125
+
126
+ PLAYBACK = "playback"
127
+ """:class:`PlaybackStatus` - album and radio playback → bluetooth driver (reports it to the headset via AVRCP)"""
128
+
129
+
130
+ JOURNAL = "journal"
131
+ """:class:`JournalEvent` - state machines → the journal of the box (:mod:`tinyjukebox.sync`), which the library turns
132
+ into the play statistics"""
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class JournalEvent:
137
+ """Something worth noting about what the box played: a tag was placed (``tag``), a track was played
138
+ (``track``), the file of a track is damaged (``broken``), a button changed the playback (``control``),
139
+ the playback ended (``end``)."""
140
+
141
+ event: str
142
+ fields: tuple[tuple[str, object], ...] = ()
143
+
144
+ @classmethod
145
+ def of(cls, event: str, **fields: object) -> "JournalEvent":
146
+ return cls(event, tuple(fields.items()))
147
+
148
+
149
+ class PlaybackStatus(StrEnum):
150
+ """What the box plays - the headset sends play or pause accordingly."""
151
+
152
+ PLAYING = "playing"
153
+ PAUSED = "paused"
154
+ STOPPED = "stopped"
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class BluetoothDevice:
159
+ """The paired headset."""
160
+
161
+ address: str # e.g. "AC:80:0A:12:34:56"
162
+ name: str
163
+
164
+
165
+ class PairingResult(StrEnum):
166
+ """Result of a search for a headset (pairing)."""
167
+
168
+ PAIRED = "paired" # found, paired and (usually) connected - the old pairing has been removed
169
+ NOT_FOUND = "not_found" # no headset in pairing mode within the search time - the old pairing is kept
170
+ FAILED = "failed" # found, but pairing failed - the old pairing is kept
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class BluetoothState:
175
+ """State of the bluetooth headset - published whenever it changes."""
176
+
177
+ device: BluetoothDevice | None = None # paired headset or None
178
+ connected: bool = False # the audio connection (A2DP) is up - the box plays on the headset
179
+ battery: int | None = None # % - only if the headset reports it
180
+ volume: float | None = None # % of the headset volume - None while unknown (e.g. not connected)
181
+ pairing: bool = False # a search is running
182
+ pairing_result: PairingResult | None = None # result of the last search
183
+ virtual: bool = False # virtual headset (--no-hardware) - no bluetooth involved
184
+
185
+
186
+ class BluetoothAction(StrEnum):
187
+ PAIR = "pair" # search a headset in pairing mode and pair it (replaces the paired one)
188
+ UNPAIR = "unpair" # remove the paired headset
189
+ VOLUME = "volume" # set the volume of the headset (``BluetoothCommand.volume``)
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class BluetoothCommand:
194
+ action: BluetoothAction
195
+ volume: float | None = None # % - for VOLUME
196
+
197
+
198
+ BLUETOOTH_STATE = "bluetooth/state"
199
+ """:class:`BluetoothState` - bluetooth driver (or the virtual headset) → tinyjukebox"""
200
+
201
+ BLUETOOTH_COMMAND = "bluetooth/command"
202
+ """:class:`BluetoothCommand` - tinyjukebox → bluetooth driver (or the virtual headset)"""
@@ -0,0 +1,133 @@
1
+ """Track metadata (id3 tags, length) and the images of an album folder, read with mutagen."""
2
+
3
+ import os
4
+ import re
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import mutagen
10
+ from mutagen.id3 import ID3, ID3NoHeaderError
11
+ from mutagen.mp3 import MP3
12
+
13
+ __all__ = [
14
+ "COVER_FILES",
15
+ "TrackCache",
16
+ "TrackInfo",
17
+ "find_cover",
18
+ "mp3_info",
19
+ "read_track",
20
+ "stored_cover",
21
+ ]
22
+
23
+ COVER_FILES = ["cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png", "front.jpg", "front.png"]
24
+ _STORED_COVERS = COVER_FILES[:3] # the names a cover was stored under until 0.31 (:func:`stored_cover`)
25
+
26
+ TrackInfo = dict[str, Any]
27
+ """filename, title, artist, album, tracknumber, discnumber, length (None if unknown)."""
28
+
29
+
30
+ def _first(tags: Any, key: str) -> str | None:
31
+ try:
32
+ value = tags.get(key)
33
+ except (KeyError, ValueError):
34
+ return None
35
+ if isinstance(value, list):
36
+ value = value[0] if value else None
37
+ return str(value) if value else None
38
+
39
+
40
+ def _number(value: str | None) -> int | None:
41
+ """'3/12' -> 3"""
42
+ if value is None:
43
+ return None
44
+ match = re.match(r"\s*(\d+)", value)
45
+ return int(match.group(1)) if match else None
46
+
47
+
48
+ def mp3_info(filename: str) -> tuple[float, int]:
49
+ """(length in seconds, sample rate) of an mp3 file.
50
+
51
+ :raises mutagen.MutagenError: (e.g. ``HeaderNotFoundError``) if the file is not a valid mp3 file.
52
+ """
53
+ info: Any = MP3(filename).info # type: ignore[no-untyped-call] # mutagen.mp3 is untyped
54
+ return float(info.length), int(info.sample_rate)
55
+
56
+
57
+ def read_track(filename: str) -> TrackInfo:
58
+ """Metadata of an mp3 file - missing or unreadable values are ``None``."""
59
+ info: TrackInfo = {
60
+ "filename": os.path.basename(filename),
61
+ "title": None,
62
+ "artist": None,
63
+ "album": None,
64
+ "tracknumber": None,
65
+ "discnumber": None,
66
+ "length": None,
67
+ }
68
+ try:
69
+ audio = mutagen.File(filename, easy=True)
70
+ except Exception: # mutagen raises all kinds of errors for garbage
71
+ return info
72
+ if audio is None:
73
+ return info
74
+ if audio.info is not None:
75
+ info["length"] = round(audio.info.length, 2)
76
+ if audio.tags is not None:
77
+ info["title"] = _first(audio.tags, "title")
78
+ info["artist"] = _first(audio.tags, "artist")
79
+ info["album"] = _first(audio.tags, "album")
80
+ info["tracknumber"] = _number(_first(audio.tags, "tracknumber"))
81
+ info["discnumber"] = _number(_first(audio.tags, "discnumber"))
82
+ return info
83
+
84
+
85
+ class TrackCache:
86
+ """Reads the metadata once per file (and again if the file has been changed)."""
87
+
88
+ def __init__(self) -> None:
89
+ self._cache: dict[str, tuple[int, TrackInfo]] = {}
90
+ self._lock = threading.Lock()
91
+
92
+ def get(self, filename: str) -> TrackInfo:
93
+ try:
94
+ mtime = os.stat(filename).st_mtime_ns
95
+ except OSError:
96
+ return dict(read_track(filename), missing=True)
97
+ with self._lock:
98
+ cached = self._cache.get(filename)
99
+ if cached is not None and cached[0] == mtime:
100
+ return dict(cached[1])
101
+ info = read_track(filename)
102
+ with self._lock:
103
+ self._cache[filename] = (mtime, info)
104
+ return dict(info)
105
+
106
+
107
+ def find_cover(album_folder: str, tracks: list[str]) -> tuple[bytes, str] | None:
108
+ """(image, mime type) of an image that comes with the album, or ``None``: what an album without a cover of its
109
+ own shows (:mod:`tinyjukebox.images`). Cover files in the folder win over embedded images."""
110
+ for name in COVER_FILES:
111
+ path = Path(album_folder, name)
112
+ if path.is_file():
113
+ return path.read_bytes(), "image/png" if name.endswith(".png") else "image/jpeg"
114
+ for track in tracks:
115
+ try:
116
+ pictures = ID3(track).getall("APIC") # type: ignore[no-untyped-call] # mutagen.id3 is untyped
117
+ except (ID3NoHeaderError, OSError, mutagen.MutagenError):
118
+ continue
119
+ if pictures:
120
+ front = [p for p in pictures if p.type == 3] or pictures # 3: front cover
121
+ mime = front[0].mime if front[0].mime in ("image/jpeg", "image/png") else "image/jpeg"
122
+ return bytes(front[0].data), str(mime)
123
+ return None
124
+
125
+
126
+ def stored_cover(album_folder: str) -> str | None:
127
+ """Until 0.31 the cover of an album was kept in its folder, as ``cover.jpg`` or ``cover.png``: the file, or
128
+ ``None`` if the folder has none. Now the cover is an image of the images folder (:mod:`tinyjukebox.images`)."""
129
+ for name in _STORED_COVERS:
130
+ path = Path(album_folder, name)
131
+ if path.is_file():
132
+ return str(path)
133
+ return None
tinyjukebox/py.typed ADDED
File without changes