Experiments and prepatations for gui
This commit is contained in:
parent
4322a68a82
commit
f339c35c5a
12 changed files with 484 additions and 163 deletions
|
@ -0,0 +1 @@
|
|||
# Syng
|
|
@ -1,5 +1,5 @@
|
|||
JSON
|
||||
====
|
||||
|
||||
.. automodule:: syng.json
|
||||
.. automodule:: syng.jsonencoder
|
||||
:members:
|
||||
|
|
|
@ -22,7 +22,9 @@ 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"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
|
124
syng/client.py
124
syng/client.py
|
@ -44,17 +44,18 @@ from traceback import print_exc
|
|||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
import pyqrcodeng as pyqrcode
|
||||
import qrcode
|
||||
|
||||
import socketio
|
||||
from PIL import Image
|
||||
|
||||
from . import json
|
||||
from . import jsonencoder
|
||||
from .entry import Entry
|
||||
from .sources import configure_sources
|
||||
from .sources import 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] = {}
|
||||
|
||||
|
@ -87,15 +88,13 @@ class State:
|
|||
:type waiting_room: list[Entry]
|
||||
:param recent: A copy of all played songs this session.
|
||||
:type recent: list[Entry]
|
||||
:param room: The room on the server this playback client is connected to.
|
||||
:type room: str
|
||||
:param secret: The passcode of the room. If a playback client reconnects to
|
||||
:param config: Various configuration options for the client:
|
||||
* `server` (`str`): The url of the server to connect to.
|
||||
* `room` (`str`): The room on the server this playback client is connected to.
|
||||
* `secret` (`str`): The passcode of the room. If a playback client reconnects to
|
||||
a room, this must be identical. Also, if a webclient wants to have
|
||||
admin privileges, this must be included.
|
||||
:type secret: str
|
||||
:param key: An optional key, if registration on the server is limited.
|
||||
:type key: Optional[str]
|
||||
:param config: Various configuration options for the client:
|
||||
* `key` (`Optional[str]`) An optional key, if registration on the server is limited.
|
||||
* `preview_duration` (`Optional[int]`): The duration in seconds the
|
||||
playback client shows a preview for the next song. This is accounted for
|
||||
in the calculation of the ETA for songs later in the queue.
|
||||
|
@ -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,12 @@ async def handle_connect() -> None:
|
|||
"queue": state.queue,
|
||||
"waiting_room": state.waiting_room,
|
||||
"recent": state.recent,
|
||||
"room": state.room,
|
||||
"secret": state.secret,
|
||||
# "room": state.config["room"],
|
||||
# "secret": state.config["secret"],
|
||||
"config": state.config,
|
||||
}
|
||||
if state.key:
|
||||
data["registration-key"] = state.key
|
||||
if state.config["key"]:
|
||||
data["registration-key"] = state.config["key"] # TODO: unify
|
||||
await sio.emit("register-client", data)
|
||||
|
||||
|
||||
|
@ -304,7 +299,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 +320,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")
|
||||
|
@ -373,30 +371,14 @@ async def handle_request_config(data: dict[str, Any]) -> None:
|
|||
await sio.emit("config", {"source": data["source"], "config": config})
|
||||
|
||||
|
||||
async def aiomain() -> None:
|
||||
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:
|
||||
|
@ -407,28 +389,60 @@ async def aiomain() -> None:
|
|||
)
|
||||
state.config |= config["config"] | {"last_song": last_song}
|
||||
|
||||
state.key = args.key if args.key else None
|
||||
|
||||
if args.room:
|
||||
state.room = args.room
|
||||
|
||||
if args.secret:
|
||||
state.secret = args.secret
|
||||
else:
|
||||
state.secret = "".join(
|
||||
if not ("secret" in state.config and state.config["secret"]):
|
||||
state.config["secret"] = "".join(
|
||||
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
|
||||
)
|
||||
print(f"Generated secret: {state.secret}")
|
||||
print(f"Generated secret: {state.config['secret']}")
|
||||
|
||||
state.server = args.server
|
||||
if not ("key" in state.config and state.config["key"]):
|
||||
state.config["key"] = ""
|
||||
|
||||
await sio.connect(args.server)
|
||||
await sio.connect(state.config["server"])
|
||||
await sio.wait()
|
||||
|
||||
|
||||
async def aiomain() -> 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.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
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.json")
|
||||
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)
|
||||
|
||||
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}
|
||||
|
||||
asyncio.run(start_client(config))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
243
syng/gui.py
Normal file
243
syng/gui.py
Normal file
|
@ -0,0 +1,243 @@
|
|||
import builtins
|
||||
from functools import partial
|
||||
from json import load
|
||||
import customtkinter
|
||||
import qrcode
|
||||
import secrets
|
||||
import string
|
||||
from tkinter import filedialog
|
||||
|
||||
from syng.client import default_config
|
||||
|
||||
from .sources import available_sources
|
||||
|
||||
|
||||
class SyngGui(customtkinter.CTk):
|
||||
def loadConfig(self):
|
||||
filedialog.askopenfilename()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(className="Syng")
|
||||
|
||||
self.wm_title("Syng")
|
||||
tabview = customtkinter.CTkTabview(self)
|
||||
tabview.pack(side="top")
|
||||
|
||||
tabview.add("General")
|
||||
for source in available_sources:
|
||||
tabview.add(source)
|
||||
tabview.set("General")
|
||||
|
||||
fileframe = customtkinter.CTkFrame(self)
|
||||
fileframe.pack(side="bottom")
|
||||
|
||||
loadbutton = customtkinter.CTkButton(
|
||||
fileframe,
|
||||
text="load",
|
||||
command=self.loadConfig,
|
||||
)
|
||||
loadbutton.pack(side="left")
|
||||
|
||||
frm = customtkinter.CTkFrame(tabview.tab("General"))
|
||||
frm.grid(ipadx=10)
|
||||
|
||||
self.qrlabel = customtkinter.CTkLabel(frm, text="")
|
||||
self.qrlabel.grid(column=0, row=0)
|
||||
|
||||
optionframe = customtkinter.CTkFrame(frm)
|
||||
optionframe.grid(column=1, row=0)
|
||||
|
||||
customtkinter.CTkLabel(optionframe, text="Server", justify="left").grid(
|
||||
column=0, row=0, padx=5, pady=5
|
||||
)
|
||||
self.serverTextbox = customtkinter.CTkTextbox(
|
||||
optionframe, wrap="none", height=1
|
||||
)
|
||||
self.serverTextbox.grid(column=1, row=0)
|
||||
self.serverTextbox.bind("<KeyRelease>", self.updateQr)
|
||||
|
||||
customtkinter.CTkLabel(optionframe, text="Room", justify="left").grid(
|
||||
column=0, row=1
|
||||
)
|
||||
self.roomTextbox = customtkinter.CTkTextbox(optionframe, wrap="none", height=1)
|
||||
self.roomTextbox.grid(column=1, row=1)
|
||||
self.roomTextbox.bind("<KeyRelease>", self.updateQr)
|
||||
|
||||
customtkinter.CTkLabel(optionframe, text="Secret", justify="left").grid(
|
||||
column=0, row=3
|
||||
)
|
||||
self.secretTextbox = customtkinter.CTkTextbox(
|
||||
optionframe, wrap="none", height=1
|
||||
)
|
||||
secret = "".join(
|
||||
secrets.choice(string.ascii_letters + string.digits) for _ in range(8)
|
||||
)
|
||||
self.secretTextbox.grid(column=1, row=3)
|
||||
|
||||
customtkinter.CTkLabel(
|
||||
optionframe, text="Waiting room policy", justify="left"
|
||||
).grid(column=0, row=4)
|
||||
self.waitingRoomPolicy = customtkinter.CTkOptionMenu(
|
||||
optionframe, values=["forced", "optional", "none"]
|
||||
)
|
||||
self.waitingRoomPolicy.set("none")
|
||||
self.waitingRoomPolicy.grid(column=1, row=4)
|
||||
|
||||
customtkinter.CTkLabel(
|
||||
optionframe, text="Time of last Song", justify="left"
|
||||
).grid(column=0, row=5)
|
||||
self.last_song = customtkinter.CTkTextbox(optionframe, wrap="none", height=1)
|
||||
self.last_song.grid(column=1, row=5)
|
||||
|
||||
customtkinter.CTkLabel(
|
||||
optionframe, text="Preview Duration", justify="left"
|
||||
).grid(column=0, row=6)
|
||||
self.preview_duration = customtkinter.CTkTextbox(
|
||||
optionframe, wrap="none", height=1
|
||||
)
|
||||
self.preview_duration.grid(column=1, row=6)
|
||||
|
||||
customtkinter.CTkButton(optionframe, text="Start", command=self.start).grid(
|
||||
column=0, row=7, columnspan=2, pady=10
|
||||
)
|
||||
|
||||
with open("syng-client.json") as cfile:
|
||||
loaded_config = load(cfile)
|
||||
config = {"sources": {}, "config": {}}
|
||||
|
||||
self.source_config_elements = {}
|
||||
for source_name, source in available_sources.items():
|
||||
self.source_config_elements[source_name] = {}
|
||||
config["sources"][source_name] = {}
|
||||
sourcefrm = customtkinter.CTkFrame(tabview.tab(source_name))
|
||||
sourcefrm.grid(ipadx=10)
|
||||
for row, (name, (typ, desc, default)) in enumerate(
|
||||
source.config_schema.items()
|
||||
):
|
||||
if name in loaded_config["sources"][source_name]:
|
||||
config["sources"][source_name][name] = loaded_config["sources"][
|
||||
source_name
|
||||
][name]
|
||||
else:
|
||||
config["sources"][source_name][name] = default
|
||||
|
||||
label = customtkinter.CTkLabel(
|
||||
sourcefrm, text=f"{desc} ({name})", justify="right"
|
||||
)
|
||||
label.grid(column=0, row=row)
|
||||
match typ:
|
||||
case builtins.bool:
|
||||
self.source_config_elements[source_name][
|
||||
name
|
||||
] = customtkinter.CTkSwitch(sourcefrm, text="")
|
||||
self.source_config_elements[source_name][name].grid(
|
||||
column=1, row=row
|
||||
)
|
||||
if config["sources"][source_name][name]:
|
||||
self.source_config_elements[source_name][name].select()
|
||||
else:
|
||||
self.source_config_elements[source_name][name].deselect()
|
||||
|
||||
case builtins.list:
|
||||
self.source_config_elements[source_name][
|
||||
name
|
||||
] = customtkinter.CTkTextbox(sourcefrm, wrap="none", height=1)
|
||||
self.source_config_elements[source_name][name].grid(
|
||||
column=1, row=row
|
||||
)
|
||||
self.source_config_elements[source_name][name].insert(
|
||||
"0.0", ",".join(config["sources"][source_name][name])
|
||||
)
|
||||
|
||||
case _:
|
||||
self.source_config_elements[source_name][
|
||||
name
|
||||
] = customtkinter.CTkTextbox(sourcefrm, wrap="none", height=1)
|
||||
self.source_config_elements[source_name][name].grid(
|
||||
column=1, row=row
|
||||
)
|
||||
self.source_config_elements[source_name][name].insert(
|
||||
"0.0", config["sources"][source_name][name]
|
||||
)
|
||||
if source_name in loaded_config["sources"]:
|
||||
config["sources"][source_name] |= loaded_config["sources"][source_name]
|
||||
|
||||
if "config" in loaded_config:
|
||||
config["config"] = default_config() | loaded_config["config"]
|
||||
|
||||
self.serverTextbox.insert("0.0", config["config"]["server"])
|
||||
self.roomTextbox.insert("0.0", config["config"]["room"])
|
||||
self.secretTextbox.insert("0.0", config["config"]["secret"])
|
||||
self.waitingRoomPolicy.set(str(config["config"]["waiting_room_policy"]).lower())
|
||||
if config["config"]["last_song"]:
|
||||
self.last_song.insert("0.0", config["config"]["last_song"])
|
||||
self.preview_duration.insert("0.0", config["config"]["preview_duration"])
|
||||
|
||||
self.updateQr()
|
||||
|
||||
def start(self):
|
||||
config = {}
|
||||
config["server"] = self.serverTextbox.get("0.0", "end").strip()
|
||||
config["room"] = self.roomTextbox.get("0.0", "end").strip()
|
||||
config["secret"] = self.secretTextbox.get("0.0", "end").strip()
|
||||
config["waiting_room_policy"] = self.waitingRoomPolicy.get().strip()
|
||||
config["last_song"] = self.last_song.get("0.0", "end").strip()
|
||||
try:
|
||||
config["preview_duration"] = int(
|
||||
self.preview_duration.get("0.0", "end").strip()
|
||||
)
|
||||
except ValueError:
|
||||
config["preview_duration"] = 0
|
||||
|
||||
sources = {}
|
||||
for source_name, config_elements in self.source_config_elements.items():
|
||||
sources[source_name] = {}
|
||||
for option, config_element in config_elements.items():
|
||||
if isinstance(config_element, customtkinter.CTkSwitch):
|
||||
sources[source_name][option] = (
|
||||
True if config_element.get() == 1 else False
|
||||
)
|
||||
elif isinstance(config_element, customtkinter.CTkTextbox):
|
||||
match available_sources[source_name].config_schema[option][0]:
|
||||
case builtins.list:
|
||||
sources[source_name][option] = [
|
||||
value.strip()
|
||||
for value in config_element.get("0.0", "end")
|
||||
.strip()
|
||||
.split(",")
|
||||
]
|
||||
|
||||
case builtins.str:
|
||||
sources[source_name][option] = config_element.get(
|
||||
"0.0", "end"
|
||||
).strip()
|
||||
else:
|
||||
raise RuntimeError("IDK")
|
||||
|
||||
syng_config = {"sources": sources, "config": config}
|
||||
|
||||
print(syng_config)
|
||||
|
||||
def changeQr(self, data: str):
|
||||
qr = qrcode.QRCode(box_size=20, border=2)
|
||||
qr.add_data(data)
|
||||
qr.make()
|
||||
qr.print_ascii()
|
||||
image = qr.make_image().convert("RGB")
|
||||
tkQrcode = customtkinter.CTkImage(light_image=image, size=(280, 280))
|
||||
self.qrlabel.configure(image=tkQrcode)
|
||||
|
||||
def updateQr(self, _evt=None):
|
||||
server = self.serverTextbox.get("0.0", "end").strip()
|
||||
server += "" if server.endswith("/") else "/"
|
||||
room = self.roomTextbox.get("0.0", "end").strip()
|
||||
print(server + room)
|
||||
self.changeQr(server + room)
|
||||
|
||||
|
||||
def main():
|
||||
SyngGui().mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -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)
|
||||
|
@ -127,7 +127,6 @@ class State:
|
|||
:type client: Client
|
||||
"""
|
||||
|
||||
secret: str
|
||||
queue: Queue
|
||||
waiting_room: list[Entry]
|
||||
recent: list[Entry]
|
||||
|
@ -329,7 +328,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,
|
||||
|
@ -707,13 +706,17 @@ 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(
|
||||
|
@ -738,7 +741,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,
|
||||
|
@ -902,7 +904,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
|
||||
|
|
76
syng/sources/filebased.py
Normal file
76
syng/sources/filebased.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
"""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 (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
|
||||
return info.audio_tracks[0].to_data()["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,41 @@
|
|||
"""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", ".")
|
||||
}
|
||||
|
||||
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 +43,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,21 @@ 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 temporary download", "/tmp/syng"),
|
||||
}
|
||||
|
||||
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 +59,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 +84,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 +104,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: Optional[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 +126,31 @@ class S3Source(Source):
|
|||
:rtype: Tuple[str, Optional[str]]
|
||||
"""
|
||||
|
||||
if os.path.splitext(entry.ident)[1] == ".cdg":
|
||||
cdg_filename: str = os.path.basename(entry.ident)
|
||||
path_to_files: str = os.path.dirname(entry.ident)
|
||||
|
||||
cdg_path: str = os.path.join(path_to_files, cdg_filename)
|
||||
target_file_cdg: str = os.path.join(self.tmp_dir, cdg_path)
|
||||
|
||||
ident_mp3: str = entry.ident[:-3] + "mp3"
|
||||
target_file_mp3: str = target_file_cdg[:-3] + "mp3"
|
||||
os.makedirs(os.path.dirname(target_file_cdg), exist_ok=True)
|
||||
|
||||
cdg_task: asyncio.Task[Any] = asyncio.create_task(
|
||||
video_path, audio_path = self.get_video_audio_split(entry.ident)
|
||||
video_dl_path: str = os.path.join(self.tmp_dir, video_path)
|
||||
os.makedirs(os.path.dirname(video_dl_path), exist_ok=True)
|
||||
video_dl_task: asyncio.Task[Any] = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
self.minio.fget_object,
|
||||
self.bucket,
|
||||
entry.ident,
|
||||
target_file_cdg,
|
||||
self.minio.fget_object, self.bucket, entry.ident, video_dl_path
|
||||
)
|
||||
)
|
||||
audio_task: asyncio.Task[Any] = asyncio.create_task(
|
||||
|
||||
if audio_path is not None:
|
||||
audio_dl_path: Optional[str] = os.path.join(self.tmp_dir, audio_path)
|
||||
|
||||
audio_dl_task: asyncio.Task[Any] = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
self.minio.fget_object,
|
||||
self.bucket,
|
||||
ident_mp3,
|
||||
target_file_mp3,
|
||||
self.minio.fget_object, self.bucket, audio_path, audio_dl_path
|
||||
)
|
||||
)
|
||||
else:
|
||||
audio_dl_path = None
|
||||
audio_dl_task = asyncio.create_task(asyncio.sleep(0))
|
||||
|
||||
await cdg_task
|
||||
await audio_task
|
||||
return target_file_cdg, target_file_mp3
|
||||
video_filename: str = os.path.basename(entry.ident)
|
||||
path_to_file: str = os.path.dirname(entry.ident)
|
||||
await video_dl_task
|
||||
await audio_dl_task
|
||||
|
||||
video_path: str = os.path.join(path_to_file, video_filename)
|
||||
target_file_video: str = os.path.join(self.tmp_dir, video_path)
|
||||
|
||||
os.makedirs(os.path.dirname(target_file_video), exist_ok=True)
|
||||
|
||||
video_task: asyncio.Task[Any] = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
self.minio.fget_object,
|
||||
self.bucket,
|
||||
entry.ident,
|
||||
target_file_video,
|
||||
)
|
||||
)
|
||||
|
||||
await video_task
|
||||
return target_file_video, None
|
||||
return video_dl_path, audio_dl_path
|
||||
|
||||
|
||||
available_sources["s3"] = S3Source
|
||||
|
|
|
@ -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,7 +120,6 @@ class Source:
|
|||
source for documentation.
|
||||
:type config: dict[str, Any]
|
||||
"""
|
||||
self.source_name: str = ""
|
||||
self.downloaded_files: defaultdict[str, DLFilesEntry] = defaultdict(
|
||||
DLFilesEntry
|
||||
)
|
||||
|
@ -144,6 +149,8 @@ class Source:
|
|||
[f"--audio-file={audio}"] if audio else []
|
||||
)
|
||||
|
||||
print(f"File is {video=} and {audio=}")
|
||||
|
||||
mpv_process = asyncio.create_subprocess_exec(
|
||||
"mpv",
|
||||
*args,
|
||||
|
@ -207,6 +214,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 +231,7 @@ 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:
|
||||
"""
|
||||
|
@ -399,6 +407,7 @@ class Source:
|
|||
:return: The part of the config, that should be sended to the server.
|
||||
:rtype: dict[str, Any] | list[dict[str, Any]]
|
||||
"""
|
||||
print("xzy")
|
||||
if not self._index:
|
||||
self._index = []
|
||||
print(f"{self.source_name}: generating index")
|
||||
|
|
|
@ -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 to search in", []),
|
||||
"tmp_dir": (str, "Folder for temporary download", "/tmp/syng"),
|
||||
"max_res": (int, "Maximum resolution to download", 720),
|
||||
"start_streaming": (
|
||||
bool,
|
||||
"Start streaming if download 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 []
|
||||
|
|
Loading…
Add table
Reference in a new issue