From f339c35c5a06d58d625a2032089d7dce2c42dca5 Mon Sep 17 00:00:00 2001
From: Christoph Stahl <christoph.stahl@tu-dortmund.de>
Date: Sun, 5 Nov 2023 22:37:16 +0100
Subject: [PATCH] Experiments and prepatations for gui

---
 README.md                        |   1 +
 docs/source/json.rst             |   2 +-
 pyproject.toml                   |   4 +-
 syng/client.py                   | 126 +++++++++-------
 syng/gui.py                      | 243 +++++++++++++++++++++++++++++++
 syng/{json.py => jsonencoder.py} |   0
 syng/server.py                   |  18 ++-
 syng/sources/filebased.py        |  76 ++++++++++
 syng/sources/files.py            |  42 +++---
 syng/sources/s3.py               | 107 +++++---------
 syng/sources/source.py           |  15 +-
 syng/sources/youtube.py          |  13 +-
 12 files changed, 484 insertions(+), 163 deletions(-)
 create mode 100644 syng/gui.py
 rename syng/{json.py => jsonencoder.py} (100%)
 create mode 100644 syng/sources/filebased.py

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("<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()
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 []