syng/syng/client.py

205 lines
5.9 KiB
Python

import asyncio
import string
import secrets
from traceback import print_exc
from json import load
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
sio: socketio.AsyncClient = socketio.AsyncClient()
logger: logging.Logger = logging.getLogger(__name__)
sources: dict[str, Source] = {}
currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
@dataclass
class State:
current_source: Optional[Source] = None
queue: list[Entry] = field(default_factory=list)
recent: list[Entry] = field(default_factory=list)
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-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])
@sio.on("state")
async def handle_state(data: dict[str, Any]) -> None:
state.queue = [Entry(**entry) for entry in data["queue"]]
state.recent = [Entry(**entry) for entry in data["recent"]]
for entry in state.queue[:2]:
logger.info("Buffering: %s", entry.title)
await sources[entry.source].buffer(entry)
@sio.on("connect")
async def handle_connect(_: dict[str, Any] = {}) -> None:
logging.info("Connected to server")
await sio.emit(
"register-client",
{
"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(),
},
)
@sio.on("buffer")
async def handle_buffer(data: dict[str, Any]) -> None:
source: Source = sources[data["source"]]
meta_info: dict[str, Any] = await source.get_missing_metadata(Entry(**data))
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)
print(
f"Playing: {entry.artist} - {entry.title} [{entry.album}] ({entry.source}) for {entry.performer}"
)
try:
state.current_source = sources[entry.source]
await preview(entry)
await sources[entry.source].play(entry)
except Exception:
print_exc()
await sio.emit("pop-then-get-next")
@sio.on("client-registered")
async def handle_register(data: dict[str, Any]) -> None:
if data["success"]:
logging.info("Registered")
print(f"Join here: {state.server}/{data['room']}")
print(pyqrcode.create(f"{state.server}/{data['room']}").terminal(quiet_zone=1))
state.room = data["room"]
await sio.emit("sources", {"sources": list(sources.keys())})
if state.current_source is None:
await sio.emit("get-first")
else:
logging.warning("Registration failed")
await sio.disconnect()
@sio.on("request-config")
async def handle_request_config(data: dict[str, Any]) -> None:
if data["source"] in sources:
config: dict[str, Any] | list[dict[str, Any]] = await sources[
data["source"]
].get_config()
if isinstance(config, list):
num_chunks: int = len(config)
for current, chunk in enumerate(config):
await sio.emit(
"config-chunk",
{
"source": data["source"],
"config": chunk,
"number": current + 1,
"total": num_chunks,
},
)
else:
await sio.emit("config", {"source": data["source"], "config": config})
async def aiomain() -> None:
parser: ArgumentParser = ArgumentParser()
parser.add_argument("--room", "-r")
parser.add_argument("--secret", "-s")
parser.add_argument("--config-file", "-C", default="syng-client.json")
parser.add_argument("server")
args = parser.parse_args()
with open(args.config_file, encoding="utf8") as file:
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
if args.secret:
state.secret = args.secret
else:
state.secret = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
print(f"Generated secret: {state.secret}")
state.server = args.server
await sio.connect(args.server)
await sio.wait()
def main() -> None:
asyncio.run(aiomain())
if __name__ == "__main__":
main()