diff --git a/README.md b/README.md index e69de29..58ea28e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1 @@ +# Syng diff --git a/docs/source/json.rst b/docs/source/json.rst index f8e2903..e1df64f 100644 --- a/docs/source/json.rst +++ b/docs/source/json.rst @@ -1,5 +1,5 @@ JSON ==== -.. automodule:: syng.json +.. automodule:: syng.jsonencoder :members: diff --git a/pyproject.toml b/pyproject.toml index b95faff..f2b9808 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/syng/client.py b/syng/client.py index 30e68d1..de8143b 100644 --- a/syng/client.py +++ b/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 - a room, this must be identical. Also, if a webclient wants to have - admin privileges, this must be included. - :type secret: str - :param key: An optional key, if registration on the server is limited. - :type key: Optional[str] :param config: Various configuration options for the client: + * `server` (`str`): The url of the server to connect to. + * `room` (`str`): The room on the server this playback client is connected to. + * `secret` (`str`): The passcode of the room. If a playback client reconnects to + a room, this must be identical. Also, if a webclient wants to have + admin privileges, this must be included. + * `key` (`Optional[str]`) An optional key, if registration on the server is limited. * `preview_duration` (`Optional[int]`): The duration in seconds the playback client shows a preview for the next song. This is accounted for in the calculation of the ETA for songs later in the queue. @@ -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__": diff --git a/syng/gui.py b/syng/gui.py new file mode 100644 index 0000000..fceb4b6 --- /dev/null +++ b/syng/gui.py @@ -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("", 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("", 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() diff --git a/syng/json.py b/syng/jsonencoder.py similarity index 100% rename from syng/json.py rename to syng/jsonencoder.py diff --git a/syng/server.py b/syng/server.py index 32a253f..157bf1c 100644 --- a/syng/server.py +++ b/syng/server.py @@ -32,14 +32,14 @@ from typing import Optional import socketio from aiohttp import web -from . import json +from . import jsonencoder from .entry import Entry from .queue import Queue from .sources import available_sources from .sources import Source sio = socketio.AsyncServer( - cors_allowed_origins="*", logger=True, engineio_logger=False, json=json + cors_allowed_origins="*", logger=True, engineio_logger=False, json=jsonencoder ) app = web.Application() sio.attach(app) @@ -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 diff --git a/syng/sources/filebased.py b/syng/sources/filebased.py new file mode 100644 index 0000000..537b66f --- /dev/null +++ b/syng/sources/filebased.py @@ -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 diff --git a/syng/sources/files.py b/syng/sources/files.py index 7d92454..f5775e2 100644 --- a/syng/sources/files.py +++ b/syng/sources/files.py @@ -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 diff --git a/syng/sources/s3.py b/syng/sources/s3.py index af3bc90..684d962 100644 --- a/syng/sources/s3.py +++ b/syng/sources/s3.py @@ -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( - asyncio.to_thread( - self.minio.fget_object, - self.bucket, - entry.ident, - target_file_cdg, - ) - ) - audio_task: asyncio.Task[Any] = asyncio.create_task( - asyncio.to_thread( - self.minio.fget_object, - self.bucket, - ident_mp3, - target_file_mp3, - ) - ) - - await cdg_task - await audio_task - return target_file_cdg, target_file_mp3 - video_filename: str = os.path.basename(entry.ident) - path_to_file: str = os.path.dirname(entry.ident) - - video_path: str = os.path.join(path_to_file, video_filename) - target_file_video: str = os.path.join(self.tmp_dir, video_path) - - os.makedirs(os.path.dirname(target_file_video), exist_ok=True) - - video_task: asyncio.Task[Any] = asyncio.create_task( + video_path, audio_path = self.get_video_audio_split(entry.ident) + video_dl_path: str = os.path.join(self.tmp_dir, video_path) + os.makedirs(os.path.dirname(video_dl_path), exist_ok=True) + video_dl_task: asyncio.Task[Any] = asyncio.create_task( asyncio.to_thread( - self.minio.fget_object, - self.bucket, - entry.ident, - target_file_video, + self.minio.fget_object, self.bucket, entry.ident, video_dl_path ) ) - await video_task - return target_file_video, None + if audio_path is not None: + audio_dl_path: Optional[str] = os.path.join(self.tmp_dir, audio_path) + + audio_dl_task: asyncio.Task[Any] = asyncio.create_task( + asyncio.to_thread( + self.minio.fget_object, self.bucket, audio_path, audio_dl_path + ) + ) + else: + audio_dl_path = None + audio_dl_task = asyncio.create_task(asyncio.sleep(0)) + + await video_dl_task + await audio_dl_task + + return video_dl_path, audio_dl_path available_sources["s3"] = S3Source diff --git a/syng/sources/source.py b/syng/sources/source.py index 6612ffc..2d3d734 100644 --- a/syng/sources/source.py +++ b/syng/sources/source.py @@ -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") diff --git a/syng/sources/youtube.py b/syng/sources/youtube.py index 1e20a0c..0272512 100644 --- a/syng/sources/youtube.py +++ b/syng/sources/youtube.py @@ -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 []