Implemented GUI for syng #2
26 changed files with 1577 additions and 1052 deletions
|
@ -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:
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
# Syng
|
|
@ -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"]
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
JSON
|
JSON
|
||||||
====
|
====
|
||||||
|
|
||||||
.. automodule:: syng.json
|
.. automodule:: syng.jsonencoder
|
||||||
:members:
|
:members:
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
171
syng/client.py
171
syng/client.py
|
@ -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
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
|
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)
|
||||||
|
|
|
@ -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
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."""
|
"""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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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 []
|
||||||
|
|
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">
|
<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
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