Merge branch 'gui' into 'main'

Implemented GUI for syng

See merge request christofsteel/syng2!2
This commit is contained in:
Christoph Stahl 2023-12-18 18:48:28 +00:00
commit d19f974e02
26 changed files with 1577 additions and 1052 deletions

View file

@ -1,9 +1,12 @@
image: python:3-alpine
variables:
MYPYPATH: "stubs/"
mypy:
stage: test
script:
- pip install mypy --quiet
- pip install mypy types-Pillow types-PyYAML --quiet
- mypy syng --strict
ruff:

View file

@ -0,0 +1 @@
# Syng

View file

@ -6,6 +6,7 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os
import sys
import sphinx_rtd_theme
sys.path.insert(0, os.path.abspath(".."))
@ -17,7 +18,7 @@ release = "2.0.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ["sphinx.ext.autodoc"]
extensions = ["sphinx.ext.autodoc", "sphinx_rtd_theme"]
templates_path = ["_templates"]
exclude_patterns = []
@ -26,5 +27,5 @@ exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "alabaster"
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]

View file

@ -1,5 +1,5 @@
JSON
====
.. automodule:: syng.json
.. automodule:: syng.jsonencoder
:members:

View file

@ -10,10 +10,11 @@ include = ["syng/static"]
[tool.poetry.scripts]
syng-client = "syng.client:main"
syng-server = "syng.server:main"
syng-gui = "syng.gui:main"
syng-shell = "syng.webclientmockup:main"
[tool.poetry.dependencies]
python = "^3.7"
python = "^3.8"
pytube = "*"
aiohttp = "^3.8.3"
python-socketio = "^5.7.2"
@ -22,7 +23,16 @@ mutagen = "^1.46.0"
aiocmd = "^0.1.5"
pillow = "^9.3.0"
yt-dlp = "*"
pyqrcodeng = "^1.3.6"
customtkinter = "^5.2.1"
qrcode = "^7.4.2"
pymediainfo = "^6.1.0"
pyyaml = "^6.0.1"
async-tkinter-loop = "^0.9.2"
tkcalendar = "^1.6.1"
tktimepicker = "^2.0.2"
types-pyyaml = "^6.0.12.12"
types-pillow = "^10.1.0.2"
platformdirs = "^4.0.0"
[build-system]
requires = ["poetry-core"]
@ -33,17 +43,23 @@ exclude = [ ".venv" ]
venvPath = "."
venv = ".venv"
[tool.pylint."MESSAGES CONTROL"]
disable = '''too-many-lines,
too-many-ancestors
'''
[[tool.mypy.overrides]]
module = [
"aiohttp",
"pytube",
"minio",
"aiocmd",
"pyqrcodeng",
"socketio",
"pillow",
"PIL",
"yt_dlp",
"pymediainfo",
"minio",
"qrcode",
"engineio",
"tkcalendar",
"customtkinter",
"aiohttp",
"tktimepicker",
"platformdirs"
]
ignore_missing_imports = true

View file

@ -16,19 +16,16 @@ Excerp from the help::
--config-file CONFIG_FILE, -C CONFIG_FILE
--key KEY, -k KEY
The config file should be a json file in the following style::
The config file should be a yaml file in the following style::
{
"sources": {
"SOURCE1": { configuration for SOURCE },
"SOURCE2": { configuration for SOURCE },
sources:
SOURCE1:
configuration for SOURCE
SOURCE2:
configuration for SOURCE
...
},
},
"config": {
config:
configuration for the client
}
}
"""
import asyncio
import datetime
@ -36,25 +33,26 @@ import logging
import secrets
import string
import tempfile
import signal
from argparse import ArgumentParser
from dataclasses import dataclass
from dataclasses import field
from json import load
from traceback import print_exc
from typing import Any
from typing import Optional
from typing import Any, Optional
import qrcode
import pyqrcodeng as pyqrcode
import socketio
import engineio
from PIL import Image
from yaml import load, Loader
from . import json
from . import jsonencoder
from .entry import Entry
from .sources import configure_sources
from .sources import Source
from .sources import configure_sources, Source
sio: socketio.AsyncClient = socketio.AsyncClient(json=json)
sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder)
logger: logging.Logger = logging.getLogger(__name__)
sources: dict[str, Source] = {}
@ -64,7 +62,10 @@ currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
def default_config() -> dict[str, Optional[int | str]]:
return {
"server": "http://localhost:8080",
"room": "ABCD",
"preview_duration": 3,
"secret": None,
"last_song": None,
"waiting_room_policy": None,
}
@ -87,22 +88,20 @@ class State:
:type waiting_room: list[Entry]
:param recent: A copy of all played songs this session.
:type recent: list[Entry]
:param room: The room on the server this playback client is connected to.
:type room: str
:param secret: The passcode of the room. If a playback client reconnects to
:param config: Various configuration options for the client:
* `server` (`str`): The url of the server to connect to.
* `room` (`str`): The room on the server this playback client is connected to.
* `secret` (`str`): The passcode of the room. If a playback client reconnects to
a room, this must be identical. Also, if a webclient wants to have
admin privileges, this must be included.
:type secret: str
:param key: An optional key, if registration on the server is limited.
:type key: Optional[str]
:param config: Various configuration options for the client:
* `key` (`Optional[str]`) An optional key, if registration on the server is limited.
* `preview_duration` (`Optional[int]`): The duration in seconds the
playback client shows a preview for the next song. This is accounted for
in the calculation of the ETA for songs later in the queue.
* `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of
the queue.
* `waiting_room_policy` (Optional[str]): One of:
- `force`, if a performer is already in the queue, they are put in the
- `forced`, if a performer is already in the queue, they are put in the
waiting room.
- `optional`, if a performer is already in the queue, they have the option
to be put in the waiting room.
@ -116,10 +115,6 @@ class State:
queue: list[Entry] = field(default_factory=list)
waiting_room: list[Entry] = field(default_factory=list)
recent: list[Entry] = field(default_factory=list)
room: str = ""
server: str = ""
secret: str = ""
key: Optional[str] = None
config: dict[str, Any] = field(default_factory=default_config)
@ -200,12 +195,8 @@ async def handle_connect() -> None:
"queue": state.queue,
"waiting_room": state.waiting_room,
"recent": state.recent,
"room": state.room,
"secret": state.secret,
"config": state.config,
}
if state.key:
data["registration-key"] = state.key
await sio.emit("register-client", data)
@ -304,7 +295,7 @@ async def handle_play(data: dict[str, Any]) -> None:
@sio.on("client-registered")
async def handle_client_registered(data: dict[str, Any]) -> None:
"""
Handle the "client-registered" massage.
Handle the "client-registered" message.
If the registration was successfull (`data["success"]` == `True`), store
the room code in the global :py:class:`State` and print out a link to join
@ -325,9 +316,12 @@ async def handle_client_registered(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"]
print(f"Join here: {state.config['server']}/{data['room']}")
qr = qrcode.QRCode(box_size=20, border=2)
qr.add_data(f"{state.config['server']}/{data['room']}")
qr.make()
qr.print_ascii()
state.config["room"] = data["room"]
await sio.emit("sources", {"sources": list(sources.keys())})
if state.current_source is None: # A possible race condition can occur here
await sio.emit("get-first")
@ -354,9 +348,7 @@ async def handle_request_config(data: dict[str, Any]) -> None:
:rtype: None
"""
if data["source"] in sources:
config: dict[str, Any] | list[dict[str, Any]] = await sources[
data["source"]
].get_config()
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):
@ -373,62 +365,87 @@ async def handle_request_config(data: dict[str, Any]) -> None:
await sio.emit("config", {"source": data["source"], "config": config})
async def aiomain() -> None:
def signal_handler() -> None:
engineio.async_client.async_signal_handler()
if state.current_source is not None:
if state.current_source.player is not None:
state.current_source.player.kill()
async def start_client(config: dict[str, Any]) -> None:
"""
Async main function.
Parses the arguments, reads a config file and sets default values. Then
connects to a specified server.
If no secret is given, a random secret will be generated and presented to
the user.
Initialize the client and connect to the server.
:param config: Config options for the client
:type config: dict[str, Any]
:rtype: 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("--key", "-k", default=None)
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:
last_song = (
datetime.datetime.fromisoformat(config["config"]["last_song"])
if "last_song" in config["config"]
datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
if "last_song" in config["config"] and config["config"]["last_song"]
else None
)
state.config |= config["config"] | {"last_song": last_song}
state.key = args.key if args.key else None
if args.room:
state.room = args.room
if args.secret:
state.secret = args.secret
else:
state.secret = "".join(
if not ("secret" in state.config and state.config["secret"]):
state.config["secret"] = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
print(f"Generated secret: {state.secret}")
print(f"Generated secret: {state.config['secret']}")
state.server = args.server
if not ("key" in state.config and state.config["key"]):
state.config["key"] = ""
await sio.connect(args.server)
await sio.connect(state.config["server"])
asyncio.get_event_loop().add_signal_handler(signal.SIGINT, signal_handler)
asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, signal_handler)
try:
await sio.wait()
except asyncio.CancelledError:
pass
finally:
if state.current_source is not None:
if state.current_source.player is not None:
state.current_source.player.kill()
def create_async_and_start_client(config: dict[str, Any]) -> None:
asyncio.run(start_client(config))
def main() -> None:
"""Entry point for the syng-client script."""
asyncio.run(aiomain())
parser: ArgumentParser = ArgumentParser()
parser.add_argument("--room", "-r")
parser.add_argument("--secret", "-s")
parser.add_argument("--config-file", "-C", default="syng-client.yaml")
parser.add_argument("--key", "-k", default=None)
parser.add_argument("--server", "-S")
args = parser.parse_args()
with open(args.config_file, encoding="utf8") as file:
config = load(file, Loader=Loader)
if "config" not in config:
config["config"] = {}
config["config"] |= {"key": args.key}
if args.room:
config["config"] |= {"room": args.room}
if args.secret:
config["config"] |= {"secret": args.secret}
if args.server:
config["config"] |= {"server": args.server}
create_async_and_start_client(config)
if __name__ == "__main__":

462
syng/gui.py Normal file
View file

@ -0,0 +1,462 @@
from multiprocessing import Process
from collections.abc import Callable
from datetime import datetime, date, time
import os
import builtins
from functools import partial
from typing import Any, Optional
import webbrowser
import multiprocessing
import secrets
import string
from PIL import ImageTk
from yaml import dump, load, Loader, Dumper
import customtkinter
from qrcode import QRCode
from tkcalendar import Calendar
from tktimepicker import AnalogPicker, AnalogThemes, constants
import platformdirs
from .client import create_async_and_start_client, default_config
from .sources import available_sources
from .server import main as server_main
class DateAndTimePickerWindow(customtkinter.CTkToplevel): # type: ignore
def __init__(
self,
parent: customtkinter.CTkFrame | customtkinter.CTkScrollableFrame,
input_field: customtkinter.CTkTextbox,
) -> None:
super().__init__(parent)
try:
iso_string = input_field.get("0.0", "end").strip()
selected = datetime.fromisoformat(iso_string)
except ValueError:
selected = datetime.now()
self.calendar = Calendar(self)
self.calendar.pack(
expand=True,
fill="both",
)
self.timepicker = AnalogPicker(
self,
type=constants.HOURS12,
period=constants.AM if selected.hour < 12 else constants.PM,
)
theme = AnalogThemes(self.timepicker)
theme.setDracula()
self.calendar.selection_set(selected)
self.timepicker.setHours(selected.hour % 12)
self.timepicker.setMinutes(selected.minute)
self.timepicker.pack(expand=True, fill="both")
button = customtkinter.CTkButton(self, text="Ok", command=partial(self.insert, input_field))
button.pack(expand=True, fill="x")
def insert(self, input_field: customtkinter.CTkTextbox) -> None:
input_field.delete("0.0", "end")
selected_date = self.calendar.selection_get()
if not isinstance(selected_date, date):
return
hours, minutes, ampm = self.timepicker.time()
hours = hours % 12
if ampm == "PM":
hours = hours + 12
selected_datetime = datetime.combine(selected_date, time(hours, minutes))
input_field.insert("0.0", selected_datetime.isoformat())
self.withdraw()
self.destroy()
class OptionFrame(customtkinter.CTkScrollableFrame): # type:ignore
def add_option_label(self, text: str) -> None:
customtkinter.CTkLabel(self, text=text, justify="left").grid(
column=0, row=self.number_of_options, padx=5, pady=5, sticky="ne"
)
def add_bool_option(self, name: str, description: str, value: bool = False) -> None:
self.add_option_label(description)
self.bool_options[name] = customtkinter.CTkCheckBox(
self,
text="",
onvalue=True,
offvalue=False,
)
if value:
self.bool_options[name].select()
else:
self.bool_options[name].deselect()
self.bool_options[name].grid(column=1, row=self.number_of_options, sticky="EW")
self.number_of_options += 1
def add_string_option(
self,
name: str,
description: str,
value: str = "",
callback: Optional[Callable[..., None]] = None,
) -> None:
self.add_option_label(description)
if value is None:
value = ""
self.string_options[name] = customtkinter.CTkTextbox(self, wrap="none", height=1)
self.string_options[name].grid(column=1, row=self.number_of_options, sticky="EW")
self.string_options[name].insert("0.0", value)
if callback is not None:
self.string_options[name].bind("<KeyRelease>", callback)
self.string_options[name].bind("<ButtonRelease>", callback)
self.number_of_options += 1
def del_list_element(
self,
name: str,
element: customtkinter.CTkTextbox,
frame: customtkinter.CTkFrame,
) -> None:
self.list_options[name].remove(element)
frame.destroy()
def add_list_element(
self,
name: str,
frame: customtkinter.CTkFrame,
init: str,
callback: Optional[Callable[..., None]],
) -> None:
input_and_minus = customtkinter.CTkFrame(frame)
input_and_minus.pack(side="top", fill="x", expand=True)
input_field = customtkinter.CTkTextbox(input_and_minus, wrap="none", height=1)
input_field.pack(side="left", fill="x", expand=True)
input_field.insert("0.0", init)
if callback is not None:
input_field.bind("<KeyRelease>", callback)
input_field.bind("<ButtonRelease>", callback)
minus_button = customtkinter.CTkButton(
input_and_minus,
text="-",
width=40,
command=partial(self.del_list_element, name, input_field, input_and_minus),
)
minus_button.pack(side="right")
self.list_options[name].append(input_field)
def add_list_option(
self,
name: str,
description: str,
value: list[str],
callback: Optional[Callable[..., None]] = None,
) -> None:
self.add_option_label(description)
frame = customtkinter.CTkFrame(self)
frame.grid(column=1, row=self.number_of_options, sticky="EW")
self.list_options[name] = []
for v in value:
self.add_list_element(name, frame, v, callback)
plus_button = customtkinter.CTkButton(
frame,
text="+",
command=partial(self.add_list_element, name, frame, "", callback),
)
plus_button.pack(side="bottom", fill="x", expand=True)
self.number_of_options += 1
def add_choose_option(
self, name: str, description: str, values: list[str], value: str = ""
) -> None:
self.add_option_label(description)
self.choose_options[name] = customtkinter.CTkOptionMenu(self, values=values)
self.choose_options[name].grid(column=1, row=self.number_of_options, sticky="EW")
self.choose_options[name].set(value)
self.number_of_options += 1
def open_date_and_time_picker(self, name: str, input_field: customtkinter.CTkTextbox) -> None:
if (
name not in self.date_and_time_pickers
or not self.date_and_time_pickers[name].winfo_exists()
):
self.date_and_time_pickers[name] = DateAndTimePickerWindow(self, input_field)
else:
self.date_and_time_pickers[name].focus()
def add_date_time_option(self, name: str, description: str, value: str) -> None:
self.add_option_label(description)
input_and_button = customtkinter.CTkFrame(self)
input_and_button.grid(column=1, row=self.number_of_options, sticky="EW")
input_field = customtkinter.CTkTextbox(input_and_button, wrap="none", height=1)
input_field.pack(side="left", fill="x", expand=True)
self.date_time_options[name] = input_field
try:
datetime.fromisoformat(value)
except TypeError:
value = ""
input_field.insert("0.0", value)
button = customtkinter.CTkButton(
input_and_button,
text="...",
width=40,
command=partial(self.open_date_and_time_picker, name, input_field),
)
button.pack(side="right")
self.number_of_options += 1
def __init__(self, parent: customtkinter.CTkFrame) -> None:
super().__init__(parent)
self.columnconfigure((1,), weight=1)
self.number_of_options: int = 0
self.string_options: dict[str, customtkinter.CTkTextbox] = {}
self.choose_options: dict[str, customtkinter.CTkOptionMenu] = {}
self.bool_options: dict[str, customtkinter.CTkCheckBox] = {}
self.list_options: dict[str, list[customtkinter.CTkTextbox]] = {}
self.date_time_options: dict[str, customtkinter.CTkTextbox] = {}
self.date_and_time_pickers: dict[str, DateAndTimePickerWindow] = {}
def get_config(self) -> dict[str, Any]:
config: dict[str, Any] = {}
for name, textbox in self.string_options.items():
config[name] = textbox.get("0.0", "end").strip()
for name, optionmenu in self.choose_options.items():
config[name] = optionmenu.get().strip()
for name, checkbox in self.bool_options.items():
config[name] = checkbox.get() == 1
for name, textboxes in self.list_options.items():
config[name] = []
for textbox in textboxes:
config[name].append(textbox.get("0.0", "end").strip())
for name, picker in self.date_time_options.items():
config[name] = picker.get("0.0", "end").strip()
return config
class SourceTab(OptionFrame):
def __init__(
self, parent: customtkinter.CTkFrame, source_name: str, config: dict[str, Any]
) -> None:
super().__init__(parent)
source = available_sources[source_name]
self.vars: dict[str, str | bool | list[str]] = {}
for name, (typ, desc, default) in source.config_schema.items():
value = config[name] if name in config else default
match typ:
case builtins.bool:
self.add_bool_option(name, desc, value=value)
case builtins.list:
self.add_list_option(name, desc, value=value)
case builtins.str:
self.add_string_option(name, desc, value=value)
class GeneralConfig(OptionFrame):
def __init__(
self,
parent: customtkinter.CTkFrame,
config: dict[str, Any],
callback: Callable[..., None],
) -> None:
super().__init__(parent)
self.add_string_option("server", "Server", config["server"], callback)
self.add_string_option("room", "Room", config["room"], callback)
self.add_string_option("secret", "Secret", config["secret"])
self.add_choose_option(
"waiting_room_policy",
"Waiting room policy",
["forced", "optional", "none"],
str(config["waiting_room_policy"]).lower(),
)
self.add_date_time_option("last_song", "Time of last song", config["last_song"])
self.add_string_option("preview_duration", "Preview Duration", config["preview_duration"])
def get_config(self) -> dict[str, Any]:
config = super().get_config()
try:
config["preview_duration"] = int(config["preview_duration"])
except ValueError:
config["preview_duration"] = 0
return config
class SyngGui(customtkinter.CTk): # type:ignore
def on_close(self) -> None:
if self.syng_server is not None:
self.syng_server.kill()
if self.syng_client is not None:
self.syng_client.kill()
self.withdraw()
self.destroy()
def __init__(self) -> None:
super().__init__(className="Syng")
self.protocol("WM_DELETE_WINDOW", self.on_close)
rel_path = os.path.dirname(__file__)
img = ImageTk.PhotoImage(file=os.path.join(rel_path, "static/syng.png"))
self.wm_iconbitmap()
self.iconphoto(False, img)
self.syng_server: Optional[Process] = None
self.syng_client: Optional[Process] = None
self.configfile = os.path.join(platformdirs.user_config_dir("syng"), "config.yaml")
try:
with open(self.configfile, encoding="utf8") as cfile:
loaded_config = load(cfile, Loader=Loader)
except FileNotFoundError:
print("No config found, using default values")
loaded_config = {}
config: dict[str, dict[str, Any]] = {"sources": {}, "config": default_config()}
try:
config["config"] |= loaded_config["config"]
except (KeyError, TypeError):
print("Could not load config")
# if "config" in loaded_config:
# config["config"] |= loaded_config["config"]
if not config["config"]["secret"]:
config["config"]["secret"] = "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
)
self.wm_title("Syng")
# Buttons
button_line = customtkinter.CTkFrame(self)
button_line.pack(side="bottom", fill="x")
startsyng_serverbutton = customtkinter.CTkButton(
button_line, text="Start Local Server", command=self.start_syng_server
)
startsyng_serverbutton.pack(side="left", expand=True, anchor="w", padx=10, pady=5)
savebutton = customtkinter.CTkButton(button_line, text="Save", command=self.save_config)
savebutton.pack(side="left", padx=10, pady=5)
open_web_button = customtkinter.CTkButton(
button_line, text="Open Web", command=self.open_web
)
open_web_button.pack(side="left", pady=5)
self.startbutton = customtkinter.CTkButton(
button_line, text="Save and Start", command=self.start_syng_client
)
self.startbutton.pack(side="left", padx=10, pady=10)
# Tabs and QR Code
frm = customtkinter.CTkFrame(self)
frm.pack(ipadx=10, padx=10, fill="both", expand=True)
tabview = customtkinter.CTkTabview(frm, width=600, height=500)
tabview.pack(side="right", padx=10, pady=10, fill="both", expand=True)
tabview.add("General")
for source in available_sources:
tabview.add(source)
tabview.set("General")
self.qrlabel = customtkinter.CTkLabel(frm, text="")
self.qrlabel.pack(side="left", anchor="n", padx=10, pady=10)
self.general_config = GeneralConfig(
tabview.tab("General"), config["config"], self.update_qr
)
self.general_config.pack(ipadx=10, fill="both", expand=True)
self.tabs = {}
for source_name in available_sources:
try:
source_config = loaded_config["sources"][source_name]
except (KeyError, TypeError):
source_config = {}
self.tabs[source_name] = SourceTab(tabview.tab(source_name), source_name, source_config)
self.tabs[source_name].pack(ipadx=10, expand=True, fill="both")
self.update_qr()
def save_config(self) -> None:
with open(self.configfile, "w", encoding="utf-8") as f:
dump(self.gather_config(), f, Dumper=Dumper)
def gather_config(self) -> dict[str, Any]:
sources = {}
for source, tab in self.tabs.items():
sources[source] = tab.get_config()
general_config = self.general_config.get_config()
return {"sources": sources, "config": general_config}
def start_syng_client(self) -> None:
if self.syng_client is None:
config = self.gather_config()
self.syng_client = multiprocessing.Process(
target=create_async_and_start_client, args=(config,)
)
self.syng_client.start()
self.startbutton.configure(text="Stop")
else:
self.syng_client.terminate()
self.syng_client = None
self.startbutton.configure(text="Save and Start")
def start_syng_server(self) -> None:
self.syng_server = multiprocessing.Process(target=server_main)
self.syng_server.start()
def open_web(self) -> None:
config = self.general_config.get_config()
syng_server = config["server"]
syng_server += "" if syng_server.endswith("/") else "/"
room = config["room"]
webbrowser.open(syng_server + room)
def change_qr(self, data: str) -> None:
qr = QRCode(box_size=20, border=2)
qr.add_data(data)
qr.make()
qr.print_ascii()
image = qr.make_image().convert("RGB")
tk_qrcode = customtkinter.CTkImage(light_image=image, size=(280, 280))
self.qrlabel.configure(image=tk_qrcode)
def update_qr(self, _evt: None = None) -> None:
config = self.general_config.get_config()
syng_server = config["server"]
syng_server += "" if syng_server.endswith("/") else "/"
room = config["room"]
print(syng_server + room)
self.change_qr(syng_server + room)
def main() -> None:
SyngGui().mainloop()
if __name__ == "__main__":
main()

View file

@ -32,14 +32,14 @@ from typing import Optional
import socketio
from aiohttp import web
from . import json
from . import jsonencoder
from .entry import Entry
from .queue import Queue
from .sources import available_sources
from .sources import Source
sio = socketio.AsyncServer(
cors_allowed_origins="*", logger=True, engineio_logger=False, json=json
cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder
)
app = web.Application()
sio.attach(app)
@ -90,7 +90,7 @@ class Client:
in the calculation of the ETA for songs later in the queue.
* `last_song` (`Optional[float]`): A timestamp, defining the end of the queue.
* `waiting_room_policy` (Optional[str]): One of:
- `force`, if a performer is already in the queue, they are put in the
- `forced`, if a performer is already in the queue, they are put in the
waiting room.
- `optional`, if a performer is already in the queue, they have the option
to be put in the waiting room.
@ -127,15 +127,12 @@ class State:
:type client: Client
"""
secret: str
queue: Queue
waiting_room: list[Entry]
recent: list[Entry]
sid: str
client: Client
last_seen: datetime.datetime = field(
init=False, default_factory=datetime.datetime.now
)
last_seen: datetime.datetime = field(init=False, default_factory=datetime.datetime.now)
clients: dict[str, State] = {}
@ -157,13 +154,17 @@ async def send_state(state: State, sid: str) -> None:
:type sid: str:
:rtype: None
"""
safe_config = {k: v for k, v in state.client.config.items() if k not in ["secret", "key"]}
print(safe_config)
await sio.emit(
"state",
{
"queue": state.queue,
"recent": state.recent,
"waiting_room": state.waiting_room,
"config": state.client.config,
"config": safe_config,
},
room=sid,
)
@ -206,18 +207,13 @@ async def handle_waiting_room_append(sid: str, data: dict[str, Any]) -> None:
if entry is None:
await sio.emit(
"msg",
{
"msg": f"Unable to add to the waiting room: {data['ident']}. Maybe try again?"
},
{"msg": f"Unable to add to the waiting room: {data['ident']}. Maybe try again?"},
room=sid,
)
return
if "uid" not in data or (
(
data["uid"] is not None
and len(list(state.queue.find_by_uid(data["uid"]))) == 0
)
(data["uid"] is not None and len(list(state.queue.find_by_uid(data["uid"]))) == 0)
or (data["uid"] is None and state.queue.find_by_name(data["performer"]) is None)
):
await append_to_queue(room, entry, sid)
@ -234,9 +230,7 @@ async def handle_waiting_room_append(sid: str, data: dict[str, Any]) -> None:
)
async def append_to_queue(
room: str, entry: Entry, report_to: Optional[str] = None
) -> None:
async def append_to_queue(room: str, entry: Entry, report_to: Optional[str] = None) -> None:
"""
Append a song to the queue for a given session.
@ -260,10 +254,7 @@ async def append_to_queue(
start_time = first_song.started_at
start_time = state.queue.fold(
lambda item, time: time
+ item.duration
+ state.client.config["preview_duration"]
+ 1,
lambda item, time: time + item.duration + state.client.config["preview_duration"] + 1,
start_time,
)
@ -329,7 +320,7 @@ async def handle_update_config(sid: str, data: dict[str, Any]) -> None:
if is_admin:
try:
config = json.loads(data["config"])
config = jsonencoder.loads(data["config"])
await sio.emit(
"update_config",
DEFAULT_CONFIG | config,
@ -385,7 +376,7 @@ async def handle_append(sid: str, data: dict[str, Any]) -> None:
state = clients[room]
if state.client.config["waiting_room_policy"] and (
state.client.config["waiting_room_policy"].lower() == "force"
state.client.config["waiting_room_policy"].lower() == "forced"
or state.client.config["waiting_room_policy"].lower() == "optional"
):
old_entry = state.queue.find_by_name(data["performer"])
@ -438,7 +429,7 @@ async def handle_append_anyway(sid: str, data: dict[str, Any]) -> None:
room = session["room"]
state = clients[room]
if state.client.config["waiting_room_policy"].lower() == "force":
if state.client.config["waiting_room_policy"].lower() == "forced":
await sio.emit(
"err",
{"type": "WAITING_ROOM_FORCED"},
@ -547,11 +538,7 @@ async def handle_waiting_room_to_queue(sid: str, data: dict[str, Any]) -> None:
if is_admin:
entry = next(
(
wr_entry
for wr_entry in state.waiting_room
if str(wr_entry.uuid) == data["uuid"]
),
(wr_entry for wr_entry in state.waiting_room if str(wr_entry.uuid) == data["uuid"]),
None,
)
if entry is not None:
@ -683,22 +670,19 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
"""
def gen_id(length: int = 4) -> str:
client_id = "".join(
[random.choice(string.ascii_letters) for _ in range(length)]
)
client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
if client_id in clients:
client_id = gen_id(length + 1)
return client_id
if not app["public"]:
with open(app["registration-keyfile"]) as f:
with open(app["registration-keyfile"], encoding="utf8") as f:
raw_keys = f.readlines()
keys = [key[:64] for key in raw_keys]
if (
"registration-key" not in data
or hashlib.sha256(data["registration-key"].encode()).hexdigest()
not in keys
"key" not in data["config"]
or hashlib.sha256(data["config"]["key"].encode()).hexdigest() not in keys
):
await sio.emit(
"client-registered",
@ -707,13 +691,15 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
)
return
room: str = data["room"] if "room" in data and data["room"] else gen_id()
room: str = (
data["config"]["room"] if "room" in data["config"] and data["config"]["room"] else gen_id()
)
async with sio.session(sid) as session:
session["room"] = room
if room in clients:
old_state: State = clients[room]
if data["secret"] == old_state.secret:
if data["config"]["secret"] == old_state.client.config["secret"]:
logger.info("Got new client connection for %s", room)
old_state.sid = sid
old_state.client = Client(
@ -722,15 +708,11 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
config=DEFAULT_CONFIG | data["config"],
)
await sio.enter_room(sid, room)
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)
else:
logger.warning("Got wrong secret for %s", room)
await sio.emit(
"client-registered", {"success": False, "room": room}, room=sid
)
await sio.emit("client-registered", {"success": False, "room": room}, room=sid)
else:
logger.info("Registerd new client %s", room)
initial_entries = [Entry(**entry) for entry in data["queue"]]
@ -738,7 +720,6 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
initial_recent = [Entry(**entry) for entry in data["recent"]]
clients[room] = State(
secret=data["secret"],
queue=Queue(initial_entries),
waiting_room=initial_waiting_room,
recent=initial_recent,
@ -822,9 +803,7 @@ async def handle_config_chunk(sid: str, data: dict[str, Any]) -> None:
return
if data["source"] not in state.client.sources:
state.client.sources[data["source"]] = available_sources[data["source"]](
data["config"]
)
state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
else:
state.client.sources[data["source"]].add_to_config(data["config"])
@ -853,9 +832,7 @@ async def handle_config(sid: str, data: dict[str, Any]) -> None:
if sid != state.sid:
return
state.client.sources[data["source"]] = available_sources[data["source"]](
data["config"]
)
state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
@sio.on("register-web")
@ -902,7 +879,7 @@ async def handle_register_admin(sid: str, data: dict[str, Any]) -> bool:
room = session["room"]
state = clients[room]
is_admin: bool = data["secret"] == state.secret
is_admin: bool = data["secret"] == state.client.config["secret"]
async with sio.session(sid) as session:
session["admin"] = is_admin
return is_admin
@ -1040,17 +1017,10 @@ async def handle_search(sid: str, data: dict[str, Any]) -> None:
query = data["query"]
results_list = await asyncio.gather(
*[
state.client.sources[source].search(query)
for source in state.client.sources_prio
]
*[state.client.sources[source].search(query) for source in state.client.sources_prio]
)
results = [
search_result
for source_result in results_list
for search_result in source_result
]
results = [search_result for source_result in results_list for search_result in source_result]
await sio.emit(
"search-results",
{"results": results},
@ -1117,7 +1087,7 @@ def main() -> None:
"""
parser = ArgumentParser()
parser.add_argument("--host", "-H", default="localhost")
parser.add_argument("--port", "-p", default="8080")
parser.add_argument("--port", "-p", type=int, default=8080)
parser.add_argument("--root-folder", "-r", default="syng/static/")
parser.add_argument("--registration-keyfile", "-k", default=None)
args = parser.parse_args()
@ -1129,9 +1099,7 @@ def main() -> None:
app["root_folder"] = args.root_folder
app.add_routes(
[web.static("/assets/", os.path.join(app["root_folder"], "assets/"))]
)
app.add_routes([web.static("/assets/", os.path.join(app["root_folder"], "assets/"))])
app.router.add_route("*", "/", root_handler)
app.router.add_route("*", "/{room}", root_handler)

View file

@ -26,5 +26,6 @@ def configure_sources(configs: dict[str, Any]) -> dict[str, Source]:
configured_sources = {}
for source, config in configs.items():
if source in available_sources:
if "enabled" in config and config["enabled"]:
configured_sources[source] = available_sources[source](config)
return configured_sources

77
syng/sources/filebased.py Normal file
View file

@ -0,0 +1,77 @@
"""Module for an abstract filebased Source."""
import asyncio
import os
from typing import Any, Optional
from pymediainfo import MediaInfo
from .source import Source
class FileBasedSource(Source):
"""A source for indexing and playing songs from a local folder.
Config options are:
-``dir``, dirctory to index and server from.
"""
config_schema = Source.config_schema | {
"extensions": (
list,
"List of filename extensions\n(mp3+cdg, mp4, ...)",
["mp3+cdg"],
)
}
def __init__(self, config: dict[str, Any]):
"""Initialize the file module."""
super().__init__(config)
self.extensions: list[str] = (
config["extensions"] if "extensions" in config else ["mp3+cdg"]
)
self.extra_mpv_arguments = ["--scale=oversample"]
def has_correct_extension(self, path: str) -> bool:
"""Check if a `path` has a correct extension.
For A+B type extensions (like mp3+cdg) only the latter halve is checked
:return: True iff path has correct extension.
:rtype: bool
"""
return os.path.splitext(path)[1][1:] in [
ext.split("+")[-1] for ext in self.extensions
]
def get_video_audio_split(self, path: str) -> tuple[str, Optional[str]]:
extension_of_path = os.path.splitext(path)[1][1:]
splitted_extensions = [ext.split("+") for ext in self.extensions if "+" in ext]
splitted_extensions_dict = {
video: audio for [audio, video] in splitted_extensions
}
if extension_of_path in splitted_extensions_dict:
audio_path = (
os.path.splitext(path)[0]
+ "."
+ splitted_extensions_dict[extension_of_path]
)
return (path, audio_path)
return (path, None)
async def get_duration(self, path: str) -> int:
def _get_duration(file: str) -> int:
print(file)
info: str | MediaInfo = MediaInfo.parse(file)
if isinstance(info, str):
return 180
duration: int = info.audio_tracks[0].to_data()["duration"]
return duration // 1000
video_path, audio_path = self.get_video_audio_split(path)
check_path = audio_path if audio_path is not None else video_path
duration = await asyncio.to_thread(_get_duration, check_path)
return duration

View file

@ -1,39 +1,42 @@
"""Module for the files Source."""
import asyncio
import os
from typing import Any
from typing import Any, Optional
from typing import Tuple
import mutagen
from ..entry import Entry
from .source import available_sources
from .source import Source
from .filebased import FileBasedSource
class FilesSource(Source):
class FilesSource(FileBasedSource):
"""A source for indexing and playing songs from a local folder.
Config options are:
-``dir``, dirctory to index and server from.
-``dir``, dirctory to index and serve from.
"""
source_name = "files"
config_schema = FileBasedSource.config_schema | {
"dir": (str, "Directory to index", "."),
"index_file": (str, "Index file", "files-index"),
}
def __init__(self, config: dict[str, Any]):
"""Initialize the file module."""
super().__init__(config)
self.source_name = "files"
self.dir = config["dir"] if "dir" in config else "."
self.extra_mpv_arguments = ["--scale=oversample"]
async def get_file_list(self) -> list[str]:
"""Collect all ``cdg`` files in ``dir``."""
"""Collect all files in ``dir``, that have the correct filename extension"""
def _get_file_list() -> list[str]:
file_list = []
for path, _, files in os.walk(self.dir):
for file in files:
if file.endswith(".cdg"):
if self.has_correct_extension(file):
file_list.append(os.path.join(path, file)[len(self.dir) :])
return file_list
@ -41,35 +44,27 @@ class FilesSource(Source):
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]:
"""
Return the duration for the mp3 file.
Return the duration for the entry file.
:param entry: The entry with the associated mp3 file
:param entry: An entry
:type entry: Entry
:return: A dictionary containing the duration in seconds in the
``duration`` key.
:rtype: dict[str, Any]
"""
def mutagen_wrapped(file: str) -> int:
meta_infos = mutagen.File(file).info
return int(meta_infos.length)
duration = await self.get_duration(os.path.join(self.dir, entry.ident))
audio_file_name: str = os.path.join(self.dir, entry.ident[:-3] + "mp3")
return {"duration": duration}
duration = await asyncio.to_thread(mutagen_wrapped, audio_file_name)
return {"duration": int(duration)}
async def do_buffer(self, entry: Entry) -> Tuple[str, str]:
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
No buffering needs to be done, since the files are already on disk.
We just return the cdg file name and the inferred mp3 file name
We just return the file names.
"""
video_file_name: str = os.path.join(self.dir, entry.ident)
audio_file_name: str = os.path.join(self.dir, entry.ident[:-3] + "mp3")
return video_file_name, audio_file_name
return self.get_video_audio_split(os.path.join(self.dir, entry.ident))
available_sources["files"] = FilesSource

View file

@ -12,15 +12,15 @@ from typing import cast
from typing import Optional
from typing import Tuple
import mutagen
from minio import Minio
from .filebased import FileBasedSource
from ..entry import Entry
from .source import available_sources
from .source import Source
class S3Source(Source):
class S3Source(FileBasedSource):
"""A source for playing songs from a s3 compatible storage.
Config options are:
@ -31,14 +31,22 @@ class S3Source(Source):
- ``index_file``: If the file does not exist, saves the paths of
files from the s3 instance to this file. If it exists, loads
the list of files from this file.
- ``extensions``: List of filename extensions. Index only files with these one
of these extensions (Default: ["cdg"])
"""
source_name = "s3"
config_schema = FileBasedSource.config_schema | {
"endpoint": (str, "Endpoint of the s3", ""),
"access_key": (str, "Access Key of the s3", ""),
"secret_key": (str, "Secret Key of the s3", ""),
"secure": (bool, "Use SSL", True),
"bucket": (str, "Bucket of the s3", ""),
"tmp_dir": (str, "Folder for\ntemporary download", "/tmp/syng"),
"index_file": (str, "Index file", "s3-index"),
}
def __init__(self, config: dict[str, Any]):
"""Create the source."""
super().__init__(config)
self.source_name = "s3"
if "endpoint" in config and "access_key" in config and "secret_key" in config:
self.minio: Minio = Minio(
@ -52,12 +60,6 @@ class S3Source(Source):
config["tmp_dir"] if "tmp_dir" in config else "/tmp/syng"
)
self.extensions = (
[f".{ext}" for ext in config["extensions"]]
if "extensions" in config
else [".cdg"]
)
self.index_file: Optional[str] = (
config["index_file"] if "index_file" in config else None
)
@ -83,7 +85,7 @@ class S3Source(Source):
file_list = [
obj.object_name
for obj in self.minio.list_objects(self.bucket, recursive=True)
if os.path.splitext(obj.object_name)[1] in self.extensions
if self.has_correct_extension(obj.object_name)
]
if self.index_file is not None and not os.path.isfile(self.index_file):
with open(self.index_file, "w", encoding="utf8") as index_file_handle:
@ -103,20 +105,13 @@ class S3Source(Source):
:rtype: dict[str, Any]
"""
def mutagen_wrapped(file: str) -> int:
meta_infos = mutagen.File(file).info
return int(meta_infos.length)
await self.ensure_playable(entry)
audio_file_name: Optional[str] = self.downloaded_files[entry.ident].audio
file_name: str = self.downloaded_files[entry.ident].video
if audio_file_name is None:
duration: int = 180
else:
duration = await asyncio.to_thread(mutagen_wrapped, audio_file_name)
duration = await self.get_duration(file_name)
return {"duration": int(duration)}
return {"duration": duration}
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
@ -132,56 +127,31 @@ class S3Source(Source):
:rtype: Tuple[str, Optional[str]]
"""
if os.path.splitext(entry.ident)[1] == ".cdg":
cdg_filename: str = os.path.basename(entry.ident)
path_to_files: str = os.path.dirname(entry.ident)
cdg_path: str = os.path.join(path_to_files, cdg_filename)
target_file_cdg: str = os.path.join(self.tmp_dir, cdg_path)
ident_mp3: str = entry.ident[:-3] + "mp3"
target_file_mp3: str = target_file_cdg[:-3] + "mp3"
os.makedirs(os.path.dirname(target_file_cdg), exist_ok=True)
cdg_task: asyncio.Task[Any] = asyncio.create_task(
video_path, audio_path = self.get_video_audio_split(entry.ident)
video_dl_path: str = os.path.join(self.tmp_dir, video_path)
os.makedirs(os.path.dirname(video_dl_path), exist_ok=True)
video_dl_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
entry.ident,
target_file_cdg,
self.minio.fget_object, self.bucket, entry.ident, video_dl_path
)
)
audio_task: asyncio.Task[Any] = asyncio.create_task(
if audio_path is not None:
audio_dl_path: Optional[str] = os.path.join(self.tmp_dir, audio_path)
audio_dl_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
ident_mp3,
target_file_mp3,
self.minio.fget_object, self.bucket, audio_path, audio_dl_path
)
)
else:
audio_dl_path = None
audio_dl_task = asyncio.create_task(asyncio.sleep(0))
await cdg_task
await audio_task
return target_file_cdg, target_file_mp3
video_filename: str = os.path.basename(entry.ident)
path_to_file: str = os.path.dirname(entry.ident)
await video_dl_task
await audio_dl_task
video_path: str = os.path.join(path_to_file, video_filename)
target_file_video: str = os.path.join(self.tmp_dir, video_path)
os.makedirs(os.path.dirname(target_file_video), exist_ok=True)
video_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
entry.ident,
target_file_video,
)
)
await video_task
return target_file_video, None
return video_dl_path, audio_dl_path
available_sources["s3"] = S3Source

View file

@ -19,6 +19,7 @@ from typing import Any
from typing import Optional
from typing import Tuple
from typing import Type
from abc import ABC, abstractmethod
from ..entry import Entry
from ..result import Result
@ -65,7 +66,7 @@ class DLFilesEntry:
buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None
class Source:
class Source(ABC):
"""Parentclass for all sources.
A new source should subclass this, and at least implement
@ -103,6 +104,11 @@ class Source:
- ``source_name``, the string used to identify the source
"""
source_name: str = ""
config_schema: dict[str, tuple[type | list[type], str, Any]] = {
"enabled": (bool, "Enable this source", False)
}
def __init__(self, config: dict[str, Any]):
"""
Create and initialize a new source.
@ -114,10 +120,7 @@ class Source:
source for documentation.
:type config: dict[str, Any]
"""
self.source_name: str = ""
self.downloaded_files: defaultdict[str, DLFilesEntry] = defaultdict(
DLFilesEntry
)
self.downloaded_files: defaultdict[str, DLFilesEntry] = defaultdict(DLFilesEntry)
self._masterlock: asyncio.Lock = asyncio.Lock()
self.player: Optional[asyncio.subprocess.Process] = None
self._index: list[str] = config["index"] if "index" in config else []
@ -140,9 +143,9 @@ class Source:
:returns: An async reference to the process
:rtype: asyncio.subprocess.Process
"""
args = ["--fullscreen", *options, video] + (
[f"--audio-file={audio}"] if audio else []
)
args = ["--fullscreen", *options, video] + ([f"--audio-file={audio}"] if audio else [])
print(f"File is {video=} and {audio=}")
mpv_process = asyncio.create_subprocess_exec(
"mpv",
@ -207,6 +210,7 @@ class Source:
results.append(result)
return results
@abstractmethod
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
"""
Source specific part of buffering.
@ -223,7 +227,6 @@ class Source:
:returns: A Tuple of the locations for the video and the audio file.
:rtype: Tuple[str, Optional[str]]
"""
raise NotImplementedError
async def buffer(self, entry: Entry) -> None:
"""

View file

@ -50,12 +50,23 @@ class YoutubeSource(Source):
``yt-dlp``. Default is False.
"""
source_name = "youtube"
config_schema = Source.config_schema | {
"channels": (list, "A list channels\nto search in", []),
"tmp_dir": (str, "Folder for\ntemporary download", "/tmp/syng"),
"max_res": (int, "Maximum resolution\nto download", 720),
"start_streaming": (
bool,
"Start streaming if\ndownload is not complete",
False,
),
}
# pylint: disable=too-many-instance-attributes
def __init__(self, config: dict[str, Any]):
"""Create the source."""
super().__init__(config)
self.source_name = "youtube"
self.innertube_client: innertube.InnerTube = innertube.InnerTube(client="WEB")
self.channels: list[str] = config["channels"] if "channels" in config else []

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,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.8572a105.js"></script>
<link rel="stylesheet" href="/assets/index.9110b89b.css">
<script type="module" crossorigin src="/assets/index.20e81f9f.js"></script>
<link rel="stylesheet" href="/assets/index.b030f504.css">
</head>
<body>
<div id="app"></div>

BIN
syng/static/syng.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB