Preparing Release

This commit is contained in:
Christoph Stahl 2022-11-29 21:44:31 +01:00
parent 3760793ed9
commit 593ee0caa6
10 changed files with 1046 additions and 87 deletions

View file

@ -21,7 +21,7 @@ minio = "^7.1.12"
mutagen = "^1.46.0" mutagen = "^1.46.0"
aiocmd = "^0.1.5" aiocmd = "^0.1.5"
pyqrcode = "^1.2.1" pyqrcode = "^1.2.1"
pillow = "^9.3.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@ -38,6 +38,8 @@ module = [
"minio", "minio",
"aiocmd", "aiocmd",
"pyqrcode", "pyqrcode",
"socketio" "socketio",
"pillow",
"PIL"
] ]
ignore_missing_imports = true ignore_missing_imports = true

View file

@ -7,9 +7,12 @@ import logging
from argparse import ArgumentParser from argparse import ArgumentParser
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, Any from typing import Optional, Any
import tempfile
import datetime
import socketio import socketio
import pyqrcode import pyqrcode
from PIL import Image
from .sources import Source, configure_sources from .sources import Source, configure_sources
from .entry import Entry from .entry import Entry
@ -31,13 +34,21 @@ class State:
room: str = "" room: str = ""
server: str = "" server: str = ""
secret: str = "" secret: str = ""
preview_duration: int = 3
last_song: Optional[datetime.datetime] = None
def get_config(self) -> dict[str, Any]:
return {
"preview_duration": self.preview_duration,
"last_song": self.last_song.timestamp() if self.last_song else None,
}
state: State = State() state: State = State()
@sio.on("skip") @sio.on("skip-current")
async def handle_skip(_: dict[str, Any]) -> None: async def handle_skip_current(_: dict[str, Any] = {}) -> None:
logger.info("Skipping current") logger.info("Skipping current")
if state.current_source is not None: if state.current_source is not None:
await state.current_source.skip_current(state.queue[0]) await state.current_source.skip_current(state.queue[0])
@ -49,20 +60,21 @@ async def handle_state(data: dict[str, Any]) -> None:
state.recent = [Entry(**entry) for entry in data["recent"]] state.recent = [Entry(**entry) for entry in data["recent"]]
for entry in state.queue[:2]: for entry in state.queue[:2]:
logger.warning(f"Buffering: %s", entry.title) logger.info("Buffering: %s", entry.title)
await sources[entry.source].buffer(entry) await sources[entry.source].buffer(entry)
@sio.on("connect") @sio.on("connect")
async def handle_connect(_: dict[str, Any]) -> None: async def handle_connect(_: dict[str, Any] = {}) -> None:
logging.info("Connected to server") logging.info("Connected to server")
await sio.emit( await sio.emit(
"register-client", "register-client",
{ {
"secret": state.secret,
"queue": [entry.to_dict() for entry in state.queue], "queue": [entry.to_dict() for entry in state.queue],
"recent": [entry.to_dict() for entry in state.recent], "recent": [entry.to_dict() for entry in state.recent],
"room": state.room, "room": state.room,
"secret": state.secret,
"config": state.get_config(),
}, },
) )
@ -74,6 +86,26 @@ async def handle_buffer(data: dict[str, Any]) -> None:
await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info}) await sio.emit("meta-info", {"uuid": data["uuid"], "meta": meta_info})
async def preview(entry: Entry) -> None:
background = Image.new("RGB", (1280, 720))
subtitle: str = f"""1
00:00:00,00 --> 00:05:00,00
{entry.artist} - {entry.title}
{entry.performer}"""
with tempfile.NamedTemporaryFile() as tmpfile:
background.save(tmpfile, "png")
process = await asyncio.create_subprocess_exec(
"mpv",
tmpfile.name,
"--image-display-duration=3",
"--sub-pos=50",
"--sub-file=-",
"--fullscreen",
stdin=asyncio.subprocess.PIPE,
)
await process.communicate(subtitle.encode())
@sio.on("play") @sio.on("play")
async def handle_play(data: dict[str, Any]) -> None: async def handle_play(data: dict[str, Any]) -> None:
entry: Entry = Entry(**data) entry: Entry = Entry(**data)
@ -82,10 +114,10 @@ async def handle_play(data: dict[str, Any]) -> None:
) )
try: try:
state.current_source = sources[entry.source] state.current_source = sources[entry.source]
await preview(entry)
await sources[entry.source].play(entry) await sources[entry.source].play(entry)
except Exception: except Exception:
print_exc() print_exc()
logging.info("Finished, waiting for next")
await sio.emit("pop-then-get-next") await sio.emit("pop-then-get-next")
@ -137,8 +169,17 @@ async def aiomain() -> None:
args = parser.parse_args() args = parser.parse_args()
with open(args.config_file, encoding="utf8") as file: with open(args.config_file, encoding="utf8") as file:
source_config = load(file) config = load(file)
sources.update(configure_sources(source_config)) sources.update(configure_sources(config["sources"]))
if "config" in config:
if "last_song" in config["config"]:
state.last_song = datetime.datetime.fromisoformat(
config["config"]["last_song"]
)
if "preview_duration" in config["config"]:
state.preview_duration = config["config"]["preview_duration"]
if args.room: if args.room:
state.room = args.room state.room = args.room

View file

@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from uuid import uuid4, UUID from uuid import uuid4, UUID
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Optional
from datetime import datetime
if TYPE_CHECKING: if TYPE_CHECKING:
from .sources import Source from .sources import Source
@ -17,7 +18,9 @@ class Entry:
album: str album: str
performer: str performer: str
failed: bool = False failed: bool = False
skip: bool = False
uuid: UUID = field(default_factory=uuid4) uuid: UUID = field(default_factory=uuid4)
started_at: Optional[float] = None
@staticmethod @staticmethod
async def from_source(performer: str, ident: str, source: Source) -> Entry: async def from_source(performer: str, ident: str, source: Source) -> Entry:
@ -33,6 +36,8 @@ class Entry:
"artist": self.artist, "artist": self.artist,
"album": self.album, "album": self.album,
"performer": self.performer, "performer": self.performer,
"skip": self.skip,
"started_at": self.started_at,
} }
@staticmethod @staticmethod

View file

@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
from collections import deque from collections import deque
from typing import Any, Callable from typing import Any, Callable, Optional
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
import string import string
import random import random
import logging import logging
from argparse import ArgumentParser from argparse import ArgumentParser
from uuid import UUID
import datetime
from aiohttp import web from aiohttp import web
import socketio import socketio
@ -21,13 +22,20 @@ sio.attach(app)
async def root_handler(request: Any) -> Any: async def root_handler(request: Any) -> Any:
if request.path.endswith("/favicon.ico"):
return web.FileResponse("syng/static/favicon.ico")
return web.FileResponse("syng/static/index.html") return web.FileResponse("syng/static/index.html")
async def favico_handler(_: Any) -> Any:
return web.FileResponse("syng/static/favicon.ico")
app.add_routes([web.static("/assets/", "syng/static/assets/")]) app.add_routes([web.static("/assets/", "syng/static/assets/")])
app.router.add_route("*", "/", root_handler) app.router.add_route("*", "/", root_handler)
app.router.add_route("*", "/{room}", root_handler) app.router.add_route("*", "/{room}", root_handler)
app.router.add_route("*", "/{room}/", root_handler) app.router.add_route("*", "/{room}/", root_handler)
app.router.add_route("*", "/favicon.ico", favico_handler)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,26 +75,51 @@ class Queue:
if locator(item): if locator(item):
updater(item) updater(item)
def find_by_uuid(self, uuid: UUID | str) -> Optional[Entry]:
for item in self._queue:
if item.uuid == uuid or str(item.uuid) == uuid:
return item
return None
async def remove(self, entry: Entry) -> None:
async with self.readlock:
await self.num_of_entries_sem.acquire()
self._queue.remove(entry)
async def moveUp(self, uuid: str) -> None:
async with self.readlock:
uuid_idx = 0
for idx, item in enumerate(self._queue):
if item.uuid == uuid or str(item.uuid) == uuid:
uuid_idx = idx
if uuid_idx > 1:
tmp = self._queue[uuid_idx]
self._queue[uuid_idx] = self._queue[uuid_idx - 1]
self._queue[uuid_idx - 1] = tmp
@dataclass
class Config:
sources: dict[str, Source]
sources_prio: list[str]
preview_duration: int
last_song: Optional[float]
@dataclass @dataclass
class State: class State:
secret: str | None secret: str | None
sources: dict[str, Source]
sources_prio: list[str]
queue: Queue queue: Queue
recent: list[Entry] recent: list[Entry]
sid: str sid: str
config: Config
clients: dict[str, State] = {} clients: dict[str, State] = {}
@sio.on("get-state") async def send_state(state: State, sid: str) -> None:
async def handle_state(sid: str, data: dict[str, Any] = {}) -> None:
async with sio.session(sid) as session:
room = session["room"]
state = clients[room]
await sio.emit( await sio.emit(
"state", "state",
{ {
@ -97,23 +130,50 @@ async def handle_state(sid: str, data: dict[str, Any] = {}) -> None:
) )
@sio.on("get-state")
async def handle_state(sid: str, data: dict[str, Any] = {}) -> None:
async with sio.session(sid) as session:
room = session["room"]
state = clients[room]
await send_state(state, sid)
@sio.on("append") @sio.on("append")
async def handle_append(sid: str, data: dict[str, Any]) -> None: async def handle_append(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session: async with sio.session(sid) as session:
room = session["room"] room = session["room"]
state = clients[room] state = clients[room]
source_obj = state.sources[data["source"]] source_obj = state.config.sources[data["source"]]
entry = await Entry.from_source(data["performer"], data["id"], source_obj) entry = await Entry.from_source(data["performer"], data["id"], source_obj)
first_song = state.queue._queue[0] if len(state.queue._queue) > 0 else None
if first_song is None or first_song.started_at is None:
start_time = datetime.datetime.now().timestamp()
else:
start_time = first_song.started_at
for item in state.queue._queue:
start_time += item.duration + state.config.preview_duration + 1
print(state.config.last_song)
print(start_time)
if state.config.last_song:
if state.config.last_song < start_time:
end_time = datetime.datetime.fromtimestamp(state.config.last_song)
await sio.emit(
"msg",
{
"msg": f"The song queue ends at {end_time.hour:02d}:{end_time.minute:02d}."
},
room=sid,
)
return
state.queue.append(entry) state.queue.append(entry)
await sio.emit( await send_state(state, room)
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=room,
)
await sio.emit( await sio.emit(
"buffer", "buffer",
@ -133,14 +193,7 @@ async def handle_meta_info(sid: str, data: dict[str, Any]) -> None:
lambda item: item.update(**data["meta"]), lambda item: item.update(**data["meta"]),
) )
await sio.emit( await send_state(state, room)
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=room,
)
@sio.on("get-first") @sio.on("get-first")
@ -150,6 +203,7 @@ async def handle_get_first(sid: str, data: dict[str, Any] = {}) -> None:
state = clients[room] state = clients[room]
current = await state.queue.peek() current = await state.queue.peek()
current.started_at = datetime.datetime.now().timestamp()
await sio.emit("play", current.to_dict(), room=sid) await sio.emit("play", current.to_dict(), room=sid)
@ -162,15 +216,11 @@ async def handle_pop_then_get_next(sid: str, data: dict[str, Any] = {}) -> None:
old_entry = await state.queue.popleft() old_entry = await state.queue.popleft()
state.recent.append(old_entry) state.recent.append(old_entry)
await sio.emit(
"state", await send_state(state, room)
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=room,
)
current = await state.queue.peek() current = await state.queue.peek()
current.started_at = datetime.datetime.now().timestamp()
await send_state(state, room)
await sio.emit("play", current.to_dict(), room=sid) await sio.emit("play", current.to_dict(), room=sid)
@ -188,15 +238,22 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session: async with sio.session(sid) as session:
session["room"] = room session["room"] = room
print(data["config"])
if room in clients: if room in clients:
old_state: State = clients[room] old_state: State = clients[room]
if data["secret"] == old_state.secret: if data["secret"] == old_state.secret:
logger.info("Got new client connection for %s", room) logger.info("Got new client connection for %s", room)
old_state.sid = sid old_state.sid = sid
old_state.config = Config(
sources=old_state.config.sources,
sources_prio=old_state.config.sources_prio,
**data["config"],
)
sio.enter_room(sid, room) sio.enter_room(sid, room)
await sio.emit( await sio.emit(
"client-registered", {"success": True, "room": room}, room=sid "client-registered", {"success": True, "room": room}, room=sid
) )
await send_state(clients[room], sid)
else: else:
logger.warning("Got wrong secret for %s", room) logger.warning("Got wrong secret for %s", room)
await sio.emit( await sio.emit(
@ -206,19 +263,17 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
logger.info("Registerd new client %s", room) logger.info("Registerd new client %s", room)
initial_entries = [Entry(**entry) for entry in data["queue"]] initial_entries = [Entry(**entry) for entry in data["queue"]]
initial_recent = [Entry(**entry) for entry in data["recent"]] initial_recent = [Entry(**entry) for entry in data["recent"]]
clients[room] = State( clients[room] = State(
data["secret"], {}, [], Queue(initial_entries), initial_recent, sid secret=data["secret"],
queue=Queue(initial_entries),
recent=initial_recent,
sid=sid,
config=Config(sources={}, sources_prio=[], **data["config"]),
) )
sio.enter_room(sid, room) sio.enter_room(sid, room)
await sio.emit(
"state",
{
"queue": clients[room].queue.to_dict(),
"recent": [entry.to_dict() for entry in clients[room].recent],
},
room=sid,
)
await sio.emit("client-registered", {"success": True, "room": room}, room=sid) await sio.emit("client-registered", {"success": True, "room": room}, room=sid)
await send_state(clients[room], sid)
@sio.on("sources") @sio.on("sources")
@ -232,13 +287,13 @@ async def handle_sources(sid: str, data: dict[str, Any]) -> None:
room = session["room"] room = session["room"]
state = clients[room] state = clients[room]
unused_sources = state.sources.keys() - data["sources"] unused_sources = state.config.sources.keys() - data["sources"]
new_sources = data["sources"] - state.sources.keys() new_sources = data["sources"] - state.config.sources.keys()
for source in unused_sources: for source in unused_sources:
del state.sources[source] del state.config.sources[source]
state.sources_prio = data["sources"] state.config.sources_prio = data["sources"]
for name in new_sources: for name in new_sources:
await sio.emit("request-config", {"source": name}, room=sid) await sio.emit("request-config", {"source": name}, room=sid)
@ -250,13 +305,12 @@ async def handle_config_chung(sid: str, data: dict[str, Any]) -> None:
room = session["room"] room = session["room"]
state = clients[room] state = clients[room]
if not data["source"] in state.sources: if not data["source"] in state.config.sources:
logger.info("Added source %s", data["source"]) state.config.sources[data["source"]] = available_sources[data["source"]](
state.sources[data["source"]] = available_sources[data["source"]](
data["config"] data["config"]
) )
else: else:
state.sources[data["source"]].add_to_config(data["config"]) state.config.sources[data["source"]].add_to_config(data["config"])
@sio.on("config") @sio.on("config")
@ -265,8 +319,9 @@ async def handle_config(sid: str, data: dict[str, Any]) -> None:
room = session["room"] room = session["room"]
state = clients[room] state = clients[room]
state.sources[data["source"]] = available_sources[data["source"]](data["config"]) state.config.sources[data["source"]] = available_sources[data["source"]](
logger.info("Added source %s", data["source"]) data["config"]
)
@sio.on("register-web") @sio.on("register-web")
@ -276,14 +331,7 @@ async def handle_register_web(sid: str, data: dict[str, Any]) -> bool:
session["room"] = data["room"] session["room"] = data["room"]
sio.enter_room(sid, session["room"]) sio.enter_room(sid, session["room"])
state = clients[session["room"]] state = clients[session["room"]]
await sio.emit( await send_state(state, sid)
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=sid,
)
return True return True
return False return False
@ -310,18 +358,47 @@ async def handle_get_config(sid: str, data: dict[str, Any]) -> None:
if is_admin: if is_admin:
await sio.emit( await sio.emit(
"config", "config",
{name: source.get_config() for name, source in state.sources.items()}, {
name: source.get_config()
for name, source in state.config.sources.items()
},
) )
@sio.on("skip") @sio.on("skip-current")
async def handle_skip(sid: str, data: dict[str, Any] = {}) -> None: async def handle_skip_current(sid: str, data: dict[str, Any] = {}) -> None:
async with sio.session(sid) as session: async with sio.session(sid) as session:
room = session["room"] room = session["room"]
is_admin = session["admin"] is_admin = session["admin"]
if is_admin: if is_admin:
await sio.emit("skip", room=clients[room].sid) await sio.emit("skip-current", room=clients[room].sid)
@sio.on("moveUp")
async def handle_moveUp(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session:
room = session["room"]
is_admin = session["admin"]
state = clients[room]
if is_admin:
await state.queue.moveUp(data["uuid"])
await send_state(state, room)
@sio.on("skip")
async def handle_skip(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session:
room = session["room"]
is_admin = session["admin"]
state = clients[room]
if is_admin:
entry = state.queue.find_by_uuid(data["uuid"])
if entry is not None:
logger.info("Skipping %s", entry)
await state.queue.remove(entry)
await send_state(state, room)
@sio.on("disconnect") @sio.on("disconnect")
@ -338,10 +415,10 @@ async def handle_search(sid: str, data: dict[str, str]) -> None:
query = data["query"] query = data["query"]
result_futures = [] result_futures = []
for source in state.sources_prio: for source in state.config.sources_prio:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
search_future = loop.create_future() search_future = loop.create_future()
loop.create_task(state.sources[source].search(search_future, query)) loop.create_task(state.config.sources[source].search(search_future, query))
result_futures.append(search_future) result_futures.append(search_future)
results = [ results = [

View file

@ -5,10 +5,14 @@ from typing import Tuple, Optional, Type, Any
import os.path import os.path
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging
from traceback import print_exc
from ..entry import Entry from ..entry import Entry
from ..result import Result from ..result import Result
logger: logging.Logger = logging.getLogger(__name__)
@dataclass @dataclass
class DLFilesEntry: class DLFilesEntry:
@ -17,7 +21,8 @@ class DLFilesEntry:
audio: Optional[str] = None audio: Optional[str] = None
buffering: bool = False buffering: bool = False
complete: bool = False complete: bool = False
skip: bool = False failed: bool = False
buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None
class Source: class Source:
@ -54,28 +59,54 @@ class Source:
async def buffer(self, entry: Entry) -> None: async def buffer(self, entry: Entry) -> None:
async with self.masterlock: async with self.masterlock:
if self.downloaded_files[entry.id].buffering: if self.downloaded_files[entry.id].buffering:
print(f"already buffering {entry.title}")
return return
self.downloaded_files[entry.id].buffering = True self.downloaded_files[entry.id].buffering = True
video, audio = await self.doBuffer(entry) try:
self.downloaded_files[entry.id].video = video buffer_task = asyncio.create_task(self.doBuffer(entry))
self.downloaded_files[entry.id].audio = audio self.downloaded_files[entry.id].buffer_task = buffer_task
self.downloaded_files[entry.id].complete = True video, audio = await buffer_task
self.downloaded_files[entry.id].video = video
self.downloaded_files[entry.id].audio = audio
self.downloaded_files[entry.id].complete = True
except Exception:
print_exc()
logger.error("Buffering failed for %s", entry)
self.downloaded_files[entry.id].failed = True
self.downloaded_files[entry.id].ready.set() self.downloaded_files[entry.id].ready.set()
print(f"Buffering done for {entry.title}")
async def play(self, entry: Entry) -> None: async def play(self, entry: Entry) -> None:
await self.ensure_playable(entry) await self.ensure_playable(entry)
if self.downloaded_files[entry.id].failed:
del self.downloaded_files[entry.id]
return
if entry.skip:
del self.downloaded_files[entry.id]
return
self.player = await self.play_mpv( self.player = await self.play_mpv(
self.downloaded_files[entry.id].video, self.downloaded_files[entry.id].video,
self.downloaded_files[entry.id].audio, self.downloaded_files[entry.id].audio,
*self.extra_mpv_arguments, *self.extra_mpv_arguments,
) )
await self.player.wait() await self.player.wait()
self.player = None
async def skip_current(self, entry: Entry) -> None: async def skip_current(self, entry: Entry) -> None:
if self.player is not None: entry.skip = True
self.downloaded_files[entry.id].buffering = False
buffer_task = self.downloaded_files[entry.id].buffer_task
if buffer_task is not None:
buffer_task.cancel()
self.downloaded_files[entry.id].ready.set()
if (
self.player is not None
): # A race condition can occur here. In that case, just press the skip button again
self.player.kill() self.player.kill()
async def ensure_playable(self, entry: Entry) -> None: async def ensure_playable(self, entry: Entry) -> None:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
syng/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

15
syng/static/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syng Rocks!</title>
<script type="module" crossorigin src="/assets/index.c3b37c18.js"></script>
<link rel="stylesheet" href="/assets/index.1ff4ae2d.css">
</head>
<body>
<div id="app"></div>
</body>
</html>