From e48574b4ce21ad0b9a63d8e7ebfeb98d5404e49c Mon Sep 17 00:00:00 2001 From: Honbra Date: Sun, 19 Oct 2025 14:20:59 +0200 Subject: [PATCH] Music transcoder!! --- .gitignore | 219 +++++++++++++++++++++++++++++++++++ flake.lock | 61 ++++++++++ flake.nix | 26 +++++ music_transcoder/__main__.py | 107 +++++++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 .gitignore create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 music_transcoder/__main__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebcad40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,219 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$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 + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.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 +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# 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/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Direnv +.direnv diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9d47e88 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1759733170, + "narHash": "sha256-TXnlsVb5Z8HXZ6mZoeOAIwxmvGHp1g4Dw89eLvIwKVI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8913c168d1c56dc49a7718685968f38752171c3b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c0fbd81 --- /dev/null +++ b/flake.nix @@ -0,0 +1,26 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell rec { + buildInputs = [ + pkgs.opusTools + (pkgs.python3.withPackages (python-pkgs: with python-pkgs; [ + black + click + ])) + ]; + }; + } + ); +} diff --git a/music_transcoder/__main__.py b/music_transcoder/__main__.py new file mode 100644 index 0000000..1a36bb5 --- /dev/null +++ b/music_transcoder/__main__.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass +import click +import os +import subprocess + +INPUT_FILE_EXTS_OPUSENC = [ + ".flac", + ".wav", + ".aiff", +] +INPUT_FILE_EXTS_COPY = [ + ".opus", + ".ogg", # this can also contain FLAC (fry about it) + ".mp3", + ".m4a", +] + + +def get_opusenc_args( + input_path: str, + output_path: str, + bitrate: int = 192, +) -> list[str]: + return [ + "opusenc", + "--bitrate", + str(bitrate), + "--music", + input_path, + output_path, + ] + + +@dataclass +class InputFile: + filepath: str + relpath: str + relpath_noext: str + transcode: bool + + +@click.option( + "--input-dir", + "-i", + type=click.Path(exists=True, dir_okay=True, file_okay=False, resolve_path=True), +) +@click.option( + "--output-dir", + "-o", + type=click.Path( + exists=False, dir_okay=True, file_okay=False, writable=True, resolve_path=True + ), +) +@click.command() +def music_transcoder(input_dir, output_dir): + input_files: list[InputFile] = [] + + for dirpath, _, filenames in os.walk(input_dir): + for filename in filenames: + _, ext = os.path.splitext(filename) + + transcode: bool + if ext in INPUT_FILE_EXTS_OPUSENC: + transcode = True + elif ext in INPUT_FILE_EXTS_COPY: + transcode = False + else: + print("[WARN] rejecting file:", dirpath, filename) + continue + + filepath = os.path.join(dirpath, filename) + relpath = os.path.relpath(filepath, input_dir) + relpath_noext, _ = os.path.splitext(relpath) + input_files.append( + InputFile( + filepath=filepath, + relpath=relpath, + relpath_noext=relpath_noext, + transcode=transcode, + ) + ) + + for input_file in input_files: + if input_file.transcode: + output_path = os.path.join(output_dir, input_file.relpath_noext + ".opus") + else: + output_path = os.path.join(output_dir, input_file.relpath) + + if os.path.exists(output_path): + print("[WARN] skipping existing file", output_path) + continue + + output_path_dir, _ = os.path.split(output_path) + os.makedirs(output_path_dir, exist_ok=True) + + if not input_file.transcode: + print("[INFO] hardlinking lossy file →", output_path) + os.link(input_file.filepath, output_path) + continue + + print("[INFO] transcoding lossless file →", output_path) + opusenc_args = get_opusenc_args(input_file.filepath, output_path) + subprocess.run(opusenc_args, check=True) + + +if __name__ == "__main__": + music_transcoder()