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/sync/box.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""A box that works with a library (docs/developer/library.md): what it stores, the connection, the files.
|
|
2
|
+
|
|
3
|
+
``BoxSync`` connects the synchronization with the data of the box (``tinyjukebox.data``):
|
|
4
|
+
|
|
5
|
+
- every change of tags, targets, activities and volume limits - whoever made it - is recorded in
|
|
6
|
+
the outbox,
|
|
7
|
+
- what the library delivers is put into the same collections, and the tag on the box is evaluated again if it is
|
|
8
|
+
concerned.
|
|
9
|
+
|
|
10
|
+
A box works with a library as soon as the address of one was entered (file ``library`` in the folder ``identity``).
|
|
11
|
+
From then on the json files are generated from what the library delivered and the changes of the box. A box without
|
|
12
|
+
a library is not touched: its json files stay what they are. A box that is unlinked from its library
|
|
13
|
+
(:meth:`BoxSync.unlink`) is such a box again, with everything it has on its card.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
import socket
|
|
22
|
+
import threading
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
from tinyjukebox.core import unexpected
|
|
27
|
+
from tinyjukebox.data import degraded
|
|
28
|
+
from tinyjukebox.data.activities import Activities
|
|
29
|
+
from tinyjukebox.data.tags import Tags
|
|
30
|
+
from tinyjukebox.data.targets import LAST_ID_SUFFIX, Targets
|
|
31
|
+
from tinyjukebox.data.volumelimits import VolumeLimits
|
|
32
|
+
from tinyjukebox.diagnostics import Collector
|
|
33
|
+
from tinyjukebox.images import Images
|
|
34
|
+
from tinyjukebox.slots import Health, Slots
|
|
35
|
+
from tinyjukebox.sync import INTERFACE, acting_as
|
|
36
|
+
from tinyjukebox.sync.data import BoxData
|
|
37
|
+
from tinyjukebox.sync.files import AlbumFiles, ImageFiles
|
|
38
|
+
from tinyjukebox.sync.link import FITS, Link
|
|
39
|
+
from tinyjukebox.sync.util import clock_synchronized, read_json, write_atomic, write_json
|
|
40
|
+
|
|
41
|
+
__all__ = ["MIGRATION", "BoxSync", "Playback"]
|
|
42
|
+
|
|
43
|
+
MIGRATION = "migration" # who made the changes that stand for what a box had before it met a library
|
|
44
|
+
VOLUME = "volume." # settings of a box: the volume limits of a profile, e.g. "volume.speaker"
|
|
45
|
+
ALIVE_SECONDS = 600.0 # a journal line this often: switching the box off leaves none (section 7)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Playback(Protocol):
|
|
49
|
+
"""What the synchronization needs of :class:`~tinyjukebox.stm.stmoperation.STMOperation`."""
|
|
50
|
+
|
|
51
|
+
def get_current_uid(self) -> str | None: ...
|
|
52
|
+
|
|
53
|
+
def get_album(self) -> Any: ...
|
|
54
|
+
|
|
55
|
+
def recheck_tag(self) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _why_left(history: list[dict[str, Any]], version: str) -> str:
|
|
59
|
+
"""Why the box went back from ``version``: the last switch away from it, with its details - e.g. "health check
|
|
60
|
+
failed: stage 1 of the health check not passed within 120 s". Empty if the list of switches does not say."""
|
|
61
|
+
for line in reversed(history):
|
|
62
|
+
if str(line.get("from", "")).endswith(f":{version}") and not str(line.get("to", "")).endswith(f":{version}"):
|
|
63
|
+
return ": ".join(part for part in (line.get("why"), line.get("details")) if part)
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BoxSync:
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
folder: str,
|
|
71
|
+
identity: str,
|
|
72
|
+
version: str,
|
|
73
|
+
logger: logging.Logger,
|
|
74
|
+
albums_folder: str,
|
|
75
|
+
images_folder: str,
|
|
76
|
+
retry_seconds: float = 30.0,
|
|
77
|
+
keep_free: int = 0,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""``folder``: copy, outbox and journal; ``identity``: what identifies this box - its id, its key, address
|
|
80
|
+
and certificate of the library. No version of the box software overwrites ``identity``."""
|
|
81
|
+
self.logger = logger
|
|
82
|
+
self.identity = identity
|
|
83
|
+
self.version = version
|
|
84
|
+
self.retry_seconds = retry_seconds
|
|
85
|
+
self.fit = FITS
|
|
86
|
+
self.page_port: int | None = None # the port of the page of this box, for its link on the page of the library
|
|
87
|
+
self._folder = folder
|
|
88
|
+
self.data = BoxData(folder, self._box_id(), version, logger, clock_synchronized, self._working_changed)
|
|
89
|
+
self.link = Link(self)
|
|
90
|
+
self._stopped = threading.Event()
|
|
91
|
+
self.files = AlbumFiles(self, albums_folder, os.path.join(folder, "manifests"), keep_free)
|
|
92
|
+
self.images = ImageFiles(self, images_folder)
|
|
93
|
+
self._tags: Tags | None = None
|
|
94
|
+
self._targets: Targets | None = None
|
|
95
|
+
self._activities: Activities | None = None
|
|
96
|
+
self._limits: VolumeLimits | None = None
|
|
97
|
+
self._playback: Playback | None = None
|
|
98
|
+
self._positions: dict[str, Any] = {}
|
|
99
|
+
self._applying = threading.Lock()
|
|
100
|
+
self.slots: Slots | None = None # which version runs (tinyjukebox.slots) - None on a box without slots
|
|
101
|
+
self.health: Health | None = None
|
|
102
|
+
self.on_fit: Callable[[], None] | None = None # box and library fit together, or not any more
|
|
103
|
+
self.diagnostics: Collector | None = None # what the library gets of the log (section 4.22)
|
|
104
|
+
self.fit = self._stored("fit") or FITS
|
|
105
|
+
|
|
106
|
+
def _box_id(self) -> str:
|
|
107
|
+
"""The internal id of this box: generated on the first start, never changed, typed by nobody (section 4.7)."""
|
|
108
|
+
filename = os.path.join(self.identity, "box-id")
|
|
109
|
+
try:
|
|
110
|
+
with open(filename, encoding="utf-8") as infile:
|
|
111
|
+
return infile.read().strip()
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
box_id = "b" + secrets.token_hex(4)
|
|
114
|
+
os.makedirs(self.identity, mode=0o700, exist_ok=True)
|
|
115
|
+
write_atomic(filename, box_id + "\n")
|
|
116
|
+
return box_id
|
|
117
|
+
|
|
118
|
+
# --- with or without a library -----------------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _stored(self, name: str) -> str:
|
|
121
|
+
try:
|
|
122
|
+
with open(os.path.join(self._folder, name), encoding="utf-8") as infile:
|
|
123
|
+
return infile.read().strip()
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
def _identity(self, name: str) -> str:
|
|
128
|
+
try:
|
|
129
|
+
with open(os.path.join(self.identity, name), encoding="utf-8") as infile:
|
|
130
|
+
return infile.read().strip()
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def library(self) -> str:
|
|
136
|
+
"""The address of the library, empty if none is configured."""
|
|
137
|
+
return self._identity("library")
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def attached(self) -> bool:
|
|
141
|
+
"""Whether this box works with a library: its changes are recorded, its json files are generated."""
|
|
142
|
+
return os.path.exists(os.path.join(self._folder, "attached"))
|
|
143
|
+
|
|
144
|
+
def set_library(self, url: str, bootstrap: str | None = None) -> None:
|
|
145
|
+
"""Enter the address of the library, and the bootstrap password for the first contact. The box works with
|
|
146
|
+
the library from its next start."""
|
|
147
|
+
os.makedirs(self.identity, mode=0o700, exist_ok=True)
|
|
148
|
+
write_atomic(os.path.join(self.identity, "library"), url.strip() + "\n")
|
|
149
|
+
if bootstrap:
|
|
150
|
+
filename = os.path.join(self.identity, "bootstrap")
|
|
151
|
+
write_atomic(filename, bootstrap.strip() + "\n")
|
|
152
|
+
os.chmod(filename, 0o600)
|
|
153
|
+
self.link.wake()
|
|
154
|
+
|
|
155
|
+
def unlink(self) -> None:
|
|
156
|
+
"""Leave the library (section 4.20). The box keeps what is on its card - its json files are the truth again -
|
|
157
|
+
and forgets the library: address, key, certificate, the copy and the changes that wait. The library is not
|
|
158
|
+
asked; it may be switched off, or not exist any more."""
|
|
159
|
+
self.link.stop()
|
|
160
|
+
with self._applying:
|
|
161
|
+
if self._targets is not None:
|
|
162
|
+
self._targets.id_source = None # new targets get a number again
|
|
163
|
+
# from here on nothing is recorded, and nothing of the copy is put into the data of the box
|
|
164
|
+
self._tags = self._targets = self._activities = self._limits = None
|
|
165
|
+
with contextlib.suppress(FileNotFoundError):
|
|
166
|
+
os.remove(os.path.join(self._folder, "attached"))
|
|
167
|
+
for name in ("library", "key", "library.crt", "bootstrap"):
|
|
168
|
+
with contextlib.suppress(FileNotFoundError):
|
|
169
|
+
os.remove(os.path.join(self.identity, name))
|
|
170
|
+
self.data.forget_library()
|
|
171
|
+
self.files.forget()
|
|
172
|
+
self.data.journal("unlinked")
|
|
173
|
+
self.data.journal_limit()
|
|
174
|
+
self.set_fit(FITS) # a library that was too old for the box does not stop it any more
|
|
175
|
+
self.logger.info("unlinked from the library: this box works for itself")
|
|
176
|
+
self.look_for_versions() # from now on they come from the releases (section 6.16)
|
|
177
|
+
if self._playback is not None:
|
|
178
|
+
self._playback.recheck_tag()
|
|
179
|
+
|
|
180
|
+
def absent(self) -> int:
|
|
181
|
+
"""Albums of which files are missing on this box. Only the library has them."""
|
|
182
|
+
if not self.attached:
|
|
183
|
+
return 0
|
|
184
|
+
return sum(1 for target in self.data.working("target") if self.files.lacking(target) != [])
|
|
185
|
+
|
|
186
|
+
def prepare(self, files: dict[str, str]) -> None:
|
|
187
|
+
"""Called before the data of the box is loaded. ``files``: the json files of tags (``tag``), targets
|
|
188
|
+
(``target``), activities (``position``) and volume limits (``volume``).
|
|
189
|
+
|
|
190
|
+
With a library: on the first start everything the box has becomes a change that the library has not seen
|
|
191
|
+
yet; on every start the json files are generated anew from copy and outbox (section 4.2).
|
|
192
|
+
"""
|
|
193
|
+
if not self.library and not self.attached:
|
|
194
|
+
return
|
|
195
|
+
if not self.attached:
|
|
196
|
+
self._attach(files)
|
|
197
|
+
limits = {
|
|
198
|
+
key[len(VOLUME) :]: value for key, value in self.data.working("setting").items() if key.startswith(VOLUME)
|
|
199
|
+
}
|
|
200
|
+
for kind, data in (("tag", None), ("target", None), ("position", None), ("volume", limits)):
|
|
201
|
+
write_json(files[kind], self.data.working(kind) if data is None else data)
|
|
202
|
+
|
|
203
|
+
def _attach(self, files: dict[str, str]) -> None:
|
|
204
|
+
"""The first start with a library. What the box has may be unknown there, so every target gets a provisional
|
|
205
|
+
id - the old id with the id of the box in front - and is settled like one that was created without a
|
|
206
|
+
connection: the library recognizes an album or a station it has already."""
|
|
207
|
+
self.logger.info("first start with a library: everything on this box is recorded as a change of its own")
|
|
208
|
+
self.data.clear_outbox()
|
|
209
|
+
targets = read_json(files["target"], {})
|
|
210
|
+
used = [int(key) for key in targets if key.isascii() and key.isdigit()]
|
|
211
|
+
last = read_json(os.path.splitext(files["target"])[0] + LAST_ID_SUFFIX, {}).get("last-id", 0)
|
|
212
|
+
number = max([0, *used, last if isinstance(last, int) else 0])
|
|
213
|
+
ids = {}
|
|
214
|
+
for key in targets:
|
|
215
|
+
if not (key.isascii() and key.isdigit()):
|
|
216
|
+
number += 1
|
|
217
|
+
ids[key] = f"{self.data.box_id}-{key if key.isascii() and key.isdigit() else number}"
|
|
218
|
+
self.data.numbers["target"] = number
|
|
219
|
+
|
|
220
|
+
def mapped(target_id: Any) -> Any:
|
|
221
|
+
"""The new id of a target; a link to a target that does not exist is no link."""
|
|
222
|
+
return ids.get(str(target_id), -1)
|
|
223
|
+
|
|
224
|
+
for key, value in targets.items():
|
|
225
|
+
self.data.add("target", ids[key], {**value, "target-id": ids[key]}, by=MIGRATION)
|
|
226
|
+
for key, tag in read_json(files["tag"], {}).items():
|
|
227
|
+
target = tag.get("target", {})
|
|
228
|
+
linked = {**tag, "target": {**target, "id": mapped(target["id"])}} if "id" in target else tag
|
|
229
|
+
self.data.add("tag", key, linked, by=MIGRATION)
|
|
230
|
+
for key, value in read_json(files["position"], {}).items():
|
|
231
|
+
if str(value["target-id"]) in ids:
|
|
232
|
+
self.data.add("position", key, {**value, "target-id": ids[str(value["target-id"])]}, by=MIGRATION)
|
|
233
|
+
for key, value in read_json(files["volume"], {}).items():
|
|
234
|
+
self.data.add("setting", VOLUME + key, value, by=MIGRATION)
|
|
235
|
+
# only now the box counts as attached: a start that was interrupted above begins anew
|
|
236
|
+
write_atomic(os.path.join(self._folder, "attached"), self.data.box_id + "\n")
|
|
237
|
+
|
|
238
|
+
def bind(
|
|
239
|
+
self,
|
|
240
|
+
tags: Tags,
|
|
241
|
+
targets: Targets,
|
|
242
|
+
activities: Activities,
|
|
243
|
+
images: Images,
|
|
244
|
+
limits: VolumeLimits,
|
|
245
|
+
playback: Playback | None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Connect the data of the box: from now on its changes are recorded, and what the library delivers is put
|
|
248
|
+
into it."""
|
|
249
|
+
self._playback = playback
|
|
250
|
+
if not self.attached:
|
|
251
|
+
return
|
|
252
|
+
self._tags, self._targets, self._activities = tags, targets, activities
|
|
253
|
+
self._limits = limits
|
|
254
|
+
self._positions = dict(self.data.working("position"))
|
|
255
|
+
targets.id_source = self.data.new_target_id
|
|
256
|
+
tags.listen(lambda key, value: self._local("tag", key, value))
|
|
257
|
+
targets.listen(lambda key, value: self._local("target", key, value))
|
|
258
|
+
activities.listen(lambda key, value: self._local("position", key, value))
|
|
259
|
+
images.listen(self.link.wake)
|
|
260
|
+
limits.listen(lambda key, value: self._local("setting", VOLUME + key, value))
|
|
261
|
+
|
|
262
|
+
def start(self) -> None:
|
|
263
|
+
if self.attached:
|
|
264
|
+
self.link.start()
|
|
265
|
+
self._stopped.clear()
|
|
266
|
+
threading.Thread(target=self._alive, name="Box.alive", daemon=True).start()
|
|
267
|
+
else:
|
|
268
|
+
self.data.journal_limit()
|
|
269
|
+
|
|
270
|
+
def stop(self) -> None:
|
|
271
|
+
self._stopped.set()
|
|
272
|
+
self.link.stop()
|
|
273
|
+
|
|
274
|
+
def _alive(self) -> None:
|
|
275
|
+
"""The last line of a start tells the library how long the box ran: a line from time to time, so that the
|
|
276
|
+
hours after the last tag count too."""
|
|
277
|
+
while not self._stopped.wait(ALIVE_SECONDS):
|
|
278
|
+
if self.attached: # not unlinked since the start
|
|
279
|
+
self.journal("alive")
|
|
280
|
+
|
|
281
|
+
# --- changes made on this box ------------------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def _local(self, kind: str, key: str, value: Any) -> None:
|
|
284
|
+
"""A change of the data of the box: into the outbox. It took effect already, with or without a connection."""
|
|
285
|
+
if not self.attached:
|
|
286
|
+
return # unlinked since the start
|
|
287
|
+
tell = True
|
|
288
|
+
if kind == "position":
|
|
289
|
+
# the position moves on all the time; the library hears of it with a change of track, a pause or the end
|
|
290
|
+
before, self._positions[key] = self._positions.get(key), value
|
|
291
|
+
tell = (
|
|
292
|
+
before is None
|
|
293
|
+
or value is None
|
|
294
|
+
or any(before.get(field) != value.get(field) for field in ("target-id", "track-id", "album-finished"))
|
|
295
|
+
)
|
|
296
|
+
self.data.add(kind, key, value)
|
|
297
|
+
if tell:
|
|
298
|
+
self.link.wake()
|
|
299
|
+
|
|
300
|
+
def journal(self, event: str, **fields: Any) -> None:
|
|
301
|
+
"""Note what happened (section 7). Without a library the journal is kept short, like a log file."""
|
|
302
|
+
self.data.journal(event, **fields)
|
|
303
|
+
if not self.attached:
|
|
304
|
+
self.data.journal_limit()
|
|
305
|
+
elif event == "tag" and fields.get("kind") == "album":
|
|
306
|
+
self.files.played(str(fields.get("target"))) # the album played last is the last to make room
|
|
307
|
+
|
|
308
|
+
def tell(self) -> None:
|
|
309
|
+
"""Something worth telling the library happened, e.g. the playback paused."""
|
|
310
|
+
if self.attached:
|
|
311
|
+
self.link.wake()
|
|
312
|
+
|
|
313
|
+
def name(self) -> str | None:
|
|
314
|
+
"""The name of this box for people - a box gets one when it works with a library."""
|
|
315
|
+
return self.data.working("setting").get("name") if self.attached else None
|
|
316
|
+
|
|
317
|
+
def set_name(self, name: str) -> None:
|
|
318
|
+
self.data.add("setting", "name", name)
|
|
319
|
+
self.link.wake()
|
|
320
|
+
|
|
321
|
+
def album_changed(self, album: str) -> None:
|
|
322
|
+
"""The files of an album changed on this box, e.g. its playlist was written anew."""
|
|
323
|
+
if self.attached:
|
|
324
|
+
self.files.changed(album)
|
|
325
|
+
self.link.wake()
|
|
326
|
+
|
|
327
|
+
def lookup(self, timeout: float = 3.0) -> None:
|
|
328
|
+
"""An unknown tag: compare the catalog with the library before it is taken for a new one (section 5)."""
|
|
329
|
+
if self.attached and self.library:
|
|
330
|
+
self.link.wait_pass(timeout)
|
|
331
|
+
|
|
332
|
+
# --- what the library delivered ----------------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def _signature(self) -> Any:
|
|
335
|
+
"""What the tag on the box plays: if this changes, the tag is evaluated again."""
|
|
336
|
+
uid = self._playback.get_current_uid() if self._playback is not None else None
|
|
337
|
+
if uid is None or self._tags is None or self._targets is None:
|
|
338
|
+
return None
|
|
339
|
+
tag = self._tags.tags.get(uid)
|
|
340
|
+
if tag is None:
|
|
341
|
+
return (uid,)
|
|
342
|
+
target = self._targets.targets.get(str(tag.target_id))
|
|
343
|
+
described = {k: v for k, v in target.to_dict().items() if k != "target-id"} if target is not None else None
|
|
344
|
+
return (uid, tag.target_type, self.data.final(str(tag.target_id)), described)
|
|
345
|
+
|
|
346
|
+
def _working_changed(self) -> None:
|
|
347
|
+
"""Copy or outbox changed: put the working data into the collections of the box."""
|
|
348
|
+
if self._tags is None or self._targets is None or self._activities is None:
|
|
349
|
+
return
|
|
350
|
+
with self._applying, acting_as("library"):
|
|
351
|
+
before = self._signature()
|
|
352
|
+
self._targets.sync(lambda: self.data.working("target"), self.data.renamed())
|
|
353
|
+
self._tags.sync(lambda: self.data.working("tag"))
|
|
354
|
+
self._activities.sync(lambda: self.data.working("position"))
|
|
355
|
+
self._positions = dict(self.data.working("position"))
|
|
356
|
+
settings = self.data.working("setting")
|
|
357
|
+
if self._limits is not None:
|
|
358
|
+
self._limits.sync({k[len(VOLUME) :]: v for k, v in settings.items() if k.startswith(VOLUME)})
|
|
359
|
+
if self._playback is not None and before != self._signature():
|
|
360
|
+
# e.g. the tag on the box was linked on the page of the library: it plays at once
|
|
361
|
+
self._playback.recheck_tag()
|
|
362
|
+
|
|
363
|
+
# --- what the connection asks ------------------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def unexpected(self, error: BaseException) -> None:
|
|
366
|
+
"""The central place for an error nobody expected in the threads of the synchronization."""
|
|
367
|
+
self.logger.error("unexpected exception in the synchronization: %r", error, exc_info=error)
|
|
368
|
+
unexpected.report(error, "synchronization")
|
|
369
|
+
|
|
370
|
+
def set_fit(self, fit: str) -> None:
|
|
371
|
+
"""Whether box and library fit together (section 4.15)."""
|
|
372
|
+
if fit == self.fit:
|
|
373
|
+
return
|
|
374
|
+
self.fit = fit
|
|
375
|
+
write_atomic(os.path.join(self._folder, "fit"), fit + "\n") # the state survives a restart
|
|
376
|
+
if self.slots is not None and self.slots.available:
|
|
377
|
+
self.slots.run("_fit", fit) # "tinyjukebox-slot status" shows it
|
|
378
|
+
if self.on_fit is not None:
|
|
379
|
+
self.on_fit()
|
|
380
|
+
if self.blocked:
|
|
381
|
+
self.logger.error("this box stops working: %s", fit)
|
|
382
|
+
else:
|
|
383
|
+
self.logger.info("box and library: %s", fit)
|
|
384
|
+
self.data.journal("fit", state=fit)
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def blocked(self) -> bool:
|
|
388
|
+
return self.fit.startswith("library-too-old")
|
|
389
|
+
|
|
390
|
+
def playing(self, target_id: str) -> bool:
|
|
391
|
+
album = self._playback.get_album() if self._playback is not None else None
|
|
392
|
+
if album is None:
|
|
393
|
+
return False
|
|
394
|
+
playing = str(album.get_info().get("target_id"))
|
|
395
|
+
return playing in (target_id, self.data.final(target_id))
|
|
396
|
+
|
|
397
|
+
def learn(self, provisional: str, final: str, value: dict[str, Any] | None) -> bool:
|
|
398
|
+
"""Apply the final id of a target. Returns False if this has to wait: the album is playing and its folder
|
|
399
|
+
has another name in the library."""
|
|
400
|
+
created = [e["value"] for e in self.data.outbox() if e["op"] == "create" and e["key"] == provisional]
|
|
401
|
+
moves = bool(created and value and created[0].get("album-folder") != value.get("album-folder"))
|
|
402
|
+
if moves and self.playing(provisional):
|
|
403
|
+
return False
|
|
404
|
+
self.files.renamed(provisional, final, value)
|
|
405
|
+
self.data.learn(provisional, final, value)
|
|
406
|
+
self.logger.info("the provisional id %s is %s", provisional, final)
|
|
407
|
+
return True
|
|
408
|
+
|
|
409
|
+
def target_removed(self, value: dict[str, Any]) -> None:
|
|
410
|
+
if not self.playing(str(value.get("target-id"))):
|
|
411
|
+
self.files.removed(value)
|
|
412
|
+
|
|
413
|
+
def look_for_versions(self) -> None:
|
|
414
|
+
"""The versions of the box software may have changed in the library."""
|
|
415
|
+
if self.health is not None:
|
|
416
|
+
self.health.look_for_versions()
|
|
417
|
+
|
|
418
|
+
def _slot_status(self) -> dict[str, Any]:
|
|
419
|
+
return self.slots.status() if self.slots is not None and self.slots.available else {}
|
|
420
|
+
|
|
421
|
+
def discard_defective(self) -> None:
|
|
422
|
+
"""Do not trust the data of a version that the health check sent back (section 4.11): what it left in the
|
|
423
|
+
outbox is dropped. What a person entered with it is logged with its full content, not applied."""
|
|
424
|
+
for version in self._slot_status().get("discard", []):
|
|
425
|
+
for entry in self.data.discard_version(version):
|
|
426
|
+
self.logger.warning(
|
|
427
|
+
"entered by %s with the defective version %s, not applied: %s",
|
|
428
|
+
entry["by"], version, json.dumps(entry),
|
|
429
|
+
) # fmt: skip
|
|
430
|
+
self.data.journal("discarded", entry=entry)
|
|
431
|
+
|
|
432
|
+
def status_report(self) -> dict[str, Any]:
|
|
433
|
+
"""What the box tells the library with every contact: what runs here, and what the box did to its versions
|
|
434
|
+
since the last report."""
|
|
435
|
+
report: dict[str, Any] = {
|
|
436
|
+
"version": self.version, "interface": INTERFACE, "host": socket.gethostname(), "fit": self.fit,
|
|
437
|
+
"degraded": degraded.notes(), # what this version could not load and works without (section 4.21)
|
|
438
|
+
} # fmt: skip
|
|
439
|
+
status = self._slot_status()
|
|
440
|
+
if status:
|
|
441
|
+
acknowledged = self.data.numbers["history"]
|
|
442
|
+
if status["history"] and status["history"][-1]["n"] < acknowledged:
|
|
443
|
+
# the switches begin anew - a card set up again with the folder of the synchronization of before:
|
|
444
|
+
# a number from the old list would keep every new switch from the library
|
|
445
|
+
acknowledged = 0
|
|
446
|
+
report.update({
|
|
447
|
+
"slot": status["active"], "slots": status["slots"], "mode": status["mode"],
|
|
448
|
+
"newest": status["newest"], "problem": status["problem"],
|
|
449
|
+
"history": [line for line in status["history"] if line["n"] > acknowledged],
|
|
450
|
+
"left": [
|
|
451
|
+
{"version": entry["version"], "base": entry["rev"],
|
|
452
|
+
"why": entry.get("why") or _why_left(status["history"], entry["version"])}
|
|
453
|
+
for entry in status["versions"] if entry["origin"] == "box"
|
|
454
|
+
],
|
|
455
|
+
"defective": status["discard"],
|
|
456
|
+
}) # fmt: skip
|
|
457
|
+
return report
|
|
458
|
+
|
|
459
|
+
def reported(self, answer: dict[str, Any]) -> None:
|
|
460
|
+
"""The library has the report: it knows the switches, the versions this box went back from, and the versions
|
|
461
|
+
that are defective here."""
|
|
462
|
+
if self.slots is None or not self.slots.available:
|
|
463
|
+
return
|
|
464
|
+
if answer.get("left"):
|
|
465
|
+
self.slots.run("_reported", *answer["left"])
|
|
466
|
+
for version in answer.get("defective", []):
|
|
467
|
+
self.slots.run("_discarded", version)
|
|
468
|
+
self.data.history_reported(answer.get("history", self.data.numbers["history"]))
|
|
469
|
+
|
|
470
|
+
def info(self) -> dict[str, Any]:
|
|
471
|
+
"""The state of the synchronization for the page of the box."""
|
|
472
|
+
return {
|
|
473
|
+
"box": self.data.box_id,
|
|
474
|
+
"name": self.name(),
|
|
475
|
+
"library": self.library,
|
|
476
|
+
"attached": self.attached,
|
|
477
|
+
"connected": self.link.connected,
|
|
478
|
+
"alone": self.link.alone,
|
|
479
|
+
"alone_code": self.link.alone_code,
|
|
480
|
+
"fit": self.fit,
|
|
481
|
+
"blocked": self.blocked,
|
|
482
|
+
"outbox": len(self.data.outbox()),
|
|
483
|
+
"absent": self.absent(),
|
|
484
|
+
"unsettled": self.data.provisional_ids(),
|
|
485
|
+
"fetching": self.files.fetching(),
|
|
486
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""HTTP(S) client on the standard library: JSON, files that can be resumed, server-sent events.
|
|
2
|
+
|
|
3
|
+
The client accepts exactly one certificate, the one it was given (docs/developer/library.md, section 4.14);
|
|
4
|
+
the name in it does not matter. The counterpart is the web server of the library (``tinyjukebox.web.server``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import http.client
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import ssl
|
|
13
|
+
import urllib.parse
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__all__ = ["KEEP_ALIVE", "Client", "HttpError", "Response", "Unreachable", "WrongCertificate"]
|
|
18
|
+
|
|
19
|
+
KEEP_ALIVE = 5.0 # seconds without an event after which an event stream sends a comment, so that both ends notice a cut
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HttpError(Exception):
|
|
23
|
+
"""An answer with a status of 400 or above - raised by a route, and by the client when it receives one."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, message: str = "", **details: Any) -> None:
|
|
26
|
+
super().__init__(f"{status} {message}".strip())
|
|
27
|
+
self.status = status
|
|
28
|
+
self.message = message
|
|
29
|
+
self.details = details
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Unreachable(Exception): # noqa: N818 - a state of the network, not a programming error
|
|
33
|
+
"""The other side cannot be reached, or the connection broke."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WrongCertificate(Exception): # noqa: N818
|
|
37
|
+
"""The server presented another certificate than the one that is expected."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Response:
|
|
41
|
+
def __init__(self, status: int, headers: http.client.HTTPMessage, body: bytes) -> None:
|
|
42
|
+
self.status = status
|
|
43
|
+
self.headers = headers
|
|
44
|
+
self.body = body
|
|
45
|
+
|
|
46
|
+
def json(self) -> Any:
|
|
47
|
+
return json.loads(self.body) if self.body else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Client:
|
|
51
|
+
"""Requests to one server. With ``certificate`` (PEM) only a server that presents exactly this one is accepted."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self, url: str, certificate: str | None = None, headers: dict[str, str] | None = None, timeout: float = 10.0
|
|
55
|
+
) -> None:
|
|
56
|
+
split = urllib.parse.urlsplit(url)
|
|
57
|
+
self._https = split.scheme == "https"
|
|
58
|
+
self._host = split.hostname or "localhost"
|
|
59
|
+
self._port = split.port or (443 if self._https else 80)
|
|
60
|
+
self._prefix = split.path.rstrip("/")
|
|
61
|
+
self._certificate = ssl.PEM_cert_to_DER_cert(certificate) if certificate else None
|
|
62
|
+
self.headers = dict(headers or {})
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self.peer_certificate: str | None = None # PEM of the certificate the server presented last
|
|
65
|
+
self._stream: socket.socket | None = None # the connection that events are read from
|
|
66
|
+
|
|
67
|
+
def _connect(self, timeout: float | None = None) -> http.client.HTTPConnection:
|
|
68
|
+
timeout = self._timeout if timeout is None else timeout
|
|
69
|
+
try:
|
|
70
|
+
if not self._https:
|
|
71
|
+
connection = http.client.HTTPConnection(self._host, self._port, timeout=timeout)
|
|
72
|
+
connection.connect()
|
|
73
|
+
return connection
|
|
74
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
75
|
+
context.check_hostname = False
|
|
76
|
+
context.verify_mode = ssl.CERT_NONE # checked below: equal to the one certificate that is expected
|
|
77
|
+
secure = http.client.HTTPSConnection(self._host, self._port, timeout=timeout, context=context)
|
|
78
|
+
secure.connect()
|
|
79
|
+
except (OSError, http.client.HTTPException) as error:
|
|
80
|
+
raise Unreachable(f"{self._host}:{self._port}: {error}") from None
|
|
81
|
+
presented = secure.sock.getpeercert(binary_form=True) or b""
|
|
82
|
+
self.peer_certificate = ssl.DER_cert_to_PEM_cert(presented)
|
|
83
|
+
if self._certificate is not None and presented != self._certificate:
|
|
84
|
+
secure.close()
|
|
85
|
+
raise WrongCertificate(f"{self._host}:{self._port} presents another certificate than the stored one")
|
|
86
|
+
return secure
|
|
87
|
+
|
|
88
|
+
def request(
|
|
89
|
+
self,
|
|
90
|
+
method: str,
|
|
91
|
+
path: str,
|
|
92
|
+
body: Any = None,
|
|
93
|
+
*,
|
|
94
|
+
upload: str | None = None,
|
|
95
|
+
sink: str | None = None,
|
|
96
|
+
headers: dict[str, str] | None = None,
|
|
97
|
+
) -> Response:
|
|
98
|
+
"""One request. ``body`` is sent as JSON, ``upload`` names a file to send, ``sink`` a file for the answer.
|
|
99
|
+
|
|
100
|
+
A ``sink`` that exists already is continued. An answer with a status of 400 or above raises ``HttpError``.
|
|
101
|
+
"""
|
|
102
|
+
send_headers = {**self.headers, **(headers or {})}
|
|
103
|
+
data: Any = None
|
|
104
|
+
if upload is not None:
|
|
105
|
+
send_headers["Content-Length"] = str(os.path.getsize(upload))
|
|
106
|
+
send_headers["Content-Type"] = "application/octet-stream"
|
|
107
|
+
elif body is not None:
|
|
108
|
+
data = json.dumps(body).encode("utf-8")
|
|
109
|
+
send_headers["Content-Type"] = "application/json"
|
|
110
|
+
offset = os.path.getsize(sink) if sink is not None and os.path.exists(sink) else 0
|
|
111
|
+
if offset:
|
|
112
|
+
send_headers["Range"] = f"bytes={offset}-"
|
|
113
|
+
connection = self._connect()
|
|
114
|
+
try:
|
|
115
|
+
if upload is not None:
|
|
116
|
+
with open(upload, "rb") as infile:
|
|
117
|
+
connection.request(method, self._prefix + path, body=infile, headers=send_headers)
|
|
118
|
+
else:
|
|
119
|
+
connection.request(method, self._prefix + path, body=data, headers=send_headers)
|
|
120
|
+
answer = connection.getresponse()
|
|
121
|
+
if sink is not None and answer.status in (200, 206):
|
|
122
|
+
# the rest of what is there already: 206; 200 with the range from a library before 0.45, and for a
|
|
123
|
+
# file that is complete already
|
|
124
|
+
rest = offset > 0 and str(answer.headers.get("Content-Range", "")).startswith(f"bytes {offset}-")
|
|
125
|
+
if answer.status == 206 and not rest:
|
|
126
|
+
raise Unreachable(f"{self._host}:{self._port}: {path} answered another range than asked for")
|
|
127
|
+
with open(sink, "ab" if rest else "wb") as outfile:
|
|
128
|
+
while chunk := answer.read(1 << 16):
|
|
129
|
+
outfile.write(chunk)
|
|
130
|
+
outfile.flush()
|
|
131
|
+
os.fsync(outfile.fileno())
|
|
132
|
+
content = b""
|
|
133
|
+
else:
|
|
134
|
+
content = answer.read()
|
|
135
|
+
except (OSError, http.client.HTTPException) as error:
|
|
136
|
+
raise Unreachable(f"{self._host}:{self._port}: {error!r}") from None
|
|
137
|
+
finally:
|
|
138
|
+
connection.close()
|
|
139
|
+
response = Response(answer.status, answer.headers, content)
|
|
140
|
+
if answer.status >= 400:
|
|
141
|
+
details: dict[str, Any] = {}
|
|
142
|
+
with contextlib.suppress(ValueError):
|
|
143
|
+
details = response.json() or {}
|
|
144
|
+
failure = HttpError(answer.status, str(details.pop("error", "") or details.get("message", "")))
|
|
145
|
+
failure.details = details
|
|
146
|
+
raise failure
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
def get(self, path: str) -> Any:
|
|
150
|
+
return self.request("GET", path).json()
|
|
151
|
+
|
|
152
|
+
def post(self, path: str, body: Any = None) -> Any:
|
|
153
|
+
return self.request("POST", path, body).json()
|
|
154
|
+
|
|
155
|
+
def put(self, path: str, body: Any = None) -> Any:
|
|
156
|
+
return self.request("PUT", path, body).json()
|
|
157
|
+
|
|
158
|
+
def delete(self, path: str) -> Any:
|
|
159
|
+
return self.request("DELETE", path).json()
|
|
160
|
+
|
|
161
|
+
def events(self, path: str) -> Iterator[tuple[str, Any]]:
|
|
162
|
+
"""Server-sent events until the connection breaks; then ``Unreachable`` is raised."""
|
|
163
|
+
connection = self._connect(timeout=3 * KEEP_ALIVE)
|
|
164
|
+
try:
|
|
165
|
+
connection.request("GET", self._prefix + path, headers=self.headers)
|
|
166
|
+
self._stream = connection.sock # the answer takes the socket over: the connection forgets it
|
|
167
|
+
answer = connection.getresponse()
|
|
168
|
+
if answer.status != 200:
|
|
169
|
+
raise HttpError(answer.status, "no events")
|
|
170
|
+
event = ""
|
|
171
|
+
while line := answer.readline():
|
|
172
|
+
text = line.decode("utf-8").rstrip("\n")
|
|
173
|
+
if text.startswith("event: "):
|
|
174
|
+
event = text[7:]
|
|
175
|
+
elif text.startswith("data: "):
|
|
176
|
+
yield event, json.loads(text[6:])
|
|
177
|
+
except (TimeoutError, OSError, http.client.HTTPException) as error:
|
|
178
|
+
raise Unreachable(f"{self._host}:{self._port}: {error!r}") from None
|
|
179
|
+
finally:
|
|
180
|
+
self._stream = None
|
|
181
|
+
connection.close()
|
|
182
|
+
raise Unreachable(f"{self._host}:{self._port}: the event stream ended")
|
|
183
|
+
|
|
184
|
+
def interrupt(self) -> None:
|
|
185
|
+
"""End :meth:`events` from another thread: its connection is cut."""
|
|
186
|
+
stream = self._stream
|
|
187
|
+
if stream is not None:
|
|
188
|
+
with contextlib.suppress(OSError):
|
|
189
|
+
stream.shutdown(socket.SHUT_RDWR)
|