syng 2.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.
- syng/__init__.py +0 -0
- syng/__main__.py +4 -0
- syng/client.py +510 -0
- syng/entry.py +104 -0
- syng/gui.py +564 -0
- syng/jsonencoder.py +43 -0
- syng/main.py +122 -0
- syng/py.typed +0 -0
- syng/qtgui.py +519 -0
- syng/queue.py +183 -0
- syng/result.py +116 -0
- syng/server.py +1250 -0
- syng/sources/__init__.py +32 -0
- syng/sources/filebased.py +103 -0
- syng/sources/files.py +72 -0
- syng/sources/s3.py +160 -0
- syng/sources/source.py +426 -0
- syng/sources/youtube.py +411 -0
- syng/static/assets/index.01568ada.js +787 -0
- syng/static/assets/index.bc7dfd5a.css +1 -0
- syng/static/assets/syng.84d20818.png +0 -0
- syng/static/favicon.ico +0 -0
- syng/static/index.html +15 -0
- syng/static/syng.png +0 -0
- syng/static/syng.svg +105 -0
- syng/syng.qrc +6 -0
- syng/syngresource.py +2764 -0
- syng-2.0.0.dist-info/LICENSE +661 -0
- syng-2.0.0.dist-info/METADATA +172 -0
- syng-2.0.0.dist-info/RECORD +32 -0
- syng-2.0.0.dist-info/WHEEL +4 -0
- syng-2.0.0.dist-info/entry_points.txt +3 -0
syng/__init__.py
ADDED
|
File without changes
|
syng/__main__.py
ADDED
syng/client.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module for the playback client.
|
|
3
|
+
|
|
4
|
+
The client connects to the server via the socket.io protocol, and plays the
|
|
5
|
+
songs, that are sent by the server.
|
|
6
|
+
|
|
7
|
+
Playback is done by the :py:class:`syng.sources.source.Source` objects, that
|
|
8
|
+
are configured in the `sources` section of the configuration file and can currently
|
|
9
|
+
be one of:
|
|
10
|
+
- `youtube`
|
|
11
|
+
- `s3`
|
|
12
|
+
- `files`
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import asyncio
|
|
17
|
+
import datetime
|
|
18
|
+
import logging
|
|
19
|
+
from logging.handlers import QueueHandler
|
|
20
|
+
from multiprocessing import Queue
|
|
21
|
+
import secrets
|
|
22
|
+
import string
|
|
23
|
+
import tempfile
|
|
24
|
+
import signal
|
|
25
|
+
from argparse import Namespace
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from dataclasses import field
|
|
28
|
+
from traceback import print_exc
|
|
29
|
+
from typing import Any, Optional
|
|
30
|
+
|
|
31
|
+
from qrcode.main import QRCode
|
|
32
|
+
|
|
33
|
+
import socketio
|
|
34
|
+
import engineio
|
|
35
|
+
from PIL import Image
|
|
36
|
+
from yaml import load, Loader
|
|
37
|
+
|
|
38
|
+
from . import jsonencoder
|
|
39
|
+
from .entry import Entry
|
|
40
|
+
from .sources import configure_sources, Source
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder)
|
|
44
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
45
|
+
sources: dict[str, Source] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_config() -> dict[str, Optional[int | str]]:
|
|
52
|
+
"""
|
|
53
|
+
Return a default configuration for the client.
|
|
54
|
+
|
|
55
|
+
:returns: A dictionary with the default configuration.
|
|
56
|
+
:rtype: dict[str, Optional[int | str]]
|
|
57
|
+
"""
|
|
58
|
+
return {
|
|
59
|
+
"server": "https://syng.rocks",
|
|
60
|
+
"room": "",
|
|
61
|
+
"preview_duration": 3,
|
|
62
|
+
"secret": None,
|
|
63
|
+
"last_song": None,
|
|
64
|
+
"waiting_room_policy": None,
|
|
65
|
+
"key": None,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class State:
|
|
71
|
+
"""This captures the current state of the playback client.
|
|
72
|
+
|
|
73
|
+
It doubles as a backup of the state of the :py:class:`syng.server` in case
|
|
74
|
+
the server needs to be restarted.
|
|
75
|
+
|
|
76
|
+
:param current_source: This holds a reference to the
|
|
77
|
+
:py:class:`syng.sources.source.Source` object, that is currently
|
|
78
|
+
playing. If no song is played, the value is `None`.
|
|
79
|
+
:type current_source: Optional[Source]
|
|
80
|
+
:param queue: A copy of the current playlist on the server.
|
|
81
|
+
:type queue: list[Entry]
|
|
82
|
+
:param waiting_room: A copy of the waiting room on the server.
|
|
83
|
+
:type waiting_room: list[Entry]
|
|
84
|
+
:param recent: A copy of all played songs this session.
|
|
85
|
+
:type recent: list[Entry]
|
|
86
|
+
:param config: Various configuration options for the client:
|
|
87
|
+
* `server` (`str`): The url of the server to connect to.
|
|
88
|
+
* `room` (`str`): The room on the server this playback client is connected to.
|
|
89
|
+
* `secret` (`str`): The passcode of the room. If a playback client reconnects to
|
|
90
|
+
a room, this must be identical. Also, if a webclient wants to have
|
|
91
|
+
admin privileges, this must be included.
|
|
92
|
+
* `key` (`Optional[str]`) An optional key, if registration or functionality on the server
|
|
93
|
+
is limited.
|
|
94
|
+
* `preview_duration` (`Optional[int]`): The duration in seconds the
|
|
95
|
+
playback client shows a preview for the next song. This is accounted for
|
|
96
|
+
in the calculation of the ETA for songs later in the queue.
|
|
97
|
+
* `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of
|
|
98
|
+
the queue.
|
|
99
|
+
* `waiting_room_policy` (Optional[str]): One of:
|
|
100
|
+
- `forced`, if a performer is already in the queue, they are put in the
|
|
101
|
+
waiting room.
|
|
102
|
+
- `optional`, if a performer is already in the queue, they have the option
|
|
103
|
+
to be put in the waiting room.
|
|
104
|
+
- `None`, performers are always added to the queue.
|
|
105
|
+
:type config: dict[str, Any]:
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
# pylint: disable=too-many-instance-attributes
|
|
109
|
+
|
|
110
|
+
current_source: Optional[Source] = None
|
|
111
|
+
queue: list[Entry] = field(default_factory=list)
|
|
112
|
+
waiting_room: list[Entry] = field(default_factory=list)
|
|
113
|
+
recent: list[Entry] = field(default_factory=list)
|
|
114
|
+
config: dict[str, Any] = field(default_factory=default_config)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
state: State = State()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@sio.on("update_config")
|
|
121
|
+
async def handle_update_config(data: dict[str, Any]) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Handle the "update_config" message.
|
|
124
|
+
|
|
125
|
+
Currently, this function is untested and should be considered dangerous.
|
|
126
|
+
|
|
127
|
+
:param data: A dictionary with the new configuration.
|
|
128
|
+
:type data: dict[str, Any]
|
|
129
|
+
:rtype: None
|
|
130
|
+
"""
|
|
131
|
+
state.config = default_config() | data
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@sio.on("skip-current")
|
|
135
|
+
async def handle_skip_current(data: dict[str, Any]) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Handle the "skip-current" message.
|
|
138
|
+
|
|
139
|
+
Skips the song, that is currently played. If playback currently waits for
|
|
140
|
+
buffering, the buffering is also aborted.
|
|
141
|
+
|
|
142
|
+
Since the ``queue`` could already be updated, when this evaluates, the
|
|
143
|
+
first entry in the queue is send explicitly.
|
|
144
|
+
|
|
145
|
+
:param data: An entry, that should be equivalent to the first entry of the
|
|
146
|
+
queue.
|
|
147
|
+
:rtype: None
|
|
148
|
+
"""
|
|
149
|
+
logger.info("Skipping current")
|
|
150
|
+
if state.current_source is not None:
|
|
151
|
+
await state.current_source.skip_current(Entry(**data))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@sio.on("state")
|
|
155
|
+
async def handle_state(data: dict[str, Any]) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Handle the "state" message.
|
|
158
|
+
|
|
159
|
+
The "state" message forwards the current queue and recent list from the
|
|
160
|
+
server. This function saves a copy of both in the global
|
|
161
|
+
:py:class:`State`:.
|
|
162
|
+
|
|
163
|
+
After recieving the new state, a buffering task for the first elements of
|
|
164
|
+
the queue is started.
|
|
165
|
+
|
|
166
|
+
:param data: A dictionary with the `queue` and `recent` list.
|
|
167
|
+
:type data: dict[str, Any]
|
|
168
|
+
:rtype: None
|
|
169
|
+
"""
|
|
170
|
+
state.queue = [Entry(**entry) for entry in data["queue"]]
|
|
171
|
+
state.waiting_room = [Entry(**entry) for entry in data["waiting_room"]]
|
|
172
|
+
state.recent = [Entry(**entry) for entry in data["recent"]]
|
|
173
|
+
|
|
174
|
+
for entry in state.queue[:2]:
|
|
175
|
+
logger.info("Buffering: %s", entry.title)
|
|
176
|
+
await sources[entry.source].buffer(entry)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@sio.on("connect")
|
|
180
|
+
async def handle_connect() -> None:
|
|
181
|
+
"""
|
|
182
|
+
Handle the "connect" message.
|
|
183
|
+
|
|
184
|
+
Called when the client successfully connects or reconnects to the server.
|
|
185
|
+
Sends a `register-client` message to the server with the initial state and
|
|
186
|
+
configuration of the client, consiting of the currently saved
|
|
187
|
+
:py:attr:`State.queue` and :py:attr:`State.recent` field of the global
|
|
188
|
+
:py:class:`State`, as well a room code the client wants to connect to, a
|
|
189
|
+
secret to secure the access to the room and a config dictionary.
|
|
190
|
+
|
|
191
|
+
If the room code is `None`, the server will issue a room code.
|
|
192
|
+
|
|
193
|
+
This message will be handled by the
|
|
194
|
+
:py:func:`syng.server.handle_register_client` function of the server.
|
|
195
|
+
|
|
196
|
+
:rtype: None
|
|
197
|
+
"""
|
|
198
|
+
logger.info("Connected to server")
|
|
199
|
+
data = {
|
|
200
|
+
"queue": state.queue,
|
|
201
|
+
"waiting_room": state.waiting_room,
|
|
202
|
+
"recent": state.recent,
|
|
203
|
+
"config": state.config,
|
|
204
|
+
}
|
|
205
|
+
await sio.emit("register-client", data)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@sio.on("get-meta-info")
|
|
209
|
+
async def handle_get_meta_info(data: dict[str, Any]) -> None:
|
|
210
|
+
"""
|
|
211
|
+
Handle a "get-meta-info" message.
|
|
212
|
+
|
|
213
|
+
Collects the metadata for a given :py:class:`Entry`, from its source, and
|
|
214
|
+
sends them back to the server in a "meta-info" message. On the server side
|
|
215
|
+
a :py:func:`syng.server.handle_meta_info` function is called.
|
|
216
|
+
|
|
217
|
+
:param data: A dictionary encoding the entry
|
|
218
|
+
:type data: dict[str, Any]
|
|
219
|
+
:rtype: None
|
|
220
|
+
"""
|
|
221
|
+
source: Source = sources[data["source"]]
|
|
222
|
+
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
|
|
223
|
+
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def preview(entry: Entry) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Generate and play a preview for a given :py:class:`Entry`.
|
|
229
|
+
|
|
230
|
+
This function shows a black screen and prints the artist, title and
|
|
231
|
+
performer of the entry for a duration.
|
|
232
|
+
|
|
233
|
+
This is done by creating a black png file, and showing subtitles in the
|
|
234
|
+
middle of the screen.... don't ask, it works
|
|
235
|
+
|
|
236
|
+
:param entry: The entry to preview
|
|
237
|
+
:type entry: :py:class:`Entry`
|
|
238
|
+
:rtype: None
|
|
239
|
+
"""
|
|
240
|
+
background = Image.new("RGB", (1280, 720))
|
|
241
|
+
subtitle: str = f"""1
|
|
242
|
+
00:00:00,00 --> 00:05:00,00
|
|
243
|
+
{entry.artist} - {entry.title}
|
|
244
|
+
{entry.performer}"""
|
|
245
|
+
with tempfile.NamedTemporaryFile() as tmpfile:
|
|
246
|
+
background.save(tmpfile, "png")
|
|
247
|
+
process = await asyncio.create_subprocess_exec(
|
|
248
|
+
"mpv",
|
|
249
|
+
tmpfile.name,
|
|
250
|
+
f"--image-display-duration={state.config['preview_duration']}",
|
|
251
|
+
"--sub-pos=50",
|
|
252
|
+
"--sub-file=-",
|
|
253
|
+
"--fullscreen",
|
|
254
|
+
stdin=asyncio.subprocess.PIPE,
|
|
255
|
+
stdout=asyncio.subprocess.PIPE,
|
|
256
|
+
stderr=asyncio.subprocess.PIPE,
|
|
257
|
+
)
|
|
258
|
+
await process.communicate(subtitle.encode())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@sio.on("play")
|
|
262
|
+
async def handle_play(data: dict[str, Any]) -> None:
|
|
263
|
+
"""
|
|
264
|
+
Handle the "play" message.
|
|
265
|
+
|
|
266
|
+
Plays the :py:class:`Entry`, that is encoded in the `data` parameter. If a
|
|
267
|
+
:py:attr:`State.preview_duration` is set, it shows a small preview before
|
|
268
|
+
that.
|
|
269
|
+
|
|
270
|
+
When the playback is done, the next song is requested from the server with
|
|
271
|
+
a "pop-then-get-next" message. This is handled by the
|
|
272
|
+
:py:func:`syng.server.handle_pop_then_get_next` function on the server.
|
|
273
|
+
|
|
274
|
+
If the entry is marked as skipped, emit a "get-first" message instead,
|
|
275
|
+
because the server already handled the removal of the first entry.
|
|
276
|
+
|
|
277
|
+
:param data: A dictionary encoding the entry
|
|
278
|
+
:type data: dict[str, Any]
|
|
279
|
+
:rtype: None
|
|
280
|
+
"""
|
|
281
|
+
entry: Entry = Entry(**data)
|
|
282
|
+
print(
|
|
283
|
+
f"Playing: {entry.artist} - {entry.title} [{entry.album}] "
|
|
284
|
+
f"({entry.source}) for {entry.performer}"
|
|
285
|
+
)
|
|
286
|
+
try:
|
|
287
|
+
state.current_source = sources[entry.source]
|
|
288
|
+
if state.config["preview_duration"] > 0:
|
|
289
|
+
await preview(entry)
|
|
290
|
+
await sources[entry.source].play(entry)
|
|
291
|
+
except Exception: # pylint: disable=broad-except
|
|
292
|
+
print_exc()
|
|
293
|
+
state.current_source = None
|
|
294
|
+
if entry.skip:
|
|
295
|
+
await sio.emit("get-first")
|
|
296
|
+
else:
|
|
297
|
+
await sio.emit("pop-then-get-next")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@sio.on("search")
|
|
301
|
+
async def handle_search(data: dict[str, Any]) -> None:
|
|
302
|
+
"""
|
|
303
|
+
Handle the "search" message.
|
|
304
|
+
|
|
305
|
+
This handles client side search requests. It sends a search request to all
|
|
306
|
+
configured :py:class:`syng.sources.source.Source` and collects the results.
|
|
307
|
+
|
|
308
|
+
The results are then send back to the server in a "search-results" message,
|
|
309
|
+
including the `sid` of the corresponding webclient.
|
|
310
|
+
|
|
311
|
+
:param data: A dictionary with the `query` and `sid` entry.
|
|
312
|
+
:type data: dict[str, Any]
|
|
313
|
+
:rtype: None
|
|
314
|
+
"""
|
|
315
|
+
logger.info(f"Searching for: {data['query']}")
|
|
316
|
+
query = data["query"]
|
|
317
|
+
sid = data["sid"]
|
|
318
|
+
results_list = await asyncio.gather(*[source.search(query) for source in sources.values()])
|
|
319
|
+
|
|
320
|
+
results = [
|
|
321
|
+
search_result.to_dict() for source_result in results_list for search_result in source_result
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
await sio.emit("search-results", {"results": results, "sid": sid})
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@sio.on("client-registered")
|
|
328
|
+
async def handle_client_registered(data: dict[str, Any]) -> None:
|
|
329
|
+
"""
|
|
330
|
+
Handle the "client-registered" message.
|
|
331
|
+
|
|
332
|
+
If the registration was successfull (`data["success"]` == `True`), store
|
|
333
|
+
the room code in the global :py:class:`State` and print out a link to join
|
|
334
|
+
the webclient.
|
|
335
|
+
|
|
336
|
+
Start listing all configured :py:class:`syng.sources.source.Source` to the
|
|
337
|
+
server via a "sources" message. This message will be handled by the
|
|
338
|
+
:py:func:`syng.server.handle_sources` function and may request additional
|
|
339
|
+
configuration for each source.
|
|
340
|
+
|
|
341
|
+
If there is no song playing, start requesting the first song of the queue
|
|
342
|
+
with a "get-first" message. This will be handled on the server by the
|
|
343
|
+
:py:func:`syng.server.handle_get_first` function.
|
|
344
|
+
|
|
345
|
+
:param data: A dictionary containing a `success` and a `room` entry.
|
|
346
|
+
:type data: dict[str, Any]
|
|
347
|
+
:rtype: None
|
|
348
|
+
"""
|
|
349
|
+
if data["success"]:
|
|
350
|
+
logger.info("Registered")
|
|
351
|
+
print(f"Join here: {state.config['server']}/{data['room']}")
|
|
352
|
+
qr = QRCode(box_size=20, border=2)
|
|
353
|
+
qr.add_data(f"{state.config['server']}/{data['room']}")
|
|
354
|
+
qr.make()
|
|
355
|
+
qr.print_ascii()
|
|
356
|
+
state.config["room"] = data["room"]
|
|
357
|
+
await sio.emit("sources", {"sources": list(sources.keys())})
|
|
358
|
+
if state.current_source is None: # A possible race condition can occur here
|
|
359
|
+
await sio.emit("get-first")
|
|
360
|
+
else:
|
|
361
|
+
logger.warning("Registration failed")
|
|
362
|
+
await sio.disconnect()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@sio.on("request-config")
|
|
366
|
+
async def handle_request_config(data: dict[str, Any]) -> None:
|
|
367
|
+
"""
|
|
368
|
+
Handle the "request-config" message.
|
|
369
|
+
|
|
370
|
+
Sends the specific server side configuration for a given
|
|
371
|
+
:py:class:`syng.sources.source.Source`.
|
|
372
|
+
|
|
373
|
+
A Source can decide, that the config will be split up in multiple Parts.
|
|
374
|
+
If this is the case, multiple "config-chunk" messages will be send with a
|
|
375
|
+
running enumerator. Otherwise a singe "config" message will be send.
|
|
376
|
+
|
|
377
|
+
:param data: A dictionary with the entry `source` and a string, that
|
|
378
|
+
corresponds to the name of a source.
|
|
379
|
+
:type data: dict[str, Any]
|
|
380
|
+
:rtype: None
|
|
381
|
+
"""
|
|
382
|
+
if data["source"] in sources:
|
|
383
|
+
config: dict[str, Any] | list[dict[str, Any]] = await sources[data["source"]].get_config()
|
|
384
|
+
if isinstance(config, list):
|
|
385
|
+
num_chunks: int = len(config)
|
|
386
|
+
for current, chunk in enumerate(config):
|
|
387
|
+
await sio.emit(
|
|
388
|
+
"config-chunk",
|
|
389
|
+
{
|
|
390
|
+
"source": data["source"],
|
|
391
|
+
"config": chunk,
|
|
392
|
+
"number": current + 1,
|
|
393
|
+
"total": num_chunks,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
await sio.emit("config", {"source": data["source"], "config": config})
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def signal_handler() -> None:
|
|
401
|
+
"""
|
|
402
|
+
Signal handler for the client.
|
|
403
|
+
|
|
404
|
+
This function is called when the client receives a signal to terminate. It
|
|
405
|
+
will disconnect from the server and kill the current player.
|
|
406
|
+
|
|
407
|
+
:rtype: None
|
|
408
|
+
"""
|
|
409
|
+
engineio.async_client.async_signal_handler()
|
|
410
|
+
if state.current_source is not None:
|
|
411
|
+
if state.current_source.player is not None:
|
|
412
|
+
state.current_source.player.kill()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def start_client(config: dict[str, Any]) -> None:
|
|
416
|
+
"""
|
|
417
|
+
Initialize the client and connect to the server.
|
|
418
|
+
|
|
419
|
+
:param config: Config options for the client
|
|
420
|
+
:type config: dict[str, Any]
|
|
421
|
+
:rtype: None
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
sources.update(configure_sources(config["sources"]))
|
|
425
|
+
|
|
426
|
+
if "config" in config:
|
|
427
|
+
last_song = (
|
|
428
|
+
datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
|
|
429
|
+
if "last_song" in config["config"] and config["config"]["last_song"]
|
|
430
|
+
else None
|
|
431
|
+
)
|
|
432
|
+
state.config |= config["config"] | {"last_song": last_song}
|
|
433
|
+
|
|
434
|
+
if not ("secret" in state.config and state.config["secret"]):
|
|
435
|
+
state.config["secret"] = "".join(
|
|
436
|
+
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
|
|
437
|
+
)
|
|
438
|
+
print(f"Generated secret: {state.config['secret']}")
|
|
439
|
+
|
|
440
|
+
if not ("key" in state.config and state.config["key"]):
|
|
441
|
+
state.config["key"] = ""
|
|
442
|
+
|
|
443
|
+
await sio.connect(state.config["server"])
|
|
444
|
+
|
|
445
|
+
asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler)
|
|
446
|
+
asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler)
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
await sio.wait()
|
|
450
|
+
except asyncio.CancelledError:
|
|
451
|
+
pass
|
|
452
|
+
finally:
|
|
453
|
+
if state.current_source is not None:
|
|
454
|
+
if state.current_source.player is not None:
|
|
455
|
+
state.current_source.player.kill()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def create_async_and_start_client(
|
|
459
|
+
config: dict[str, Any], queue: Optional[Queue[logging.LogRecord]] = None
|
|
460
|
+
) -> None:
|
|
461
|
+
"""
|
|
462
|
+
Create an asyncio event loop and start the client.
|
|
463
|
+
|
|
464
|
+
If a multiprocessing queue is given, the client will log to the queue.
|
|
465
|
+
|
|
466
|
+
:param config: Config options for the client
|
|
467
|
+
:type config: dict[str, Any]
|
|
468
|
+
:param queue: A multiprocessing queue to log to
|
|
469
|
+
:type queue: Optional[Queue[LogRecord]]
|
|
470
|
+
:rtype: None
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
if queue is not None:
|
|
474
|
+
logger.addHandler(QueueHandler(queue))
|
|
475
|
+
|
|
476
|
+
asyncio.run(start_client(config))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def run_client(args: Namespace) -> None:
|
|
480
|
+
"""
|
|
481
|
+
Run the client with the given arguments.
|
|
482
|
+
|
|
483
|
+
Namespace contains the following attributes:
|
|
484
|
+
- room: The room code to connect to
|
|
485
|
+
- secret: The secret to connect to the room
|
|
486
|
+
- config_file: The path to the configuration file
|
|
487
|
+
- key: The key to connect to the server
|
|
488
|
+
- server: The url of the server to connect to
|
|
489
|
+
|
|
490
|
+
:param args: The arguments from the command line
|
|
491
|
+
:type args: Namespace
|
|
492
|
+
:rtype: None
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
with open(args.config_file, encoding="utf8") as file:
|
|
496
|
+
config = load(file, Loader=Loader)
|
|
497
|
+
except FileNotFoundError:
|
|
498
|
+
config = {}
|
|
499
|
+
|
|
500
|
+
if "config" not in config:
|
|
501
|
+
config["config"] = {}
|
|
502
|
+
|
|
503
|
+
if args.room:
|
|
504
|
+
config["config"] |= {"room": args.room}
|
|
505
|
+
if args.secret:
|
|
506
|
+
config["config"] |= {"secret": args.secret}
|
|
507
|
+
if args.server:
|
|
508
|
+
config["config"] |= {"server": args.server}
|
|
509
|
+
|
|
510
|
+
create_async_and_start_client(config)
|
syng/entry.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Module for the entry of the queue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import field
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Entry:
|
|
16
|
+
"""This represents a song in the queue.
|
|
17
|
+
|
|
18
|
+
:param ident: An identifier, that uniquely identifies the song in its
|
|
19
|
+
source.
|
|
20
|
+
:type ident: str
|
|
21
|
+
:param source: The name of the source, this will be played from.
|
|
22
|
+
:type source: str
|
|
23
|
+
:param duration: The duration of the song in seconds.
|
|
24
|
+
:type duration: int
|
|
25
|
+
:param title: The title of the song.
|
|
26
|
+
:type title: Optional[str]
|
|
27
|
+
:param artist: The name of the original artist.
|
|
28
|
+
:type artist: Optional[str]
|
|
29
|
+
:param album: The name of the album or compilation, this particular
|
|
30
|
+
version is from.
|
|
31
|
+
:type album: str
|
|
32
|
+
:param performer: The person, that will sing this song.
|
|
33
|
+
:type performer: str
|
|
34
|
+
:param failed: A flag, that indecates, that something went wrong. E.g.
|
|
35
|
+
buffering was canceled, the file could not be read from disc etc.
|
|
36
|
+
The exact meaning can differ from source to source. Default is false.
|
|
37
|
+
:type failed: bool
|
|
38
|
+
:param skip: A flag indicating, that this song is marked for skipping.
|
|
39
|
+
:type skip: bool
|
|
40
|
+
:param uuid: The UUID, that identifies this exact entry in the queue.
|
|
41
|
+
Will be automatically assigned on creation.
|
|
42
|
+
:type uuid: UUID
|
|
43
|
+
:param uid: ID of the user that added this song to the queue.
|
|
44
|
+
:type uid: Optional[str]
|
|
45
|
+
:param started_at: The timestamp this entry began playing. ``None``, if it
|
|
46
|
+
is yet to be played.
|
|
47
|
+
:type started_at: Optional[float]
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# pylint: disable=too-many-instance-attributes
|
|
51
|
+
|
|
52
|
+
ident: str
|
|
53
|
+
source: str
|
|
54
|
+
duration: int
|
|
55
|
+
title: Optional[str]
|
|
56
|
+
artist: Optional[str]
|
|
57
|
+
album: str
|
|
58
|
+
performer: str
|
|
59
|
+
failed: bool = False
|
|
60
|
+
skip: bool = False
|
|
61
|
+
uuid: UUID = field(default_factory=uuid4)
|
|
62
|
+
uid: Optional[str] = None
|
|
63
|
+
started_at: Optional[float] = None
|
|
64
|
+
|
|
65
|
+
def update(self, **kwargs: Any) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Update the attributes with given substitutions.
|
|
68
|
+
|
|
69
|
+
:param \\*\\*kwargs: Keywords taken from the list of attributes.
|
|
70
|
+
:type \\*\\*kwargs: Any
|
|
71
|
+
:rtype: None
|
|
72
|
+
"""
|
|
73
|
+
self.__dict__.update(kwargs)
|
|
74
|
+
|
|
75
|
+
def shares_performer(self, other_performer: str) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Check if this entry shares a performer with another entry.
|
|
78
|
+
|
|
79
|
+
:param other_performer: The performer to check against.
|
|
80
|
+
:type other_performer: str
|
|
81
|
+
:return: True if the performers intersect, False otherwise.
|
|
82
|
+
:rtype: bool
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def normalize(performers: str) -> set[str]:
|
|
86
|
+
return set(
|
|
87
|
+
filter(
|
|
88
|
+
lambda x: len(x) > 0 and x not in ["der", "die", "das", "alle", "und"],
|
|
89
|
+
re.sub(
|
|
90
|
+
r"[^a-zA-Z0-9\s]",
|
|
91
|
+
"",
|
|
92
|
+
re.sub(
|
|
93
|
+
r"\s",
|
|
94
|
+
" ",
|
|
95
|
+
performers.lower().replace(".", " ").replace(",", " "),
|
|
96
|
+
),
|
|
97
|
+
).split(" "),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
e1_split_names = normalize(self.performer)
|
|
102
|
+
e2_split_names = normalize(other_performer)
|
|
103
|
+
|
|
104
|
+
return len(e1_split_names.intersection(e2_split_names)) > 0
|