Implemented GUI for syng #2

Merged
christofsteel merged 18 commits from gui into main 2023-12-18 19:48:28 +01:00
12 changed files with 484 additions and 163 deletions
Showing only changes of commit f339c35c5a - Show all commits

View file

@ -0,0 +1 @@
# Syng

View file

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

View file

@ -22,7 +22,9 @@ 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"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View file

@ -44,17 +44,18 @@ from traceback import print_exc
from typing import Any from typing import Any
from typing import Optional from typing import Optional
import pyqrcodeng as pyqrcode import qrcode
import socketio import socketio
from PIL import Image from PIL import Image
from . import json from . import jsonencoder
from .entry import Entry from .entry import Entry
from .sources import configure_sources from .sources import configure_sources
from .sources import 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] = {}
@ -87,15 +88,13 @@ 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. :param config: Various configuration options for the client:
:type room: str * `server` (`str`): The url of the server to connect to.
:param secret: The passcode of the room. If a playback client reconnects 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 a room, this must be identical. Also, if a webclient wants to have
admin privileges, this must be included. admin privileges, this must be included.
:type secret: str * `key` (`Optional[str]`) An optional key, if registration on the server is limited.
:param key: An optional key, if registration on the server is limited.
:type key: Optional[str]
:param config: Various configuration options for the client:
* `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.
@ -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,12 @@ 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, # "room": state.config["room"],
"secret": state.secret, # "secret": state.config["secret"],
"config": state.config, "config": state.config,
} }
if state.key: if state.config["key"]:
data["registration-key"] = state.key data["registration-key"] = state.config["key"] # TODO: unify
await sio.emit("register-client", data) await sio.emit("register-client", data)
@ -304,7 +299,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 +320,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")
@ -373,30 +371,14 @@ 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: 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:
@ -407,28 +389,60 @@ async def aiomain() -> 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() 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: 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.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__": if __name__ == "__main__":

243
syng/gui.py Normal file
View 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()

View file

@ -32,14 +32,14 @@ from typing import Optional
import socketio import socketio
from aiohttp import web from aiohttp import web
from . import json from . import jsonencoder
from .entry import Entry from .entry import Entry
from .queue import Queue from .queue import Queue
from .sources import available_sources from .sources import available_sources
from .sources import Source from .sources import Source
sio = socketio.AsyncServer( sio = socketio.AsyncServer(
cors_allowed_origins="*", logger=True, engineio_logger=False, json=json cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder
) )
app = web.Application() app = web.Application()
sio.attach(app) sio.attach(app)
@ -127,7 +127,6 @@ 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]
@ -329,7 +328,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,
@ -707,13 +706,17 @@ 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(
@ -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"]] 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,
@ -902,7 +904,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

76
syng/sources/filebased.py Normal file
View 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

View file

@ -1,39 +1,41 @@
"""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", ".")
}
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 +43,27 @@ class FilesSource(Source):
async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]: async def get_missing_metadata(self, entry: Entry) -> dict[str, Any]:
""" """
Return the duration for the mp3 file. Return the duration for the entry file.
:param entry: The entry with the associated mp3 file :param entry: An entry
:type entry: Entry :type entry: Entry
:return: A dictionary containing the duration in seconds in the :return: A dictionary containing the duration in seconds in the
``duration`` key. ``duration`` key.
:rtype: dict[str, Any] :rtype: dict[str, Any]
""" """
def mutagen_wrapped(file: str) -> int: duration = await self.get_duration(os.path.join(self.dir, entry.ident))
meta_infos = mutagen.File(file).info
return int(meta_infos.length)
audio_file_name: str = os.path.join(self.dir, entry.ident[:-3] + "mp3") return {"duration": duration}
duration = await asyncio.to_thread(mutagen_wrapped, audio_file_name) async def do_buffer(self, entry: Entry) -> Tuple[str, Optional[str]]:
return {"duration": int(duration)}
async def do_buffer(self, entry: Entry) -> Tuple[str, str]:
""" """
No buffering needs to be done, since the files are already on disk. No buffering needs to be done, since the files are already on disk.
We just return the cdg file name and the inferred mp3 file name We just return the file names.
""" """
video_file_name: str = os.path.join(self.dir, entry.ident)
audio_file_name: str = os.path.join(self.dir, entry.ident[:-3] + "mp3")
return video_file_name, audio_file_name return self.get_video_audio_split(os.path.join(self.dir, entry.ident))
available_sources["files"] = FilesSource available_sources["files"] = FilesSource

View file

@ -12,15 +12,15 @@ from typing import cast
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
import mutagen
from minio import Minio from minio import Minio
from .filebased import FileBasedSource
from ..entry import Entry from ..entry import Entry
from .source import available_sources from .source import available_sources
from .source import Source
class S3Source(Source): class S3Source(FileBasedSource):
"""A source for playing songs from a s3 compatible storage. """A source for playing songs from a s3 compatible storage.
Config options are: Config options are:
@ -31,14 +31,21 @@ 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 temporary download", "/tmp/syng"),
}
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 +59,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 +84,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 +104,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: Optional[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 +126,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( asyncio.to_thread(
self.minio.fget_object, self.minio.fget_object, self.bucket, entry.ident, video_dl_path
self.bucket,
entry.ident,
target_file_cdg,
) )
) )
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( asyncio.to_thread(
self.minio.fget_object, self.minio.fget_object, self.bucket, audio_path, audio_dl_path
self.bucket,
ident_mp3,
target_file_mp3,
) )
) )
else:
audio_dl_path = None
audio_dl_task = asyncio.create_task(asyncio.sleep(0))
await cdg_task await video_dl_task
await audio_task await audio_dl_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) return video_dl_path, audio_dl_path
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
available_sources["s3"] = S3Source available_sources["s3"] = S3Source

View file

@ -19,6 +19,7 @@ from typing import Any
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
from typing import Type from typing import Type
from abc import ABC, abstractmethod
from ..entry import Entry from ..entry import Entry
from ..result import Result from ..result import Result
@ -65,7 +66,7 @@ class DLFilesEntry:
buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None buffer_task: Optional[asyncio.Task[Tuple[str, Optional[str]]]] = None
class Source: class Source(ABC):
"""Parentclass for all sources. """Parentclass for all sources.
A new source should subclass this, and at least implement A new source should subclass this, and at least implement
@ -103,6 +104,11 @@ class Source:
- ``source_name``, the string used to identify the source - ``source_name``, the string used to identify the source
""" """
source_name: str = ""
config_schema: dict[str, tuple[type | list[type], str, Any]] = {
"enabled": (bool, "Enable this source", False)
}
def __init__(self, config: dict[str, Any]): def __init__(self, config: dict[str, Any]):
""" """
Create and initialize a new source. Create and initialize a new source.
@ -114,7 +120,6 @@ 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( self.downloaded_files: defaultdict[str, DLFilesEntry] = defaultdict(
DLFilesEntry DLFilesEntry
) )
@ -144,6 +149,8 @@ class Source:
[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",
*args, *args,
@ -207,6 +214,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 +231,7 @@ 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:
""" """
@ -399,6 +407,7 @@ class Source:
:return: The part of the config, that should be sended to the server. :return: The part of the config, that should be sended to the server.
:rtype: dict[str, Any] | list[dict[str, Any]] :rtype: dict[str, Any] | list[dict[str, Any]]
""" """
print("xzy")
if not self._index: if not self._index:
self._index = [] self._index = []
print(f"{self.source_name}: generating index") print(f"{self.source_name}: generating index")

View file

@ -50,12 +50,23 @@ class YoutubeSource(Source):
``yt-dlp``. Default is False. ``yt-dlp``. Default is False.
""" """
source_name = "youtube"
config_schema = Source.config_schema | {
"channels": (list, "A list channels 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 # 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 []