commit 3ae3c02e8ba4edd10c978ea467fa1c2120d8881d Author: onyx-and-iris Date: Wed Jun 25 14:38:54 2025 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcad9ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm-project.org/#use-with-ide +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.envrc + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +bin/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f46e52 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# simple-recorder + +A single purpose application for naming a file recording in OBS. + +Run it as a CLI or a GUI. + +## Requirements + +- Python 3.11 or greater + +## Installation + +*with uv* + +```console +uv tool install simple-recorder +``` + +*with pipx* + +```console +pipx install simple-recorder +``` + +## Use + +Without passing a subcommand (start/stop) a GUI will be launched, otherwise a CLI will be launched. + +### GUI + +![simple-recorder](./img/simple-recorder.png) + +Just enter the filename and click *Start Recording*. + +### CLI + +```shell +Usage: simple-recorder [OPTIONS] COMMAND + +┏━ Subcommands ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ start Start recording ┃ +┃ stop Stop recording ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +┏━ Options ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃ --host ┃ +┃ --port ┃ +┃ --password ┃ +┃ --theme ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +``` + +```console +simple-recorder start "File Name" + +simple-recorder stop +``` + +If no filename is passed to start then you will be prompted for one. A default_name will be used if none is supplied to the prompt. diff --git a/img/simple-recorder.png b/img/simple-recorder.png new file mode 100755 index 0000000..c288b5e Binary files /dev/null and b/img/simple-recorder.png differ diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 0000000..f75f967 --- /dev/null +++ b/pdm.lock @@ -0,0 +1,98 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:7d4367f16062b5a2d661a207b7b33518aaea59371e239b8cf90a5b5b583e1abc" + +[[metadata.targets]] +requires_python = ">=3.11" + +[[package]] +name = "clypi" +version = "1.8.1" +requires_python = ">=3.11" +summary = "Your all-in-one for beautiful, lightweight, prod-ready CLIs" +groups = ["default"] +dependencies = [ + "python-dateutil>=2.9.0.post0", + "typing-extensions>=4.4.0", +] +files = [ + {file = "clypi-1.8.1-py3-none-any.whl", hash = "sha256:7218807e31f698fe5585c63d397645e481ca846d1491a4f1fde017c7892dbe3c"}, + {file = "clypi-1.8.1.tar.gz", hash = "sha256:9efa0a5a0e3668dd390e0d90321587dcb8eea12e28facd2ac437383f0de3dc76"}, +] + +[[package]] +name = "freesimplegui" +version = "5.2.0.post1" +summary = "The free-forever Python GUI framework." +groups = ["default"] +files = [ + {file = "freesimplegui-5.2.0.post1-py3-none-any.whl", hash = "sha256:3d61eb519324503232f86b2f1bd7f5c6813ce225f6e189d0fd737ddb036af4d5"}, + {file = "freesimplegui-5.2.0.post1.tar.gz", hash = "sha256:e58a0e6758e9a9e87152256911f94fcc3998356d1309973a9f4d9df2dc55f98a"}, +] + +[[package]] +name = "obsws-python" +version = "1.7.2" +requires_python = ">=3.9" +summary = "A Python SDK for OBS Studio WebSocket v5.0" +groups = ["default"] +dependencies = [ + "tomli>=2.0.1; python_version < \"3.11\"", + "websocket-client", +] +files = [ + {file = "obsws_python-1.7.2-py3-none-any.whl", hash = "sha256:acda31852ad9d7165de915b0603c13f6df527d3f61619970bf5fb562e300bc85"}, + {file = "obsws_python-1.7.2.tar.gz", hash = "sha256:b5cdaad30fbe1f6d4787b6530048b9882f070c3ee7830abb6dad4a47f84d7fa0"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Extensions to the standard Python datetime module" +groups = ["default"] +dependencies = [ + "six>=1.5", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "six" +version = "1.17.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +summary = "Python 2 and 3 compatibility utilities" +groups = ["default"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" +groups = ["default"] +files = [ + {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, + {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, +] + +[[package]] +name = "websocket-client" +version = "1.8.0" +requires_python = ">=3.8" +summary = "WebSocket client for Python with low level API options" +groups = ["default"] +files = [ + {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, + {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0cbe892 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "simple-recorder" +version = "0.1.0" +description = "A simple OBS recorder" +authors = [{ name = "onyx-and-iris", email = "code@onyxandiris.online" }] +dependencies = [ + "clypi>=1.8.1", + "FreeSimpleGUI>=5.2.0.post1", + "obsws-python>=1.7.2", +] +requires-python = ">=3.11" +readme = "README.md" +license = { text = "MIT" } + +[project.scripts] +simple-recorder = "simple_recorder:run" + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + + +[tool.pdm] +distribution = true + +[tool.pdm.scripts] +compile = "uvx shiv -c simple-recorder -o bin/simple-recorder-bin ." diff --git a/src/simple_recorder/__init__.py b/src/simple_recorder/__init__.py new file mode 100644 index 0000000..0565668 --- /dev/null +++ b/src/simple_recorder/__init__.py @@ -0,0 +1,3 @@ +from .app import run + +__all__ = ["run"] diff --git a/src/simple_recorder/app.py b/src/simple_recorder/app.py new file mode 100644 index 0000000..6515d39 --- /dev/null +++ b/src/simple_recorder/app.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime + +import FreeSimpleGUI as fsg +import obsws_python as obsws +from clypi import ClypiConfig, ClypiException, Command, Positional, arg, configure +from typing_extensions import override + +logger = logging.getLogger(__name__) + +config = ClypiConfig( + nice_errors=(ClypiException,), +) +configure(config) + + +class Start(Command): + """Start recording.""" + + filename: Positional[str] = arg( + default="default_name", + help="Name of the recording", + prompt="Enter the name for the recording", + ) + host: str = arg(inherited=True) + port: int = arg(inherited=True) + password: str = arg(inherited=True) + + @staticmethod + def get_timestamp(): + return datetime.now().strftime("%Y-%m-%d %H-%M-%S") + + @override + async def run(self): + if not self.filename: + raise ClypiException("Recording name cannot be empty.") + + with obsws.ReqClient( + host=self.host, port=self.port, password=self.password + ) as client: + resp = client.get_record_status() + if resp.output_active: + raise ClypiException("Recording is already active.") + + client.set_profile_parameter( + "Output", + "FilenameFormatting", + f"{self.filename} {self.get_timestamp()}", + ) + client.start_record() + + +class Stop(Command): + """Stop recording.""" + + host: str = arg(inherited=True) + port: int = arg(inherited=True) + password: str = arg(inherited=True) + + @override + async def run(self): + with obsws.ReqClient( + host=self.host, port=self.port, password=self.password + ) as client: + resp = client.get_record_status() + if not resp.output_active: + raise ClypiException("Recording is not active.") + + client.stop_record() + + +def theme_parser(value: str) -> str: + """Parse the theme argument.""" + themes = ["Light Purple", "Neutral Blue", "Reds", "Sandy Beach"] + if value not in themes: + raise ClypiException( + f"Invalid theme: {value}. Available themes: {', '.join(themes)}" + ) + return value + + +class SimpleRecorder(Command): + subcommand: Start | Stop | None = None + host: str = arg(default="localhost", env="OBS_HOST") + port: int = arg(default=4455, env="OBS_PORT") + password: str | None = arg(default=None, env="OBS_PASSWORD") + theme: str = arg(default="Reds", parser=theme_parser, env="OBS_THEME") + + @override + async def run(self): + fsg.theme(self.theme) + + input_text = fsg.InputText("", key="-FILENAME-") + start_record_button = fsg.Button("Start Recording", key="Start Recording") + stop_record_button = fsg.Button("Stop Recording", key="Stop Recording") + + layout = [ + [fsg.Text("Enter recording filename:")], + [input_text], + [start_record_button, stop_record_button], + [fsg.Text("Status: Not started", key="-OUTPUT-")], + ] + window = fsg.Window("Simple Recorder", layout, finalize=True) + status_text = window["-OUTPUT-"] + input_text.bind("", "-ENTER-") + start_record_button.bind("", "-ENTER-") + stop_record_button.bind("", "-ENTER-") + + while True: + event, values = window.read() + logger.debug(f"Event: {event}, Values: {values}") + if event == fsg.WIN_CLOSED: + break + elif event in ( + "Start Recording", + "Start Recording-ENTER-", + "-FILENAME--ENTER-", + ): + try: + await Start( + filename=input_text.get(), + host=self.host, + port=self.port, + password=self.password, + ).run() + status_text.update("Status: Recording started", text_color="green") + except ClypiException as e: + status_text.update(str(e), text_color="red") + logger.error(f"Error starting recording: {e}") + elif event in ("Stop Recording", "Stop Recording-ENTER-"): + try: + await Stop( + host=self.host, + port=self.port, + password=self.password, + ).run() + status_text.update("Status: Recording stopped", text_color="green") + except ClypiException as e: + status_text.update(str(e), text_color="red") + logger.error(f"Error stopping recording: {e}") + + +def run(): + """Run the CLI application.""" + SimpleRecorder.parse().start() + + +if __name__ == "__main__": + run() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29