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"
aiocmd = "^0.1.5"
pyqrcode = "^1.2.1"
pillow = "^9.3.0"
[build-system]
requires = ["poetry-core"]
@ -38,6 +38,8 @@ module = [
"minio",
"aiocmd",
"pyqrcode",
"socketio"
"socketio",
"pillow",
"PIL"
]
ignore_missing_imports = true

View file

@ -7,9 +7,12 @@ import logging
from argparse import ArgumentParser
from dataclasses import dataclass, field
from typing import Optional, Any
import tempfile
import datetime
import socketio
import pyqrcode
from PIL import Image
from .sources import Source, configure_sources
from .entry import Entry
@ -31,13 +34,21 @@ class State:
room: str = ""
server: 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()
@sio.on("skip")
async def handle_skip(_: dict[str, Any]) -> None:
@sio.on("skip-current")
async def handle_skip_current(_: dict[str, Any] = {}) -> None:
logger.info("Skipping current")
if state.current_source is not None:
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"]]
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)
@sio.on("connect")
async def handle_connect(_: dict[str, Any]) -> None:
async def handle_connect(_: dict[str, Any] = {}) -> None:
logging.info("Connected to server")
await sio.emit(
"register-client",
{
"secret": state.secret,
"queue": [entry.to_dict() for entry in state.queue],
"recent": [entry.to_dict() for entry in state.recent],
"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})
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")
async def handle_play(data: dict[str, Any]) -> None:
entry: Entry = Entry(**data)
@ -82,10 +114,10 @@ async def handle_play(data: dict[str, Any]) -> None:
)
try:
state.current_source = sources[entry.source]
await preview(entry)
await sources[entry.source].play(entry)
except Exception:
print_exc()
logging.info("Finished, waiting for next")
await sio.emit("pop-then-get-next")
@ -137,8 +169,17 @@ async def aiomain() -> None:
args = parser.parse_args()
with open(args.config_file, encoding="utf8") as file:
source_config = load(file)
sources.update(configure_sources(source_config))
config = load(file)
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:
state.room = args.room

View file

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

View file

@ -1,13 +1,14 @@
from __future__ import annotations
from collections import deque
from typing import Any, Callable
from typing import Any, Callable, Optional
import asyncio
from dataclasses import dataclass
import string
import random
import logging
from argparse import ArgumentParser
from uuid import UUID
import datetime
from aiohttp import web
import socketio
@ -21,13 +22,20 @@ sio.attach(app)
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")
async def favico_handler(_: Any) -> Any:
return web.FileResponse("syng/static/favicon.ico")
app.add_routes([web.static("/assets/", "syng/static/assets/")])
app.router.add_route("*", "/", 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)
logger = logging.getLogger(__name__)
@ -67,26 +75,51 @@ class Queue:
if locator(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
class State:
secret: str | None
sources: dict[str, Source]
sources_prio: list[str]
queue: Queue
recent: list[Entry]
sid: str
config: Config
clients: dict[str, State] = {}
@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]
async def send_state(state: State, sid: str) -> None:
await sio.emit(
"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")
async def handle_append(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session:
room = session["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)
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)
await sio.emit(
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=room,
)
await send_state(state, room)
await sio.emit(
"buffer",
@ -133,14 +193,7 @@ async def handle_meta_info(sid: str, data: dict[str, Any]) -> None:
lambda item: item.update(**data["meta"]),
)
await sio.emit(
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=room,
)
await send_state(state, room)
@sio.on("get-first")
@ -150,6 +203,7 @@ async def handle_get_first(sid: str, data: dict[str, Any] = {}) -> None:
state = clients[room]
current = await state.queue.peek()
current.started_at = datetime.datetime.now().timestamp()
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()
state.recent.append(old_entry)
await sio.emit(
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=room,
)
await send_state(state, room)
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)
@ -188,15 +238,22 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
async with sio.session(sid) as session:
session["room"] = room
print(data["config"])
if room in clients:
old_state: State = clients[room]
if data["secret"] == old_state.secret:
logger.info("Got new client connection for %s", room)
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)
await sio.emit(
"client-registered", {"success": True, "room": room}, room=sid
)
await send_state(clients[room], sid)
else:
logger.warning("Got wrong secret for %s", room)
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)
initial_entries = [Entry(**entry) for entry in data["queue"]]
initial_recent = [Entry(**entry) for entry in data["recent"]]
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)
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 send_state(clients[room], sid)
@sio.on("sources")
@ -232,13 +287,13 @@ async def handle_sources(sid: str, data: dict[str, Any]) -> None:
room = session["room"]
state = clients[room]
unused_sources = state.sources.keys() - data["sources"]
new_sources = data["sources"] - state.sources.keys()
unused_sources = state.config.sources.keys() - data["sources"]
new_sources = data["sources"] - state.config.sources.keys()
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:
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"]
state = clients[room]
if not data["source"] in state.sources:
logger.info("Added source %s", data["source"])
state.sources[data["source"]] = available_sources[data["source"]](
if not data["source"] in state.config.sources:
state.config.sources[data["source"]] = available_sources[data["source"]](
data["config"]
)
else:
state.sources[data["source"]].add_to_config(data["config"])
state.config.sources[data["source"]].add_to_config(data["config"])
@sio.on("config")
@ -265,8 +319,9 @@ async def handle_config(sid: str, data: dict[str, Any]) -> None:
room = session["room"]
state = clients[room]
state.sources[data["source"]] = available_sources[data["source"]](data["config"])
logger.info("Added source %s", data["source"])
state.config.sources[data["source"]] = available_sources[data["source"]](
data["config"]
)
@sio.on("register-web")
@ -276,14 +331,7 @@ async def handle_register_web(sid: str, data: dict[str, Any]) -> bool:
session["room"] = data["room"]
sio.enter_room(sid, session["room"])
state = clients[session["room"]]
await sio.emit(
"state",
{
"queue": state.queue.to_dict(),
"recent": [entry.to_dict() for entry in state.recent],
},
room=sid,
)
await send_state(state, sid)
return True
return False
@ -310,18 +358,47 @@ async def handle_get_config(sid: str, data: dict[str, Any]) -> None:
if is_admin:
await sio.emit(
"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")
async def handle_skip(sid: str, data: dict[str, Any] = {}) -> None:
@sio.on("skip-current")
async def handle_skip_current(sid: str, data: dict[str, Any] = {}) -> None:
async with sio.session(sid) as session:
room = session["room"]
is_admin = session["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")
@ -338,10 +415,10 @@ async def handle_search(sid: str, data: dict[str, str]) -> None:
query = data["query"]
result_futures = []
for source in state.sources_prio:
for source in state.config.sources_prio:
loop = asyncio.get_running_loop()
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)
results = [

View file

@ -5,10 +5,14 @@ from typing import Tuple, Optional, Type, Any
import os.path
from collections import defaultdict
from dataclasses import dataclass, field
import logging
from traceback import print_exc
from ..entry import Entry
from ..result import Result
logger: logging.Logger = logging.getLogger(__name__)
@dataclass
class DLFilesEntry:
@ -17,7 +21,8 @@ class DLFilesEntry:
audio: Optional[str] = None
buffering: bool = False
complete: bool = False
skip: bool = False
failed: bool = False
buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None
class Source:
@ -54,28 +59,54 @@ class Source:
async def buffer(self, entry: Entry) -> None:
async with self.masterlock:
if self.downloaded_files[entry.id].buffering:
print(f"already buffering {entry.title}")
return
self.downloaded_files[entry.id].buffering = True
video, audio = await self.doBuffer(entry)
self.downloaded_files[entry.id].video = video
self.downloaded_files[entry.id].audio = audio
self.downloaded_files[entry.id].complete = True
try:
buffer_task = asyncio.create_task(self.doBuffer(entry))
self.downloaded_files[entry.id].buffer_task = buffer_task
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()
print(f"Buffering done for {entry.title}")
async def play(self, entry: Entry) -> None:
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.downloaded_files[entry.id].video,
self.downloaded_files[entry.id].audio,
*self.extra_mpv_arguments,
)
await self.player.wait()
self.player = 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()
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>