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
tinyjukebox/messages.py
ADDED
|
@@ -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)"""
|
tinyjukebox/metadata.py
ADDED
|
@@ -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
|