webplayercontrol 1.0.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.
@@ -0,0 +1,3 @@
1
+ """An ASGI application for controlling MPRIS-compliant media players."""
2
+
3
+ from .main import main as main
@@ -0,0 +1,12 @@
1
+ html {
2
+ color: white;
3
+ background: black;
4
+ }
5
+
6
+ a, a:active, a:link, a:visited {
7
+ color: white;
8
+ }
9
+
10
+ .error-message {
11
+ color: red;
12
+ }
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>WebPlayerControl</title>
6
+ <link href="{{hash-urlsafe:common.css}}" integrity="sha256-{{hash:common.css}}" rel="stylesheet">
7
+ <script type="module" integrity="sha256-{{hash:index.js}}" src="{{hash-urlsafe:index.js}}"></script>
8
+ </head>
9
+ <body>
10
+ <h1>Select a player to control</h1>
11
+ <ul id="player-list">
12
+ </ul>
13
+ </body>
14
+ </html>
@@ -0,0 +1,26 @@
1
+ const onLoad = async function () {
2
+ const playerList = document.getElementById("player-list");
3
+ try {
4
+ // Fetch the list of players from the server.
5
+ const listingResp = await fetch("players/", { headers: { Accept: "application/json" } });
6
+ if (!listingResp.ok) {
7
+ throw new Error(`Fetching player list failed: HTTP error ${listingResp.status}`);
8
+ }
9
+ const listing = await listingResp.json();
10
+ for (const [name, display] of listing) {
11
+ const item = document.createElement("li");
12
+ const link = document.createElement("a");
13
+ link.href = `players/${name}/`;
14
+ link.textContent = display;
15
+ item.append(link);
16
+ playerList.append(item);
17
+ }
18
+ } catch (exp) {
19
+ const para = document.createElement("p");
20
+ para.textContent = exp.toString();
21
+ para.classList.add("error-message");
22
+ playerList.parentElement.replaceChild(para, playerList);
23
+ }
24
+ };
25
+
26
+ window.addEventListener("load", onLoad);
@@ -0,0 +1,43 @@
1
+ html {
2
+ text-align: center;
3
+ }
4
+
5
+ button {
6
+ background: black;
7
+ border: 0;
8
+ font-size: min(10vw, 35vh);
9
+ }
10
+
11
+ button:disabled {
12
+ filter: grayscale(100%);
13
+ }
14
+
15
+ #status {
16
+ font-size: 2rem;
17
+ margin-bottom: 2rem;
18
+ }
19
+
20
+ #spinner-wrapper {
21
+ display: inline-block;
22
+ min-height: 2em;
23
+ vertical-align: middle;
24
+ }
25
+
26
+ #spinner {
27
+ animation: spin 2s linear infinite;
28
+ border: 0.2em solid #333;
29
+ border-top: 0.2em solid #888;
30
+ border-radius: 50%;
31
+ display: inline-block;
32
+ width: 1em;
33
+ height: 1em;
34
+ }
35
+
36
+ #spinner.off {
37
+ display: none;
38
+ }
39
+
40
+ @keyframes spin {
41
+ 0% { transform: rotate(0deg); }
42
+ 100% { transform: rotate(360deg); }
43
+ }
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>WebPlayerControl</title>
6
+ <link href="../../{{hash-urlsafe:common.css}}" integrity="sha256-{{hash:common.css}}" rel="stylesheet">
7
+ <link href="../../{{hash-urlsafe:player.css}}" integrity="sha256-{{hash:player.css}}" rel="stylesheet">
8
+ <script type="module" integrity="sha256-{{hash:player.js}}" src="../../{{hash-urlsafe:player.js}}"></script>
9
+ </head>
10
+ <body>
11
+ <h1>WebPlayerControl</h1>
12
+ <a href="../..">← Back to player list</a>
13
+ <div id="status">
14
+ <div id="spinner-wrapper">
15
+ <div id="spinner"></div>
16
+ </div>
17
+ <span id="status-text">Initializing</span>
18
+ </div>
19
+ <div id="buttons">
20
+ <button id="goPrevious" type="button" disabled>⏮️</button>
21
+ <button id="seekBack" type="button" disabled>⏪️</button>
22
+ <button id="stop" type="button" disabled>⏹️</button>
23
+ <button id="pause" type="button" disabled>⏸️</button>
24
+ <button id="play" type="button" disabled>▶️</button>
25
+ <button id="seekForward" type="button" disabled>⏩️</button>
26
+ <button id="goNext" type="button" disabled>⏭️</button>
27
+ </div>
28
+ </body>
29
+ </html>
@@ -0,0 +1,114 @@
1
+ /*
2
+ * Capitalizes the first character of a string.
3
+ */
4
+ const capFirst = function (string) {
5
+ return string.charAt(0).toUpperCase() + string.slice(1);
6
+ };
7
+
8
+ /*
9
+ * Lowercases the first character of a string.
10
+ */
11
+ const lowerFirst = function (string) {
12
+ return string.charAt(0).toLowerCase() + string.slice(1);
13
+ };
14
+
15
+ /*
16
+ * Display a status message for an error or reconnection.
17
+ *
18
+ * All controls are disabled.
19
+ */
20
+ const showStatus = function (msg, error) {
21
+ for (const button of document.getElementsByTagName("button")) {
22
+ button.disabled = true;
23
+ }
24
+ const spinner = document.getElementById("spinner");
25
+ const statusText = document.getElementById("status-text");
26
+ statusText.textContent = msg;
27
+ if (error) {
28
+ spinner.classList.add("off");
29
+ statusText.classList.add("error-message");
30
+ } else {
31
+ spinner.classList.remove("off");
32
+ statusText.classList.remove("error-message");
33
+ }
34
+ };
35
+
36
+ /*
37
+ * Handles a server-sent event starting with “can”.
38
+ *
39
+ * Such an event enables or disables the indicated button.
40
+ */
41
+ const onCan = function (evt) {
42
+ const buttonName = lowerFirst(evt.type.slice(3));
43
+ const button = document.getElementById(buttonName);
44
+ button.disabled = evt.data === "0";
45
+ };
46
+
47
+ /*
48
+ * Handles a button being clicked.
49
+ */
50
+ const onClick = async function (evt, source) {
51
+ const buttonName = evt.target.id;
52
+ const result = await (async () => {
53
+ try {
54
+ const resp = await fetch(buttonName, { body: "", method: "POST", mode: "same-origin" });
55
+ return [resp, null];
56
+ } catch (err) {
57
+ if (err instanceof TypeError) {
58
+ return [null, err];
59
+ }
60
+ throw err;
61
+ }
62
+ })();
63
+ const [resp, err] = result;
64
+ if (resp === null) {
65
+ source.close();
66
+ showStatus(`POST to “${buttonName}” failed with ${err.message}`, true);
67
+ } else if (!resp.ok) {
68
+ source.close();
69
+ showStatus(`POST to “${resp.url}” failed with HTTP ${resp.status}`, true);
70
+ }
71
+ };
72
+
73
+ /*
74
+ * Handles an error in the server-sent event connection.
75
+ *
76
+ * The problem is displayed and all controls are disabled until the event
77
+ * stream reconnects (if it is going to do so).
78
+ */
79
+ const onError = function (evt) {
80
+ if (evt.target.readyState === EventSource.CONNECTING) {
81
+ showStatus("SSE reconnecting", false);
82
+ } else {
83
+ showStatus("SSE connection failed", true);
84
+ }
85
+ };
86
+
87
+ /*
88
+ * Handles a server-sent “title” event.
89
+ *
90
+ * The title is displayed for the user. The loading spinner is also hidden,
91
+ * since “title” is always the last event in the initial burst on a new
92
+ * connection.
93
+ */
94
+ const onTitle = function (evt) {
95
+ const statusText = document.getElementById("status-text");
96
+ statusText.textContent = evt.data;
97
+ const spinner = document.getElementById("spinner");
98
+ spinner.classList.add("off");
99
+ };
100
+
101
+ /*
102
+ * Starts the application.
103
+ */
104
+ const main = function () {
105
+ const source = new EventSource("status");
106
+ source.addEventListener("error", onError);
107
+ for (const button of document.getElementsByTagName("button")) {
108
+ source.addEventListener(`can${capFirst(button.id)}`, onCan);
109
+ button.addEventListener("click", (evt) => onClick(evt, source));
110
+ }
111
+ source.addEventListener("title", onTitle);
112
+ };
113
+
114
+ main();
@@ -0,0 +1,284 @@
1
+ """Handling of static asset files."""
2
+
3
+ import binascii
4
+ import importlib.resources
5
+ from base64 import b64encode, urlsafe_b64decode, urlsafe_b64encode
6
+ from dataclasses import dataclass
7
+ from hashlib import sha256
8
+ from logging import getLogger
9
+ from os import environ
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .utils import acceptable, discard_request, send_error, unmodified
14
+
15
+ if TYPE_CHECKING:
16
+ from asgi_types import (
17
+ ASGIReceiveCallable,
18
+ ASGISendCallable,
19
+ HTTPScope,
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class _FileType:
25
+ """Information about a particular file type."""
26
+
27
+ __slots__ = {
28
+ "mime_type": "The MIME type of files with this extension.",
29
+ "template": "Whether this file type permits template substitutions.",
30
+ }
31
+
32
+ mime_type: bytes
33
+ template: bool
34
+
35
+
36
+ _FILE_TYPES = {
37
+ "css": _FileType(mime_type=b"text/css", template=False),
38
+ "html": _FileType(mime_type=b"text/html", template=True),
39
+ "js": _FileType(mime_type=b"text/javascript", template=False),
40
+ }
41
+ """All known file types, by file extension."""
42
+
43
+
44
+ @dataclass
45
+ class _Asset:
46
+ """Information about an individual asset."""
47
+
48
+ __slots__ = {
49
+ "content": "The content of the asset.",
50
+ "etag": "The ETag header value.",
51
+ "extracted": "The path to which the asset was extracted for pathsend.",
52
+ "file_type": "The file type.",
53
+ }
54
+
55
+ content: bytes
56
+ etag: bytes
57
+ extracted: Path | None
58
+ file_type: _FileType
59
+
60
+
61
+ class AssetHandler:
62
+ """A handler that knows how to deal with static asset files."""
63
+
64
+ __slots__ = {
65
+ "_assets": "A mapping from hash value to _Asset object.",
66
+ "_hashes": "A mapping from filename to hash value.",
67
+ "_warned": "Whether the missing pathsend extension warning has been shown yet.",
68
+ }
69
+
70
+ _assets: dict[bytes, _Asset]
71
+ _hashes: dict[str, bytes]
72
+ _warned: bool
73
+
74
+ def __init__(self) -> None:
75
+ """Initialize the asset handler."""
76
+ self._warned = False
77
+
78
+ # Load all the assets into memory.
79
+ by_name = {
80
+ i.name: _Asset(
81
+ content=i.read_bytes(),
82
+ etag=b"",
83
+ extracted=None,
84
+ file_type=_FILE_TYPES[i.name.rsplit(".", 1)[-1]],
85
+ )
86
+ for i in (importlib.resources.files(__package__) / "assets").iterdir()
87
+ }
88
+
89
+ # Hash the ones that are not templates, because we already know their content.
90
+ self._hashes = {
91
+ k: sha256(v.content).digest()
92
+ for k, v in by_name.items()
93
+ if not v.file_type.template
94
+ }
95
+
96
+ # Preprocess the ones that are templates; they can depend on any non-template
97
+ # files, but not other templates since that could create a loop of changing hash
98
+ # values.
99
+ for k, v in by_name.items():
100
+ if v.file_type.template:
101
+ v.content = self._preprocess(k, v.content, self._hashes)
102
+
103
+ # Add the preprocessed templates to the hash dictionary.
104
+ self._hashes.update(
105
+ (
106
+ (k, sha256(v.content).digest())
107
+ for k, v in by_name.items()
108
+ if v.file_type.template
109
+ ),
110
+ )
111
+
112
+ # Create the hash-to-asset dictionary.
113
+ self._assets = {self._hashes[k]: v for k, v in by_name.items()}
114
+
115
+ # Fill in the ETag header values.
116
+ for k, v in self._assets.items():
117
+ v.etag = b'"' + b64encode(k) + b'"'
118
+
119
+ # If an assets directory was specified, extract them.
120
+ if (extract_dir := environ.get("WEBPLAYERCONTROL_ASSETS")) is not None:
121
+ extract_dir = Path(extract_dir)
122
+ extract_dir.mkdir(parents=True, exist_ok=True)
123
+ for k, v in self._assets.items():
124
+ dest = extract_dir / urlsafe_b64encode(k).decode("ASCII")
125
+ dest.write_bytes(v.content)
126
+ v.extracted = dest
127
+
128
+ @staticmethod
129
+ def _preprocess(name: str, content: bytes, hashes: dict[str, bytes]) -> bytes:
130
+ """
131
+ Preprocess an asset file.
132
+
133
+ Every occurrence of {{hash:<filename>}} will be replaced with the SHA256 of
134
+ <filename>, encoded with base64. Every occurrence of {{hash-urlsafe:<filename>}}
135
+ will be replaced with the SHA256 of <filename>, encoded with URL-safe base64.
136
+
137
+ :param name: The name of this asset.
138
+ :param content: The content to preprocess.
139
+ :param hashes: The hashes of the assets.
140
+ """
141
+ for prefix, encode in (
142
+ (b"{{hash:", b64encode),
143
+ (b"{{hash-urlsafe:", urlsafe_b64encode),
144
+ ):
145
+ while (pos := content.find(prefix)) >= 0:
146
+ end_pos = content.find(b"}}", pos)
147
+ if end_pos < 0:
148
+ msg = f"Asset {name} contains {prefix} without matching b'}}'"
149
+ raise ValueError(msg)
150
+ target_name = content[pos + len(prefix) : end_pos].decode("UTF-8")
151
+ content = (
152
+ content[:pos] + encode(hashes[target_name]) + content[end_pos + 2 :]
153
+ )
154
+ return content
155
+
156
+ async def maybe_handle_cas(
157
+ self,
158
+ path: str,
159
+ scope: HTTPScope,
160
+ receive: ASGIReceiveCallable,
161
+ send: ASGISendCallable,
162
+ ) -> bool:
163
+ """
164
+ Handle a request if it matches the name of a content-addressable asset.
165
+
166
+ :param path: The request path.
167
+ :param scope: The scope.
168
+ :param receive: The event-receive function.
169
+ :param send: The event-send function.
170
+ :return: True if the request matched a content-addressable asset and has been
171
+ handled, or False if it did not match any such name.
172
+ """
173
+ try:
174
+ h = urlsafe_b64decode(path.encode("UTF-8"))
175
+ except binascii.Error:
176
+ return False
177
+ if (asset := self._assets.get(h)) is None:
178
+ return False
179
+ await self._handle(
180
+ asset,
181
+ b"max-age=31536000, immutable, stale-while-revalidate=31536000",
182
+ scope,
183
+ receive,
184
+ send,
185
+ )
186
+ return True
187
+
188
+ async def handle(
189
+ self,
190
+ asset: str,
191
+ cache_control: bytes,
192
+ scope: HTTPScope,
193
+ receive: ASGIReceiveCallable,
194
+ send: ASGISendCallable,
195
+ ) -> None:
196
+ """
197
+ Handle a request as a static asset.
198
+
199
+ :param asset: The name of the asset to send.
200
+ :param cache_control: The value of the cache-control header.
201
+ :param scope: The scope.
202
+ :param receive: The event-receive function.
203
+ :param send: The event-send function.
204
+ """
205
+ await self._handle(
206
+ self._assets[self._hashes[asset]],
207
+ cache_control,
208
+ scope,
209
+ receive,
210
+ send,
211
+ )
212
+
213
+ async def _handle(
214
+ self,
215
+ asset: _Asset,
216
+ cache_control: bytes,
217
+ scope: HTTPScope,
218
+ receive: ASGIReceiveCallable,
219
+ send: ASGISendCallable,
220
+ ) -> None:
221
+ """
222
+ Handle a request as a static asset.
223
+
224
+ :param asset: Which asset to send.
225
+ :param cache_control: The value of the cache-control header.
226
+ :param scope: The scope.
227
+ :param receive: The event-receive function.
228
+ :param send: The event-send function.
229
+ """
230
+ # Receive and discard any request body.
231
+ await discard_request(receive)
232
+
233
+ # Static assets can only handle GET requests.
234
+ if scope["method"] != "GET":
235
+ await send_error(send, 405, "Method Not Allowed")
236
+ return
237
+
238
+ # Check that the asset’s content type is acceptable to the client.
239
+ if not acceptable(asset.file_type.mime_type, scope["headers"]):
240
+ await send_error(send, 406, "Not Acceptable")
241
+ return
242
+
243
+ # Prepare the headers.
244
+ content_type = asset.file_type.mime_type
245
+ if content_type.startswith(b"text/"):
246
+ content_type += b"; charset=utf-8"
247
+ headers = [
248
+ (b"content-type", content_type),
249
+ (b"content-length", str(len(asset.content)).encode("ASCII")),
250
+ (b"cache-control", cache_control),
251
+ (b"etag", asset.etag),
252
+ ]
253
+
254
+ # Check if the client is doing a conditional request that should be skipped.
255
+ if unmodified(asset.etag, scope["headers"]):
256
+ await send(
257
+ {
258
+ "type": "http.response.start",
259
+ "status": 304,
260
+ "headers": headers,
261
+ },
262
+ )
263
+ await send({"type": "http.response.body", "body": b""})
264
+ return
265
+
266
+ # Send the asset content.
267
+ await send({"type": "http.response.start", "status": 200, "headers": headers})
268
+ if asset.extracted is not None:
269
+ if "http.response.pathsend" in scope.get("extensions", {}):
270
+ await send(
271
+ # asgi_types doesn’t know about pathsend
272
+ { # pyright: ignore [reportArgumentType]
273
+ "type": "http.response.pathsend",
274
+ "path": str(asset.extracted),
275
+ },
276
+ )
277
+ return
278
+ if not self._warned:
279
+ getLogger(__name__).warning(
280
+ "WEBPLAYERCONTROL_ASSETS unused; server does not support "
281
+ "http.response.pathsend",
282
+ )
283
+ self._warned = True
284
+ await send({"type": "http.response.body", "body": asset.content})
@@ -0,0 +1,132 @@
1
+ <!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
2
+ <node>
3
+ <interface name="org.freedesktop.DBus">
4
+ <method name="Hello">
5
+ <arg direction="out" type="s"/>
6
+ </method>
7
+ <method name="RequestName">
8
+ <arg direction="in" type="s"/>
9
+ <arg direction="in" type="u"/>
10
+ <arg direction="out" type="u"/>
11
+ </method>
12
+ <method name="ReleaseName">
13
+ <arg direction="in" type="s"/>
14
+ <arg direction="out" type="u"/>
15
+ </method>
16
+ <method name="StartServiceByName">
17
+ <arg direction="in" type="s"/>
18
+ <arg direction="in" type="u"/>
19
+ <arg direction="out" type="u"/>
20
+ </method>
21
+ <method name="UpdateActivationEnvironment">
22
+ <arg direction="in" type="a{ss}"/>
23
+ </method>
24
+ <method name="NameHasOwner">
25
+ <arg direction="in" type="s"/>
26
+ <arg direction="out" type="b"/>
27
+ </method>
28
+ <method name="ListNames">
29
+ <arg direction="out" type="as"/>
30
+ </method>
31
+ <method name="ListActivatableNames">
32
+ <arg direction="out" type="as"/>
33
+ </method>
34
+ <method name="AddMatch">
35
+ <arg direction="in" type="s"/>
36
+ </method>
37
+ <method name="RemoveMatch">
38
+ <arg direction="in" type="s"/>
39
+ </method>
40
+ <method name="GetNameOwner">
41
+ <arg direction="in" type="s"/>
42
+ <arg direction="out" type="s"/>
43
+ </method>
44
+ <method name="ListQueuedOwners">
45
+ <arg direction="in" type="s"/>
46
+ <arg direction="out" type="as"/>
47
+ </method>
48
+ <method name="GetConnectionUnixUser">
49
+ <arg direction="in" type="s"/>
50
+ <arg direction="out" type="u"/>
51
+ </method>
52
+ <method name="GetConnectionUnixProcessID">
53
+ <arg direction="in" type="s"/>
54
+ <arg direction="out" type="u"/>
55
+ </method>
56
+ <method name="GetAdtAuditSessionData">
57
+ <arg direction="in" type="s"/>
58
+ <arg direction="out" type="ay"/>
59
+ </method>
60
+ <method name="GetConnectionSELinuxSecurityContext">
61
+ <arg direction="in" type="s"/>
62
+ <arg direction="out" type="ay"/>
63
+ </method>
64
+ <method name="ReloadConfig">
65
+ </method>
66
+ <method name="GetId">
67
+ <arg direction="out" type="s"/>
68
+ </method>
69
+ <method name="GetConnectionCredentials">
70
+ <arg direction="in" type="s"/>
71
+ <arg direction="out" type="a{sv}"/>
72
+ </method>
73
+ <property name="Features" type="as" access="read">
74
+ <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
75
+ </property>
76
+ <property name="Interfaces" type="as" access="read">
77
+ <annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="const"/>
78
+ </property>
79
+ <signal name="NameOwnerChanged">
80
+ <arg type="s"/>
81
+ <arg type="s"/>
82
+ <arg type="s"/>
83
+ </signal>
84
+ <signal name="NameLost">
85
+ <arg type="s"/>
86
+ </signal>
87
+ <signal name="NameAcquired">
88
+ <arg type="s"/>
89
+ </signal>
90
+ <signal name="ActivatableServicesChanged">
91
+ </signal>
92
+ </interface>
93
+ <interface name="org.freedesktop.DBus.Properties">
94
+ <method name="Get">
95
+ <arg direction="in" type="s"/>
96
+ <arg direction="in" type="s"/>
97
+ <arg direction="out" type="v"/>
98
+ </method>
99
+ <method name="GetAll">
100
+ <arg direction="in" type="s"/>
101
+ <arg direction="out" type="a{sv}"/>
102
+ </method>
103
+ <method name="Set">
104
+ <arg direction="in" type="s"/>
105
+ <arg direction="in" type="s"/>
106
+ <arg direction="in" type="v"/>
107
+ </method>
108
+ <signal name="PropertiesChanged">
109
+ <arg type="s" name="interface_name"/>
110
+ <arg type="a{sv}" name="changed_properties"/>
111
+ <arg type="as" name="invalidated_properties"/>
112
+ </signal>
113
+ </interface>
114
+ <interface name="org.freedesktop.DBus.Introspectable">
115
+ <method name="Introspect">
116
+ <arg direction="out" type="s"/>
117
+ </method>
118
+ </interface>
119
+ <interface name="org.freedesktop.DBus.Monitoring">
120
+ <method name="BecomeMonitor">
121
+ <arg direction="in" type="as"/>
122
+ <arg direction="in" type="u"/>
123
+ </method>
124
+ </interface>
125
+ <interface name="org.freedesktop.DBus.Peer">
126
+ <method name="GetMachineId">
127
+ <arg direction="out" type="s"/>
128
+ </method>
129
+ <method name="Ping">
130
+ </method>
131
+ </interface>
132
+ </node>