Implemented GUI for syng #2
26 changed files with 1577 additions and 1052 deletions
|
@ -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:
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
# Syng
|
|
@ -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"]
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
JSON
|
||||
====
|
||||
|
||||
.. automodule:: syng.json
|
||||
.. automodule:: syng.jsonencoder
|
||||
:members:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
171
syng/client.py
171
syng/client.py
|
@ -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
|
||||
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:
|
||||
* `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
|
||||
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.wait()
|
||||
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
462
syng/gui.py
Normal 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()
|
100
syng/server.py
100
syng/server.py
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
configured_sources[source] = available_sources[source](config)
|
||||
if "enabled" in config and config["enabled"]:
|
||||
configured_sources[source] = available_sources[source](config)
|
||||
return configured_sources
|
||||
|
|
77
syng/sources/filebased.py
Normal file
77
syng/sources/filebased.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
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(
|
||||
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_video,
|
||||
self.minio.fget_object, self.bucket, entry.ident, video_dl_path
|
||||
)
|
||||
)
|
||||
|
||||
await video_task
|
||||
return target_file_video, None
|
||||
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, 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
|
||||
|
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
@ -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 []
|
||||
|
|
787
syng/static/assets/index.20e81f9f.js
Normal file
787
syng/static/assets/index.20e81f9f.js
Normal file
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
1
syng/static/assets/index.b030f504.css
Normal file
1
syng/static/assets/index.b030f504.css
Normal file
File diff suppressed because one or more lines are too long
|
@ -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
BIN
syng/static/syng.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
Loading…
Add table
Reference in a new issue