diff --git a/syng/config.py b/syng/config.py new file mode 100644 index 0000000..fb519d1 --- /dev/null +++ b/syng/config.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class Option(Generic[T]): + pass + + +@dataclass +class ConfigOption(Generic[T]): + type: Option[T] + description: str + default: T + + +class BoolOption(Option[bool]): + pass + + +class IntOption(Option[int]): + pass + + +class StrOption(Option[str]): + pass + + +class PasswordOption(Option[str]): + pass + + +class FolderOption(Option[str]): + pass + + +class FileOption(Option[str]): + pass + + +class ListStrOption(Option[list[str]]): + pass + + +@dataclass +class ChoiceOption(Option[str]): + choices: list[str] diff --git a/syng/gui.py b/syng/gui.py index d83f61c..5620103 100644 --- a/syng/gui.py +++ b/syng/gui.py @@ -32,6 +32,7 @@ from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QDateTimeEdit, + QFileDialog, QFormLayout, QHBoxLayout, QLabel, @@ -40,6 +41,7 @@ from PyQt6.QtWidgets import ( QPushButton, QSizePolicy, QSpacerItem, + QSpinBox, QTabWidget, QVBoxLayout, QWidget, @@ -52,6 +54,16 @@ from . import resources # noqa from .client import create_async_and_start_client, default_config from .sources import available_sources +from .config import ( + BoolOption, + ChoiceOption, + FileOption, + FolderOption, + IntOption, + ListStrOption, + PasswordOption, + StrOption, +) # try: # from .server import run_server @@ -72,7 +84,6 @@ class OptionFrame(QWidget): self.bool_options[name] = QCheckBox(self) self.bool_options[name].setChecked(value) self.form_layout.addRow(label, self.bool_options[name]) - self.number_of_options += 1 def add_string_option( self, @@ -105,9 +116,100 @@ class OptionFrame(QWidget): self.string_options[name].insert(value) self.form_layout.addRow(label, self.string_options[name]) + self.rows[name] = (label, self.string_options[name]) if callback is not None: self.string_options[name].textChanged.connect(callback) - self.number_of_options += 1 + + def path_setter(self, line: QLineEdit, name: Optional[str]) -> None: + if name: + line.setText(name) + + def add_file_option( + self, + name: str, + description: str, + value: Optional[str] = "", + callback: Optional[Callable[..., None]] = None, + ) -> None: + if value is None: + value = "" + + label = QLabel(description, self) + file_layout = QHBoxLayout() + file_name_widget = QLineEdit(value, self) + file_button = QPushButton(QIcon.fromTheme("document-open"), "", self) + + file_button.clicked.connect( + lambda: self.path_setter( + file_name_widget, + QFileDialog.getOpenFileName( + self, "Select File", directory=os.path.dirname(file_name_widget.text()) + )[0], + ) + ) + + if callback is not None: + file_name_widget.textChanged.connect(callback) + + file_layout.addWidget(file_name_widget) + file_layout.addWidget(file_button) + + self.string_options[name] = file_name_widget + self.rows[name] = (label, file_name_widget) + self.form_layout.addRow(label, file_layout) + + def add_folder_option( + self, + name: str, + description: str, + value: Optional[str] = "", + callback: Optional[Callable[..., None]] = None, + ) -> None: + if value is None: + value = "" + + label = QLabel(description, self) + folder_layout = QHBoxLayout() + folder_name_widget = QLineEdit(value, self) + folder_button = QPushButton(QIcon.fromTheme("folder-open"), "", self) + + folder_button.clicked.connect( + lambda: self.path_setter( + folder_name_widget, + QFileDialog.getExistingDirectory( + self, "Select Folder", directory=folder_name_widget.text() + ), + ) + ) + + if callback is not None: + folder_name_widget.textChanged.connect(callback) + + folder_layout.addWidget(folder_name_widget) + folder_layout.addWidget(folder_button) + + self.string_options[name] = folder_name_widget + self.rows[name] = (label, folder_name_widget) + self.form_layout.addRow(label, folder_layout) + + def add_int_option( + self, + name: str, + description: str, + value: Optional[int] = 0, + callback: Optional[Callable[..., None]] = None, + ) -> None: + if value is None: + value = 0 + + label = QLabel(description, self) + + self.int_options[name] = QSpinBox(self) + self.int_options[name].setValue(value) + self.form_layout.addRow(label, self.int_options[name]) + self.rows[name] = (label, self.int_options[name]) + if callback is not None: + self.int_options[name].textChanged.connect(callback) def del_list_element( self, @@ -165,6 +267,7 @@ class OptionFrame(QWidget): container_layout = QVBoxLayout() self.form_layout.addRow(label, container_layout) + self.rows[name] = (label, container_layout) self.list_options[name] = [] for v in value: @@ -177,8 +280,6 @@ class OptionFrame(QWidget): container_layout.addWidget(plus_button) - self.number_of_options += 1 - def add_choose_option( self, name: str, description: str, values: list[str], value: str = "" ) -> None: @@ -186,9 +287,9 @@ class OptionFrame(QWidget): self.choose_options[name] = QComboBox(self) self.choose_options[name].addItems(values) - self.choose_options[name].setCurrentText(value) + self.choose_options[name].setCurrentText(str(value)) self.form_layout.addRow(label, self.choose_options[name]) - self.number_of_options += 1 + self.rows[name] = (label, self.choose_options[name]) def add_date_time_option(self, name: str, description: str, value: str) -> None: label = QLabel(description, self) @@ -213,25 +314,27 @@ class OptionFrame(QWidget): date_time_layout.addWidget(date_time_enabled) self.form_layout.addRow(label, date_time_layout) - - self.number_of_options += 1 + self.rows[name] = (label, date_time_layout) def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.form_layout = QFormLayout(self) self.setLayout(self.form_layout) - self.number_of_options: int = 0 self.string_options: dict[str, QLineEdit] = {} + self.int_options: dict[str, QSpinBox] = {} self.choose_options: dict[str, QComboBox] = {} self.bool_options: dict[str, QCheckBox] = {} self.list_options: dict[str, list[QLineEdit]] = {} self.date_time_options: dict[str, tuple[QDateTimeEdit, QCheckBox]] = {} + self.rows: dict[str, tuple[QLabel, QWidget | QLayout]] = {} def get_config(self) -> dict[str, Any]: config: dict[str, Any] = {} for name, textbox in self.string_options.items(): config[name] = textbox.text().strip() + for name, textbox in self.int_options.items(): + config[name] = textbox.value() for name, optionmenu in self.choose_options.items(): config[name] = optionmenu.currentText().strip() @@ -260,17 +363,25 @@ class SourceTab(OptionFrame): super().__init__(parent) source = available_sources[source_name] self.vars: dict[str, str | bool | list[str]] = {} - for name, (typ, desc, default) in source.config_schema.items(): - value = config[name] if name in config else default - match typ: - case builtins.bool: - self.add_bool_option(name, desc, value=value) - case builtins.list: - self.add_list_option(name, desc, value=value) - case builtins.str: - self.add_string_option(name, desc, value=value) - case "password": - self.add_string_option(name, desc, value=value, is_password=True) + for name, option in source.config_schema.items(): + value = config[name] if name in config else option.default + match option.type: + case BoolOption(): + self.add_bool_option(name, option.description, value=value) + case ListStrOption(): + self.add_list_option(name, option.description, value=value) + case StrOption(): + self.add_string_option(name, option.description, value=value) + case IntOption(): + self.add_int_option(name, option.description, value=value) + case PasswordOption(): + self.add_string_option(name, option.description, value=value, is_password=True) + case FolderOption(): + self.add_folder_option(name, option.description, value=value) + case FileOption(): + self.add_file_option(name, option.description, value=value) + case ChoiceOption(choices): + self.add_choose_option(name, option.description, choices, value) class GeneralConfig(OptionFrame): @@ -292,8 +403,8 @@ class GeneralConfig(OptionFrame): str(config["waiting_room_policy"]).lower(), ) self.add_date_time_option("last_song", "Last song ends at", config["last_song"]) - self.add_string_option( - "preview_duration", "Preview duration in seconds", str(config["preview_duration"]) + self.add_int_option( + "preview_duration", "Preview duration in seconds", int(config["preview_duration"]) ) self.add_string_option( "key", "Key for server (if necessary)", config["key"], is_password=True @@ -302,11 +413,6 @@ class GeneralConfig(OptionFrame): def get_config(self) -> dict[str, Any]: config = super().get_config() - try: - config["preview_duration"] = int(config["preview_duration"]) - except ValueError: - config["preview_duration"] = 0 - return config diff --git a/syng/sources/filebased.py b/syng/sources/filebased.py index 314bae2..2f93c8c 100644 --- a/syng/sources/filebased.py +++ b/syng/sources/filebased.py @@ -4,6 +4,7 @@ import asyncio import os from typing import TYPE_CHECKING, Any, Optional + try: from pymediainfo import MediaInfo @@ -14,6 +15,7 @@ except ImportError: PYMEDIAINFO_AVAILABLE = False from .source import Source +from ..config import ListStrOption, ConfigOption class FileBasedSource(Source): @@ -25,8 +27,8 @@ class FileBasedSource(Source): """ config_schema = Source.config_schema | { - "extensions": ( - list, + "extensions": ConfigOption( + ListStrOption(), "List of filename extensions\n(mp3+cdg, mp4, ...)", ["mp3+cdg"], ) diff --git a/syng/sources/files.py b/syng/sources/files.py index 765aa8b..0215679 100644 --- a/syng/sources/files.py +++ b/syng/sources/files.py @@ -6,9 +6,11 @@ from typing import Any, Optional from typing import Tuple from platformdirs import user_cache_dir + from ..entry import Entry from .source import available_sources from .filebased import FileBasedSource +from ..config import FolderOption, ConfigOption class FilesSource(FileBasedSource): @@ -20,8 +22,8 @@ class FilesSource(FileBasedSource): source_name = "files" config_schema = FileBasedSource.config_schema | { - "dir": (str, "Directory to index", "."), - "index_file": (str, "Index file", os.path.join(user_cache_dir("syng"), "files-index")), + "dir": ConfigOption(FolderOption(), "Directory to index", "."), + # "index_file": ("file", "Index file", os.path.join(user_cache_dir("syng"), "files-index")), } def __init__(self, config: dict[str, Any]): diff --git a/syng/sources/s3.py b/syng/sources/s3.py index 2848bae..49bc391 100644 --- a/syng/sources/s3.py +++ b/syng/sources/s3.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Optional, Tuple, cast from platformdirs import user_cache_dir + try: from minio import Minio @@ -23,6 +24,7 @@ except ImportError: from ..entry import Entry from .filebased import FileBasedSource from .source import available_sources +from ..config import BoolOption, ConfigOption, FileOption, FolderOption, PasswordOption, StrOption class S3Source(FileBasedSource): @@ -40,13 +42,19 @@ class S3Source(FileBasedSource): source_name = "s3" config_schema = FileBasedSource.config_schema | { - "endpoint": (str, "Endpoint of the s3", ""), - "access_key": ("password", "Access Key of the s3", ""), - "secret_key": ("password", "Secret Key of the s3", ""), - "secure": (bool, "Use SSL", True), - "bucket": (str, "Bucket of the s3", ""), - "tmp_dir": (str, "Folder for\ntemporary download", user_cache_dir("syng")), - "index_file": (str, "Index file", os.path.join(user_cache_dir("syng"), "s3-index")), + "endpoint": ConfigOption(StrOption(), "Endpoint of the s3", ""), + "access_key": ConfigOption(StrOption(), "Access Key of the s3 (username)", ""), + "secret_key": ConfigOption(PasswordOption(), "Secret Key of the s3 (password)", ""), + "secure": ConfigOption(BoolOption(), "Use SSL", True), + "bucket": ConfigOption(StrOption(), "Bucket of the s3", ""), + "tmp_dir": ConfigOption( + FolderOption(), "Folder for\ntemporary download", user_cache_dir("syng") + ), + "index_file": ConfigOption( + FileOption(), + "Index file", + os.path.join(user_cache_dir("syng"), "s3-index"), + ), } def __init__(self, config: dict[str, Any]): diff --git a/syng/sources/source.py b/syng/sources/source.py index ad73f13..570b9eb 100644 --- a/syng/sources/source.py +++ b/syng/sources/source.py @@ -21,9 +21,11 @@ from typing import Tuple from typing import Type from abc import ABC, abstractmethod + from ..log import logger from ..entry import Entry from ..result import Result +from ..config import BoolOption, ConfigOption # logger: logging.Logger = logging.getLogger(__name__) @@ -106,8 +108,8 @@ class Source(ABC): """ source_name: str = "" - config_schema: dict[str, tuple[type | list[type] | str, str, Any]] = { - "enabled": (bool, "Enable this source", False) + config_schema: dict[str, ConfigOption[Any]] = { + "enabled": ConfigOption(BoolOption(), "Enable this source", False) } def __init__(self, config: dict[str, Any]): diff --git a/syng/sources/youtube.py b/syng/sources/youtube.py index 9375a8c..4eee4d4 100644 --- a/syng/sources/youtube.py +++ b/syng/sources/youtube.py @@ -18,9 +18,11 @@ from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError from platformdirs import user_cache_dir + from ..entry import Entry from ..result import Result from .source import Source, available_sources +from ..config import BoolOption, ChoiceOption, FolderOption, ListStrOption, Option, ConfigOption class YouTube: @@ -28,9 +30,9 @@ class YouTube: A minimal compatibility layer for the YouTube object of pytube, implemented via yt-dlp """ - __cache__: dict[ - str, Any - ] = {} # TODO: this may grow fast... but atm it fixed youtubes anti bot measures + __cache__: dict[str, Any] = ( + {} + ) # TODO: this may grow fast... but atm it fixed youtubes anti bot measures def __init__(self, url: Optional[str] = None): """ @@ -182,12 +184,18 @@ class YoutubeSource(Source): source_name = "youtube" config_schema = Source.config_schema | { - "enabled": (bool, "Enable this source", True), - "channels": (list, "A list channels\nto search in", []), - "tmp_dir": (str, "Folder for\ntemporary download", user_cache_dir("syng")), - "max_res": (int, "Maximum resolution\nto download", 720), - "start_streaming": ( - bool, + "enabled": ConfigOption(BoolOption(), "Enable this source", True), + "channels": ConfigOption(ListStrOption(), "A list channels\nto search in", []), + "tmp_dir": ConfigOption( + FolderOption(), "Folder for\ntemporary download", user_cache_dir("syng") + ), + "max_res": ConfigOption( + ChoiceOption(["144", "240", "360", "480", "720", "1080", "2160"]), + "Maximum resolution\nto download", + "720", + ), + "start_streaming": ConfigOption( + BoolOption(), "Start streaming if\ndownload is not complete", False, ), @@ -205,7 +213,10 @@ class YoutubeSource(Source): self.channels: list[str] = config["channels"] if "channels" in config else [] self.tmp_dir: str = config["tmp_dir"] if "tmp_dir" in config else "/tmp/syng" - self.max_res: int = config["max_res"] if "max_res" in config else 720 + try: + self.max_res: int = int(config["max_res"]) + except (ValueError, KeyError): + self.max_res = 720 self.start_streaming: bool = ( config["start_streaming"] if "start_streaming" in config else False )