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 image: python:3-alpine
variables:
MYPYPATH: "stubs/"
mypy: mypy:
stage: test stage: test
script: script:
- pip install mypy --quiet - pip install mypy types-Pillow types-PyYAML --quiet
- mypy syng --strict - mypy syng --strict
ruff: 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 # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import os import os
import sys import sys
import sphinx_rtd_theme
sys.path.insert(0, os.path.abspath("..")) sys.path.insert(0, os.path.abspath(".."))
@ -17,7 +18,7 @@ release = "2.0.0"
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#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"] templates_path = ["_templates"]
exclude_patterns = [] exclude_patterns = []
@ -26,5 +27,5 @@ exclude_patterns = []
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#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"] html_static_path = ["_static"]

View file

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

View file

@ -10,10 +10,11 @@ include = ["syng/static"]
[tool.poetry.scripts] [tool.poetry.scripts]
syng-client = "syng.client:main" syng-client = "syng.client:main"
syng-server = "syng.server:main" syng-server = "syng.server:main"
syng-gui = "syng.gui:main"
syng-shell = "syng.webclientmockup:main" syng-shell = "syng.webclientmockup:main"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.8"
pytube = "*" pytube = "*"
aiohttp = "^3.8.3" aiohttp = "^3.8.3"
python-socketio = "^5.7.2" python-socketio = "^5.7.2"
@ -22,7 +23,16 @@ mutagen = "^1.46.0"
aiocmd = "^0.1.5" aiocmd = "^0.1.5"
pillow = "^9.3.0" pillow = "^9.3.0"
yt-dlp = "*" 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] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@ -33,17 +43,23 @@ exclude = [ ".venv" ]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"
[tool.pylint."MESSAGES CONTROL"]
disable = '''too-many-lines,
too-many-ancestors
'''
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"aiohttp",
"pytube",
"minio",
"aiocmd",
"pyqrcodeng",
"socketio",
"pillow",
"PIL",
"yt_dlp", "yt_dlp",
"pymediainfo",
"minio",
"qrcode",
"engineio",
"tkcalendar",
"customtkinter",
"aiohttp",
"tktimepicker",
"platformdirs"
] ]
ignore_missing_imports = true ignore_missing_imports = true

View file

@ -16,19 +16,16 @@ Excerp from the help::
--config-file CONFIG_FILE, -C CONFIG_FILE --config-file CONFIG_FILE, -C CONFIG_FILE
--key KEY, -k KEY --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:
"sources": { SOURCE1:
"SOURCE1": { configuration for SOURCE }, configuration for SOURCE
"SOURCE2": { configuration for SOURCE }, SOURCE2:
configuration for SOURCE
... ...
}, config:
},
"config": {
configuration for the client configuration for the client
}
}
""" """
import asyncio import asyncio
import datetime import datetime
@ -36,25 +33,26 @@ import logging
import secrets import secrets
import string import string
import tempfile import tempfile
import signal
from argparse import ArgumentParser from argparse import ArgumentParser
from dataclasses import dataclass from dataclasses import dataclass
from dataclasses import field from dataclasses import field
from json import load
from traceback import print_exc from traceback import print_exc
from typing import Any from typing import Any, Optional
from typing import Optional
import qrcode
import pyqrcodeng as pyqrcode
import socketio import socketio
import engineio
from PIL import Image from PIL import Image
from yaml import load, Loader
from . import json from . import jsonencoder
from .entry import Entry from .entry import Entry
from .sources import configure_sources from .sources import configure_sources, Source
from .sources import Source
sio: socketio.AsyncClient = socketio.AsyncClient(json=json) sio: socketio.AsyncClient = socketio.AsyncClient(json=jsonencoder)
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
sources: dict[str, Source] = {} sources: dict[str, Source] = {}
@ -64,7 +62,10 @@ currentLock: asyncio.Semaphore = asyncio.Semaphore(0)
def default_config() -> dict[str, Optional[int | str]]: def default_config() -> dict[str, Optional[int | str]]:
return { return {
"server": "http://localhost:8080",
"room": "ABCD",
"preview_duration": 3, "preview_duration": 3,
"secret": None,
"last_song": None, "last_song": None,
"waiting_room_policy": None, "waiting_room_policy": None,
} }
@ -87,22 +88,20 @@ class State:
:type waiting_room: list[Entry] :type waiting_room: list[Entry]
:param recent: A copy of all played songs this session. :param recent: A copy of all played songs this session.
:type recent: list[Entry] :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
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: :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.
* `key` (`Optional[str]`) An optional key, if registration on the server is limited.
* `preview_duration` (`Optional[int]`): The duration in seconds the * `preview_duration` (`Optional[int]`): The duration in seconds the
playback client shows a preview for the next song. This is accounted for 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. in the calculation of the ETA for songs later in the queue.
* `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of * `last_song` (`Optional[datetime.datetime]`): A timestamp, defining the end of
the queue. the queue.
* `waiting_room_policy` (Optional[str]): One of: * `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. waiting room.
- `optional`, if a performer is already in the queue, they have the option - `optional`, if a performer is already in the queue, they have the option
to be put in the waiting room. to be put in the waiting room.
@ -116,10 +115,6 @@ class State:
queue: list[Entry] = field(default_factory=list) queue: list[Entry] = field(default_factory=list)
waiting_room: list[Entry] = field(default_factory=list) waiting_room: list[Entry] = field(default_factory=list)
recent: 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) config: dict[str, Any] = field(default_factory=default_config)
@ -200,12 +195,8 @@ async def handle_connect() -> None:
"queue": state.queue, "queue": state.queue,
"waiting_room": state.waiting_room, "waiting_room": state.waiting_room,
"recent": state.recent, "recent": state.recent,
"room": state.room,
"secret": state.secret,
"config": state.config, "config": state.config,
} }
if state.key:
data["registration-key"] = state.key
await sio.emit("register-client", data) await sio.emit("register-client", data)
@ -304,7 +295,7 @@ async def handle_play(data: dict[str, Any]) -> None:
@sio.on("client-registered") @sio.on("client-registered")
async def handle_client_registered(data: dict[str, Any]) -> None: 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 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 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"]: if data["success"]:
logging.info("Registered") logging.info("Registered")
print(f"Join here: {state.server}/{data['room']}") print(f"Join here: {state.config['server']}/{data['room']}")
print(pyqrcode.create(f"{state.server}/{data['room']}").terminal(quiet_zone=1)) qr = qrcode.QRCode(box_size=20, border=2)
state.room = data["room"] 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())}) await sio.emit("sources", {"sources": list(sources.keys())})
if state.current_source is None: # A possible race condition can occur here if state.current_source is None: # A possible race condition can occur here
await sio.emit("get-first") await sio.emit("get-first")
@ -354,9 +348,7 @@ async def handle_request_config(data: dict[str, Any]) -> None:
:rtype: None :rtype: None
""" """
if data["source"] in sources: if data["source"] in sources:
config: dict[str, Any] | list[dict[str, Any]] = await sources[ config: dict[str, Any] | list[dict[str, Any]] = await sources[data["source"]].get_config()
data["source"]
].get_config()
if isinstance(config, list): if isinstance(config, list):
num_chunks: int = len(config) num_chunks: int = len(config)
for current, chunk in enumerate(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}) 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. Initialize the client and connect to the server.
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.
:param config: Config options for the client
:type config: dict[str, Any]
:rtype: None :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"])) sources.update(configure_sources(config["sources"]))
if "config" in config: if "config" in config:
last_song = ( last_song = (
datetime.datetime.fromisoformat(config["config"]["last_song"]) datetime.datetime.fromisoformat(config["config"]["last_song"]).timestamp()
if "last_song" in config["config"] if "last_song" in config["config"] and config["config"]["last_song"]
else None else None
) )
state.config |= config["config"] | {"last_song": last_song} state.config |= config["config"] | {"last_song": last_song}
state.key = args.key if args.key else None if not ("secret" in state.config and state.config["secret"]):
state.config["secret"] = "".join(
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) 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"])
await sio.wait()
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: def main() -> None:
"""Entry point for the syng-client script.""" """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__": 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 import socketio
from aiohttp import web from aiohttp import web
from . import json from . import jsonencoder
from .entry import Entry from .entry import Entry
from .queue import Queue from .queue import Queue
from .sources import available_sources from .sources import available_sources
from .sources import Source from .sources import Source
sio = socketio.AsyncServer( 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() app = web.Application()
sio.attach(app) sio.attach(app)
@ -90,7 +90,7 @@ class Client:
in the calculation of the ETA for songs later in the queue. in the calculation of the ETA for songs later in the queue.
* `last_song` (`Optional[float]`): A timestamp, defining the end of the queue. * `last_song` (`Optional[float]`): A timestamp, defining the end of the queue.
* `waiting_room_policy` (Optional[str]): One of: * `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. waiting room.
- `optional`, if a performer is already in the queue, they have the option - `optional`, if a performer is already in the queue, they have the option
to be put in the waiting room. to be put in the waiting room.
@ -127,15 +127,12 @@ class State:
:type client: Client :type client: Client
""" """
secret: str
queue: Queue queue: Queue
waiting_room: list[Entry] waiting_room: list[Entry]
recent: list[Entry] recent: list[Entry]
sid: str sid: str
client: Client client: Client
last_seen: datetime.datetime = field( last_seen: datetime.datetime = field(init=False, default_factory=datetime.datetime.now)
init=False, default_factory=datetime.datetime.now
)
clients: dict[str, State] = {} clients: dict[str, State] = {}
@ -157,13 +154,17 @@ async def send_state(state: State, sid: str) -> None:
:type sid: str: :type sid: str:
:rtype: None :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( await sio.emit(
"state", "state",
{ {
"queue": state.queue, "queue": state.queue,
"recent": state.recent, "recent": state.recent,
"waiting_room": state.waiting_room, "waiting_room": state.waiting_room,
"config": state.client.config, "config": safe_config,
}, },
room=sid, room=sid,
) )
@ -206,18 +207,13 @@ async def handle_waiting_room_append(sid: str, data: dict[str, Any]) -> None:
if entry is None: if entry is None:
await sio.emit( await sio.emit(
"msg", "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, room=sid,
) )
return return
if "uid" not in data or ( 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) or (data["uid"] is None and state.queue.find_by_name(data["performer"]) is None)
): ):
await append_to_queue(room, entry, sid) 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( async def append_to_queue(room: str, entry: Entry, report_to: Optional[str] = None) -> None:
room: str, entry: Entry, report_to: Optional[str] = None
) -> None:
""" """
Append a song to the queue for a given session. 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 = first_song.started_at
start_time = state.queue.fold( start_time = state.queue.fold(
lambda item, time: time lambda item, time: time + item.duration + state.client.config["preview_duration"] + 1,
+ item.duration
+ state.client.config["preview_duration"]
+ 1,
start_time, start_time,
) )
@ -329,7 +320,7 @@ async def handle_update_config(sid: str, data: dict[str, Any]) -> None:
if is_admin: if is_admin:
try: try:
config = json.loads(data["config"]) config = jsonencoder.loads(data["config"])
await sio.emit( await sio.emit(
"update_config", "update_config",
DEFAULT_CONFIG | config, DEFAULT_CONFIG | config,
@ -385,7 +376,7 @@ async def handle_append(sid: str, data: dict[str, Any]) -> None:
state = clients[room] state = clients[room]
if state.client.config["waiting_room_policy"] and ( 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" or state.client.config["waiting_room_policy"].lower() == "optional"
): ):
old_entry = state.queue.find_by_name(data["performer"]) 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"] room = session["room"]
state = clients[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( await sio.emit(
"err", "err",
{"type": "WAITING_ROOM_FORCED"}, {"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: if is_admin:
entry = next( 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, None,
) )
if entry is not 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: def gen_id(length: int = 4) -> str:
client_id = "".join( client_id = "".join([random.choice(string.ascii_letters) for _ in range(length)])
[random.choice(string.ascii_letters) for _ in range(length)]
)
if client_id in clients: if client_id in clients:
client_id = gen_id(length + 1) client_id = gen_id(length + 1)
return client_id return client_id
if not app["public"]: if not app["public"]:
with open(app["registration-keyfile"]) as f: with open(app["registration-keyfile"], encoding="utf8") as f:
raw_keys = f.readlines() raw_keys = f.readlines()
keys = [key[:64] for key in raw_keys] keys = [key[:64] for key in raw_keys]
if ( if (
"registration-key" not in data "key" not in data["config"]
or hashlib.sha256(data["registration-key"].encode()).hexdigest() or hashlib.sha256(data["config"]["key"].encode()).hexdigest() not in keys
not in keys
): ):
await sio.emit( await sio.emit(
"client-registered", "client-registered",
@ -707,13 +691,15 @@ async def handle_register_client(sid: str, data: dict[str, Any]) -> None:
) )
return 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: async with sio.session(sid) as session:
session["room"] = room session["room"] = room
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["config"]["secret"] == old_state.client.config["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.client = Client( 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"], config=DEFAULT_CONFIG | data["config"],
) )
await sio.enter_room(sid, room) await 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) 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("client-registered", {"success": False, "room": room}, room=sid)
"client-registered", {"success": False, "room": room}, room=sid
)
else: else:
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"]]
@ -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"]] initial_recent = [Entry(**entry) for entry in data["recent"]]
clients[room] = State( clients[room] = State(
secret=data["secret"],
queue=Queue(initial_entries), queue=Queue(initial_entries),
waiting_room=initial_waiting_room, waiting_room=initial_waiting_room,
recent=initial_recent, recent=initial_recent,
@ -822,9 +803,7 @@ async def handle_config_chunk(sid: str, data: dict[str, Any]) -> None:
return return
if data["source"] not in state.client.sources: if data["source"] not in state.client.sources:
state.client.sources[data["source"]] = available_sources[data["source"]]( state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
data["config"]
)
else: else:
state.client.sources[data["source"]].add_to_config(data["config"]) 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: if sid != state.sid:
return return
state.client.sources[data["source"]] = available_sources[data["source"]]( state.client.sources[data["source"]] = available_sources[data["source"]](data["config"])
data["config"]
)
@sio.on("register-web") @sio.on("register-web")
@ -902,7 +879,7 @@ async def handle_register_admin(sid: str, data: dict[str, Any]) -> bool:
room = session["room"] room = session["room"]
state = clients[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: async with sio.session(sid) as session:
session["admin"] = is_admin session["admin"] = is_admin
return is_admin return is_admin
@ -1040,17 +1017,10 @@ async def handle_search(sid: str, data: dict[str, Any]) -> None:
query = data["query"] query = data["query"]
results_list = await asyncio.gather( 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 = [ results = [search_result for source_result in results_list for search_result in source_result]
search_result
for source_result in results_list
for search_result in source_result
]
await sio.emit( await sio.emit(
"search-results", "search-results",
{"results": results}, {"results": results},
@ -1117,7 +1087,7 @@ def main() -> None:
""" """
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument("--host", "-H", default="localhost") 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("--root-folder", "-r", default="syng/static/")
parser.add_argument("--registration-keyfile", "-k", default=None) parser.add_argument("--registration-keyfile", "-k", default=None)
args = parser.parse_args() args = parser.parse_args()
@ -1129,9 +1099,7 @@ def main() -> None:
app["root_folder"] = args.root_folder app["root_folder"] = args.root_folder
app.add_routes( app.add_routes([web.static("/assets/", os.path.join(app["root_folder"], "assets/"))])
[web.static("/assets/", os.path.join(app["root_folder"], "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)

View file

@ -26,5 +26,6 @@ def configure_sources(configs: dict[str, Any]) -> dict[str, Source]:
configured_sources = {} configured_sources = {}
for source, config in configs.items(): for source, config in configs.items():
if source in available_sources: if source in available_sources:
configured_sources[source] = available_sources[source](config) if "enabled" in config and config["enabled"]:
configured_sources[source] = available_sources[source](config)
return configured_sources 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.""" """Module for the files Source."""
import asyncio import asyncio
import os import os
from typing import Any from typing import Any, Optional
from typing import Tuple from typing import Tuple
import mutagen
from ..entry import Entry from ..entry import Entry
from .source import available_sources 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. """A source for indexing and playing songs from a local folder.
Config options are: 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]): def __init__(self, config: dict[str, Any]):
"""Initialize the file module.""" """Initialize the file module."""
super().__init__(config) super().__init__(config)
self.source_name = "files"
self.dir = config["dir"] if "dir" in config else "." self.dir = config["dir"] if "dir" in config else "."
self.extra_mpv_arguments = ["--scale=oversample"] self.extra_mpv_arguments = ["--scale=oversample"]
async def get_file_list(self) -> list[str]: 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]: def _get_file_list() -> list[str]:
file_list = [] file_list = []
for path, _, files in os.walk(self.dir): for path, _, files in os.walk(self.dir):
for file in files: 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) :]) file_list.append(os.path.join(path, file)[len(self.dir) :])
return file_list return file_list
@ -41,35 +44,27 @@ class FilesSource(Source):
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]: 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 :type entry: Entry
:return: A dictionary containing the duration in seconds in the :return: A dictionary containing the duration in seconds in the
``duration`` key. ``duration`` key.
:rtype: dict[str, Any] :rtype: dict[str, Any]
""" """
def mutagen_wrapped(file: str) -> int: duration = await self.get_duration(os.path.join(self.dir, entry.ident))
meta_infos = mutagen.File(file).info
return int(meta_infos.length)
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) async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
return {"duration": int(duration)}
async def do_buffer(self, entry: Entry) -> Tuple[str, str]:
""" """
No buffering needs to be done, since the files are already on disk. 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 available_sources["files"] = FilesSource

View file

@ -12,15 +12,15 @@ from typing import cast
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
import mutagen
from minio import Minio from minio import Minio
from .filebased import FileBasedSource
from ..entry import Entry from ..entry import Entry
from .source import available_sources 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. """A source for playing songs from a s3 compatible storage.
Config options are: Config options are:
@ -31,14 +31,22 @@ class S3Source(Source):
- ``index_file``: If the file does not exist, saves the paths of - ``index_file``: If the file does not exist, saves the paths of
files from the s3 instance to this file. If it exists, loads files from the s3 instance to this file. If it exists, loads
the list of files from this file. 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]): def __init__(self, config: dict[str, Any]):
"""Create the source.""" """Create the source."""
super().__init__(config) super().__init__(config)
self.source_name = "s3"
if "endpoint" in config and "access_key" in config and "secret_key" in config: if "endpoint" in config and "access_key" in config and "secret_key" in config:
self.minio: Minio = Minio( self.minio: Minio = Minio(
@ -52,12 +60,6 @@ class S3Source(Source):
config["tmp_dir"] if "tmp_dir" in config else "/tmp/syng" 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] = ( self.index_file: Optional[str] = (
config["index_file"] if "index_file" in config else None config["index_file"] if "index_file" in config else None
) )
@ -83,7 +85,7 @@ class S3Source(Source):
file_list = [ file_list = [
obj.object_name obj.object_name
for obj in self.minio.list_objects(self.bucket, recursive=True) 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): 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: with open(self.index_file, "w", encoding="utf8") as index_file_handle:
@ -103,20 +105,13 @@ class S3Source(Source):
:rtype: dict[str, Any] :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) 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 = await self.get_duration(file_name)
duration: int = 180
else:
duration = await asyncio.to_thread(mutagen_wrapped, audio_file_name)
return {"duration": int(duration)} return {"duration": duration}
async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]: async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
""" """
@ -132,56 +127,31 @@ class S3Source(Source):
:rtype: Tuple[str, Optional[str]] :rtype: Tuple[str, Optional[str]]
""" """
if os.path.splitext(entry.ident)[1] == ".cdg": video_path, audio_path = self.get_video_audio_split(entry.ident)
cdg_filename: str = os.path.basename(entry.ident) video_dl_path: str = os.path.join(self.tmp_dir, video_path)
path_to_files: str = os.path.dirname(entry.ident) os.makedirs(os.path.dirname(video_dl_path), exist_ok=True)
video_dl_task: asyncio.Task[Any] = asyncio.create_task(
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(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
entry.ident,
target_file_cdg,
)
)
audio_task: asyncio.Task[Any] = asyncio.create_task(
asyncio.to_thread(
self.minio.fget_object,
self.bucket,
ident_mp3,
target_file_mp3,
)
)
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)
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( asyncio.to_thread(
self.minio.fget_object, self.minio.fget_object, self.bucket, entry.ident, video_dl_path
self.bucket,
entry.ident,
target_file_video,
) )
) )
await video_task if audio_path is not None:
return target_file_video, 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, audio_path, audio_dl_path
)
)
else:
audio_dl_path = None
audio_dl_task = asyncio.create_task(asyncio.sleep(0))
await video_dl_task
await audio_dl_task
return video_dl_path, audio_dl_path
available_sources["s3"] = S3Source available_sources["s3"] = S3Source

View file

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

View file

@ -50,12 +50,23 @@ class YoutubeSource(Source):
``yt-dlp``. Default is False. ``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 # pylint: disable=too-many-instance-attributes
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
"""Create the source.""" """Create the source."""
super().__init__(config) super().__init__(config)
self.source_name = "youtube"
self.innertube_client: innertube.InnerTube = innertube.InnerTube(client="WEB") self.innertube_client: innertube.InnerTube = innertube.InnerTube(client="WEB")
self.channels: list[str] = config["channels"] if "channels" in config else [] 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"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syng Rocks!</title> <title>Syng Rocks!</title>
<script type="module" crossorigin src="/assets/index.8572a105.js"></script> <script type="module" crossorigin src="/assets/index.20e81f9f.js"></script>
<link rel="stylesheet" href="/assets/index.9110b89b.css"> <link rel="stylesheet" href="/assets/index.b030f504.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

BIN
syng/static/syng.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB