From a997be4515c35bb57ad53de50734c413875aa92d Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Sun, 17 Jan 2021 00:14:53 -0600 Subject: [PATCH] Initial commit *Rupert* ("Ripper") is a tool to rip CD (and eventually DVD) media (almost) automatically. It converts CDDA to WAV using `cdparanoia`, encodes the output with `flac`, and adds metadata tags from MusicBrainz using *mutagen*. The console user interface is provided by *rich*. --- .gitignore | 2 + .pylintrc | 8 + .vscode/.ropeproject/config.py | 114 ++++++++ .vscode/.ropeproject/objectdb | Bin 0 -> 6 bytes .vscode/settings.json | 4 + mypy.ini | 25 ++ poetry.lock | 509 +++++++++++++++++++++++++++++++++ pyproject.toml | 42 +++ src/rupert/__init__.py | 0 src/rupert/disc.py | 83 ++++++ src/rupert/inotify.py | 249 ++++++++++++++++ src/rupert/main.py | 217 ++++++++++++++ src/rupert/musicbrainz.py | 77 +++++ src/rupert/ripper.py | 343 ++++++++++++++++++++++ 14 files changed, 1673 insertions(+) create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 .vscode/.ropeproject/config.py create mode 100644 .vscode/.ropeproject/objectdb create mode 100644 .vscode/settings.json create mode 100644 mypy.ini create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 src/rupert/__init__.py create mode 100644 src/rupert/disc.py create mode 100644 src/rupert/inotify.py create mode 100644 src/rupert/main.py create mode 100644 src/rupert/musicbrainz.py create mode 100644 src/rupert/ripper.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e79e5a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.venv +/dist diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..f15938e --- /dev/null +++ b/.pylintrc @@ -0,0 +1,8 @@ +[MASTER] +extension-pkg-whitelist = pydantic + +[MESSAGES CONTROL] +disable = + invalid-name, + no-else-return, + raise-missing-from, diff --git a/.vscode/.ropeproject/config.py b/.vscode/.ropeproject/config.py new file mode 100644 index 0000000..dee2d1a --- /dev/null +++ b/.vscode/.ropeproject/config.py @@ -0,0 +1,114 @@ +# The default ``config.py`` +# flake8: noqa + + +def set_prefs(prefs): + """This function is called before opening the project""" + + # Specify which files and folders to ignore in the project. + # Changes to ignored resources are not added to the history and + # VCSs. Also they are not returned in `Project.get_files()`. + # Note that ``?`` and ``*`` match all characters but slashes. + # '*.pyc': matches 'test.pyc' and 'pkg/test.pyc' + # 'mod*.pyc': matches 'test/mod1.pyc' but not 'mod/1.pyc' + # '.svn': matches 'pkg/.svn' and all of its children + # 'build/*.o': matches 'build/lib.o' but not 'build/sub/lib.o' + # 'build//*.o': matches 'build/lib.o' and 'build/sub/lib.o' + prefs['ignored_resources'] = ['*.pyc', '*~', '.ropeproject', + '.hg', '.svn', '_svn', '.git', '.tox'] + + # Specifies which files should be considered python files. It is + # useful when you have scripts inside your project. Only files + # ending with ``.py`` are considered to be python files by + # default. + # prefs['python_files'] = ['*.py'] + + # Custom source folders: By default rope searches the project + # for finding source folders (folders that should be searched + # for finding modules). You can add paths to that list. Note + # that rope guesses project source folders correctly most of the + # time; use this if you have any problems. + # The folders should be relative to project root and use '/' for + # separating folders regardless of the platform rope is running on. + # 'src/my_source_folder' for instance. + # prefs.add('source_folders', 'src') + + # You can extend python path for looking up modules + # prefs.add('python_path', '~/python/') + + # Should rope save object information or not. + prefs['save_objectdb'] = True + prefs['compress_objectdb'] = False + + # If `True`, rope analyzes each module when it is being saved. + prefs['automatic_soa'] = True + # The depth of calls to follow in static object analysis + prefs['soa_followed_calls'] = 0 + + # If `False` when running modules or unit tests "dynamic object + # analysis" is turned off. This makes them much faster. + prefs['perform_doa'] = True + + # Rope can check the validity of its object DB when running. + prefs['validate_objectdb'] = True + + # How many undos to hold? + prefs['max_history_items'] = 32 + + # Shows whether to save history across sessions. + prefs['save_history'] = True + prefs['compress_history'] = False + + # Set the number spaces used for indenting. According to + # :PEP:`8`, it is best to use 4 spaces. Since most of rope's + # unit-tests use 4 spaces it is more reliable, too. + prefs['indent_size'] = 4 + + # Builtin and c-extension modules that are allowed to be imported + # and inspected by rope. + prefs['extension_modules'] = [] + + # Add all standard c-extensions to extension_modules list. + prefs['import_dynload_stdmods'] = True + + # If `True` modules with syntax errors are considered to be empty. + # The default value is `False`; When `False` syntax errors raise + # `rope.base.exceptions.ModuleSyntaxError` exception. + prefs['ignore_syntax_errors'] = False + + # If `True`, rope ignores unresolvable imports. Otherwise, they + # appear in the importing namespace. + prefs['ignore_bad_imports'] = False + + # If `True`, rope will insert new module imports as + # `from import ` by default. + prefs['prefer_module_from_imports'] = False + + # If `True`, rope will transform a comma list of imports into + # multiple separate import statements when organizing + # imports. + prefs['split_imports'] = False + + # If `True`, rope will remove all top-level import statements and + # reinsert them at the top of the module when making changes. + prefs['pull_imports_to_top'] = True + + # If `True`, rope will sort imports alphabetically by module name instead + # of alphabetically by import statement, with from imports after normal + # imports. + prefs['sort_imports_alphabetically'] = False + + # Location of implementation of + # rope.base.oi.type_hinting.interfaces.ITypeHintingFactory In general + # case, you don't have to change this value, unless you're an rope expert. + # Change this value to inject you own implementations of interfaces + # listed in module rope.base.oi.type_hinting.providers.interfaces + # For example, you can add you own providers for Django Models, or disable + # the search type-hinting in a class hierarchy, etc. + prefs['type_hinting_factory'] = ( + 'rope.base.oi.type_hinting.factory.default_type_hinting_factory') + + +def project_opened(project): + """This function is called after opening the project""" + # Do whatever you like here! diff --git a/.vscode/.ropeproject/objectdb b/.vscode/.ropeproject/objectdb new file mode 100644 index 0000000000000000000000000000000000000000..0a47446c0ad231c193bdd44ff327ba2ab28bf3d8 GIT binary patch literal 6 NcmZo*sx4&D0{{kv0iOT> literal 0 HcmV?d00001 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..658c804 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.pythonPath": ".venv/bin/python", + "python.linting.mypyEnabled": true, +} diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..0c980f3 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,25 @@ +[mypy] +allow_redefinition = False +allow_untyped_globals = False +check_untyped_defs = True +disallow_any_generics = True +disallow_incomplete_defs = True +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_decorators = True +disallow_untyped_defs = True +disallow_untyped_defs = True +ignore_missing_imports = True +implicit_reexport = False +local_partial_types = False +namespace_packages = True +no_implicit_optional = True +strict_equality = True +strict_optional = True +warn_no_return = True +warn_redundant_cass = True +warn_return_any = True +warn_return_any = True +warn_unreachable = True +warn_unused_configs = True +warn_unused_ignores = True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..3b142bf --- /dev/null +++ b/poetry.lock @@ -0,0 +1,509 @@ +[[package]] +name = "astroid" +version = "2.4.2" +description = "An abstract syntax tree for Python with inference support." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +lazy-object-proxy = ">=1.4.0,<1.5.0" +six = ">=1.12,<2.0" +typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} +wrapt = ">=1.11,<2.0" + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[[package]] +name = "importlib-metadata" +version = "3.3.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "isort" +version = "5.7.0" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] + +[[package]] +name = "lazy-object-proxy" +version = "1.4.3" +description = "A fast and thorough lazy object proxy." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "musicbrainzngs" +version = "0.7.1" +description = "Python bindings for the MusicBrainz NGS and the Cover Art Archive webservices" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "mutagen" +version = "1.45.1" +description = "read and write audio tags for many formats" +category = "main" +optional = false +python-versions = ">=3.5, <4" + +[[package]] +name = "mypy" +version = "0.790" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pydantic" +version = "1.7.3" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pygments" +version = "2.7.3" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "pylint" +version = "2.6.0" +description = "python code static checker" +category = "dev" +optional = false +python-versions = ">=3.5.*" + +[package.dependencies] +astroid = ">=2.4.0,<=2.5" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +isort = ">=4.2.5,<6" +mccabe = ">=0.6,<0.7" +toml = ">=0.7.1" + +[[package]] +name = "python-libdiscid" +version = "1.1" +description = "Python bindings for libdiscid" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyudev" +version = "0.22.0" +description = "A libudev binding" +category = "main" +optional = true +python-versions = "*" + +[package.dependencies] +six = "*" + +[[package]] +name = "rich" +version = "9.6.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +colorama = ">=0.4.0,<0.5.0" +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" +typing-extensions = ">=3.7.4,<4.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] + +[[package]] +name = "rope" +version = "0.18.0" +description = "a python refactoring library..." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +dev = ["pytest"] + +[[package]] +name = "six" +version = "1.15.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "typed-ast" +version = "1.4.2" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typer" +version = "0.3.2" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +click = ">=7.1.1,<7.2.0" + +[package.extras] +test = ["pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.782)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)", "shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)"] +all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"] + +[[package]] +name = "typing-extensions" +version = "3.7.4.3" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "wrapt" +version = "1.12.1" +description = "Module for decorators, wrappers and monkey patching." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "zipp" +version = "3.4.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[extras] +udev = ["pyudev"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "f3ded121ca6cca186d5e649e2a1f73136e28ed4d57cae8118fb7d97f5c236c80" + +[metadata.files] +astroid = [ + {file = "astroid-2.4.2-py3-none-any.whl", hash = "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386"}, + {file = "astroid-2.4.2.tar.gz", hash = "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] +flake8 = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] +importlib-metadata = [ + {file = "importlib_metadata-3.3.0-py3-none-any.whl", hash = "sha256:bf792d480abbd5eda85794e4afb09dd538393f7d6e6ffef6e9f03d2014cf9450"}, + {file = "importlib_metadata-3.3.0.tar.gz", hash = "sha256:5c5a2720817414a6c41f0a49993908068243ae02c1635a228126519b509c8aed"}, +] +isort = [ + {file = "isort-5.7.0-py3-none-any.whl", hash = "sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc"}, + {file = "isort-5.7.0.tar.gz", hash = "sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e"}, +] +lazy-object-proxy = [ + {file = "lazy-object-proxy-1.4.3.tar.gz", hash = "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win32.whl", hash = "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27m-win_amd64.whl", hash = "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a"}, + {file = "lazy_object_proxy-1.4.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d"}, + {file = "lazy_object_proxy-1.4.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a"}, + {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win32.whl", hash = "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e"}, + {file = "lazy_object_proxy-1.4.3-cp34-cp34m-win_amd64.whl", hash = "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357"}, + {file = "lazy_object_proxy-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50"}, + {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db"}, + {file = "lazy_object_proxy-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449"}, + {file = "lazy_object_proxy-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156"}, + {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531"}, + {file = "lazy_object_proxy-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142"}, + {file = "lazy_object_proxy-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea"}, + {file = "lazy_object_proxy-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62"}, + {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, + {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +musicbrainzngs = [ + {file = "musicbrainzngs-0.7.1-py2.py3-none-any.whl", hash = "sha256:e841a8f975104c0a72290b09f59326050194081a5ae62ee512f41915090e1a10"}, + {file = "musicbrainzngs-0.7.1.tar.gz", hash = "sha256:ab1c0100fd0b305852e65f2ed4113c6de12e68afd55186987b8ed97e0f98e627"}, +] +mutagen = [ + {file = "mutagen-1.45.1-py3-none-any.whl", hash = "sha256:9c9f243fcec7f410f138cb12c21c84c64fde4195481a30c9bfb05b5f003adfed"}, + {file = "mutagen-1.45.1.tar.gz", hash = "sha256:6397602efb3c2d7baebd2166ed85731ae1c1d475abca22090b7141ff5034b3e1"}, +] +mypy = [ + {file = "mypy-0.790-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:bd03b3cf666bff8d710d633d1c56ab7facbdc204d567715cb3b9f85c6e94f669"}, + {file = "mypy-0.790-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2170492030f6faa537647d29945786d297e4862765f0b4ac5930ff62e300d802"}, + {file = "mypy-0.790-cp35-cp35m-win_amd64.whl", hash = "sha256:e86bdace26c5fe9cf8cb735e7cedfe7850ad92b327ac5d797c656717d2ca66de"}, + {file = "mypy-0.790-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e97e9c13d67fbe524be17e4d8025d51a7dca38f90de2e462243ab8ed8a9178d1"}, + {file = "mypy-0.790-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0d34d6b122597d48a36d6c59e35341f410d4abfa771d96d04ae2c468dd201abc"}, + {file = "mypy-0.790-cp36-cp36m-win_amd64.whl", hash = "sha256:72060bf64f290fb629bd4a67c707a66fd88ca26e413a91384b18db3876e57ed7"}, + {file = "mypy-0.790-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:eea260feb1830a627fb526d22fbb426b750d9f5a47b624e8d5e7e004359b219c"}, + {file = "mypy-0.790-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c614194e01c85bb2e551c421397e49afb2872c88b5830e3554f0519f9fb1c178"}, + {file = "mypy-0.790-cp37-cp37m-win_amd64.whl", hash = "sha256:0a0d102247c16ce93c97066443d11e2d36e6cc2a32d8ccc1f705268970479324"}, + {file = "mypy-0.790-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cf4e7bf7f1214826cf7333627cb2547c0db7e3078723227820d0a2490f117a01"}, + {file = "mypy-0.790-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:af4e9ff1834e565f1baa74ccf7ae2564ae38c8df2a85b057af1dbbc958eb6666"}, + {file = "mypy-0.790-cp38-cp38-win_amd64.whl", hash = "sha256:da56dedcd7cd502ccd3c5dddc656cb36113dd793ad466e894574125945653cea"}, + {file = "mypy-0.790-py3-none-any.whl", hash = "sha256:2842d4fbd1b12ab422346376aad03ff5d0805b706102e475e962370f874a5122"}, + {file = "mypy-0.790.tar.gz", hash = "sha256:2b21ba45ad9ef2e2eb88ce4aeadd0112d0f5026418324176fd494a6824b74975"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pydantic = [ + {file = "pydantic-1.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c59ea046aea25be14dc22d69c97bee629e6d48d2b2ecb724d7fe8806bf5f61cd"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a4143c8d0c456a093387b96e0f5ee941a950992904d88bc816b4f0e72c9a0009"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:d8df4b9090b595511906fa48deda47af04e7d092318bfb291f4d45dfb6bb2127"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:514b473d264671a5c672dfb28bdfe1bf1afd390f6b206aa2ec9fed7fc592c48e"}, + {file = "pydantic-1.7.3-cp36-cp36m-win_amd64.whl", hash = "sha256:dba5c1f0a3aeea5083e75db9660935da90216f8a81b6d68e67f54e135ed5eb23"}, + {file = "pydantic-1.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59e45f3b694b05a69032a0d603c32d453a23f0de80844fb14d55ab0c6c78ff2f"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5b24e8a572e4b4c18f614004dda8c9f2c07328cb5b6e314d6e1bbd536cb1a6c1"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:b2b054d095b6431cdda2f852a6d2f0fdec77686b305c57961b4c5dd6d863bf3c"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:025bf13ce27990acc059d0c5be46f416fc9b293f45363b3d19855165fee1874f"}, + {file = "pydantic-1.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:6e3874aa7e8babd37b40c4504e3a94cc2023696ced5a0500949f3347664ff8e2"}, + {file = "pydantic-1.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e682f6442ebe4e50cb5e1cfde7dda6766fb586631c3e5569f6aa1951fd1a76ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:185e18134bec5ef43351149fe34fda4758e53d05bb8ea4d5928f0720997b79ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:f5b06f5099e163295b8ff5b1b71132ecf5866cc6e7f586d78d7d3fd6e8084608"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:24ca47365be2a5a3cc3f4a26dcc755bcdc9f0036f55dcedbd55663662ba145ec"}, + {file = "pydantic-1.7.3-cp38-cp38-win_amd64.whl", hash = "sha256:d1fe3f0df8ac0f3a9792666c69a7cd70530f329036426d06b4f899c025aca74e"}, + {file = "pydantic-1.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6864844b039805add62ebe8a8c676286340ba0c6d043ae5dea24114b82a319e"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ecb54491f98544c12c66ff3d15e701612fc388161fd455242447083350904730"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:ffd180ebd5dd2a9ac0da4e8b995c9c99e7c74c31f985ba090ee01d681b1c4b95"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8d72e814c7821125b16f1553124d12faba88e85405b0864328899aceaad7282b"}, + {file = "pydantic-1.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:475f2fa134cf272d6631072554f845d0630907fce053926ff634cc6bc45bf1af"}, + {file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"}, + {file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pygments = [ + {file = "Pygments-2.7.3-py3-none-any.whl", hash = "sha256:f275b6c0909e5dafd2d6269a656aa90fa58ebf4a74f8fcf9053195d226b24a08"}, + {file = "Pygments-2.7.3.tar.gz", hash = "sha256:ccf3acacf3782cbed4a989426012f1c535c9a90d3a7fc3f16d231b9372d2b716"}, +] +pylint = [ + {file = "pylint-2.6.0-py3-none-any.whl", hash = "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f"}, + {file = "pylint-2.6.0.tar.gz", hash = "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210"}, +] +python-libdiscid = [ + {file = "python-libdiscid-1.1.tar.gz", hash = "sha256:c342531ca6cf0c0ed7890515d135e6d16199d81040189c8653002327f7e7229a"}, +] +pyudev = [ + {file = "pyudev-0.22.0.tar.gz", hash = "sha256:69bb1beb7ac52855b6d1b9fe909eefb0017f38d917cba9939602c6880035b276"}, +] +rich = [ + {file = "rich-9.6.2-py3-none-any.whl", hash = "sha256:e0efd2ba715dcfb78e57986e15c6d70a3beb98a7015471ca9dd511571a8a9882"}, + {file = "rich-9.6.2.tar.gz", hash = "sha256:b6a7f9ef1a35c248498952d3454fb4f88de415dd989f97c3e5c5e2235d66e3a5"}, +] +rope = [ + {file = "rope-0.18.0.tar.gz", hash = "sha256:786b5c38c530d4846aa68a42604f61b4e69a493390e3ca11b88df0fbfdc3ed04"}, +] +six = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +typed-ast = [ + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, + {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, + {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, + {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, + {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, + {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, + {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, + {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, + {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, + {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, + {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, + {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, + {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, + {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, + {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, + {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, + {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, + {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, + {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, + {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, + {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, +] +typer = [ + {file = "typer-0.3.2-py3-none-any.whl", hash = "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b"}, + {file = "typer-0.3.2.tar.gz", hash = "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, + {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, + {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, +] +wrapt = [ + {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, +] +zipp = [ + {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, + {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa7a167 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[tool.poetry] +name = "rupert" +version = "0.0.0" +description = "" +authors = ["Dustin C. Hatch "] + +[tool.poetry.dependencies] +python = "^3.7" +pyudev = { version = "^0.22.0", optional = true } +rich = "^9.6.2" +typer = "^0.3.2" +pydantic = "^1.7.3" +python-libdiscid = "^1.1" +musicbrainzngs = "^0.7.1" +mutagen = "^1.45.1" + +[tool.poetry.dev-dependencies] +pylint = "^2.6.0" +mypy = "^0.790" +flake8 = "^3.8.4" +rope = "^0.18.0" + +[tool.poetry.scripts] +ripper = "rupert.main:main" + +[tool.poetry.extras] +udev = ["pyudev"] + +[tool.black] +line-length = 79 + +[tool.isort] +ensure_newline_before_comments = true +force_grid_wrap = 0 +include_trailing_comma = true +line_length = 79 +lines_after_imports = 2 +multi_line_output = 3 + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/src/rupert/__init__.py b/src/rupert/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/rupert/disc.py b/src/rupert/disc.py new file mode 100644 index 0000000..4a742c1 --- /dev/null +++ b/src/rupert/disc.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + + +try: + from libdiscid.compat import discid +except ModuleNotFoundError: + import discid + +try: + import pyudev +except ModuleNotFoundError: + pyudev = None + + +log = logging.getLogger(__name__) + + +OFFSETS = {'ASUS_BW-12B1ST_a': 6, 'ATAPI_iHES212_3': 702} + + +@dataclass +class Disc: + disc_id: str + toc_string: str + + +@dataclass +class DiscDrive: + device_node: Path + model: str + offset: Optional[int] + + @classmethod + def from_path(cls, path: Optional[Path]) -> DiscDrive: + if pyudev is not None: + return cls._from_udev(path) + elif path is not None: + return cls._from_sysfs(path) + else: + raise FileNotFoundError( + 'No CD-ROM device specifed and missing udev support' + ) + + @classmethod + def _from_udev(cls, path: Optional[Path]) -> DiscDrive: + udev = pyudev.Context() + if path: + path_str = path.as_posix() + try: + dev = pyudev.Device.from_device_file(udev, path_str) + except pyudev.DeviceNotFoundError: + raise FileNotFoundError(path_str) + else: + block_devices = udev.list_devices(subsystem='block') + try: + dev = next(iter(block_devices.match_property('ID_CDROM', 1))) + except StopIteration: + raise FileNotFoundError('No CD-ROM device found') + model = dev.properties.get('ID_MODEL') + return cls( + device_node=Path(dev.device_node), + model=model, + offset=OFFSETS.get(model), + ) + + @classmethod + def _from_sysfs(cls, path: Path) -> DiscDrive: + sysfs_path = Path('/sys/block') / path.name + with (sysfs_path / 'device/model').open() as f: + model = '_'.join(f.read().strip().split()) + with (sysfs_path / 'device/vendor').open() as f: + vendor = '_'.join(f.read().strip().split()) + model = f'{vendor}_{model}' + return cls(device_node=path, model=model, offset=OFFSETS.get(model)) + + def get_disc(self) -> Disc: + disc = discid.read(str(self.device_node)) + return Disc(disc_id=disc.id, toc_string=disc.toc_string) diff --git a/src/rupert/inotify.py b/src/rupert/inotify.py new file mode 100644 index 0000000..86a12cd --- /dev/null +++ b/src/rupert/inotify.py @@ -0,0 +1,249 @@ +# Copyright 2016 FireMon. All rights reserved. +# +# This file is a part of the FireMon codebase. The contents of this +# file are confidential and cannot be distributed without prior +# written authorization. +# +# Warning: This computer program is protected by copyright law and +# international treaties. Unauthorized reproduction or distribution of +# this program, or any portion of it, may result in severe civil and +# criminal penalties, and will be prosecuted to the maximum extent +# possible under the law. +'''\ +This module provides Python bindings for the Linux inotify system, which +is a means for receiving events about file access and modification. + +All inotify operations are handled by the :py:class:`Inotify` class. +When an instance is created, the inotify system is initialized and an +inotify file descriptor is assigned. + +To begin watching files, use the :py:meth:`Inotify.add_watch` method. +Messages can then be obtained by iterating over the results of the +:py:meth:`Inotify.read` method. + +>>> inot = Inotify() +>>> inot.add_watch('/tmp', IN_CREATE | IN_MOVE) +>>> for event in inot.read(): +... print(event) + +The :py:meth:`Inotify.read` method will block until an event is +received. To avoid blocking, use an I/O multiplexing mechanism such as +:py:func:`~select.select` or :py:class:`~select.epoll`. +:py:class:`Inotify` instances can be passed directly to these +mechanisms, or the underlying file descriptor can be obtained by calling +the :py:meth:`Inotify.fileno` method. +''' + +import collections +import ctypes.util +import os +import struct + + +_libc = ctypes.CDLL(ctypes.util.find_library('c')) + +_errno = _libc.__errno_location +_errno.restype = ctypes.POINTER(ctypes.c_int) + +_libc.inotify_add_watch.argtypes = (ctypes.c_int, ctypes.c_char_p, + ctypes.c_uint32) +_libc.inotify_rm_watch.argtypes = (ctypes.c_int, ctypes.c_int) + +IN_NONBLOCK = 0x800 +IN_CLOEXEC = 0x80000 + +#: File was accessed. +IN_ACCESS = 0x1 +#: File was modified. +IN_MODIFY = 0x2 +#: Metadata changed. +IN_ATTRIB = 0x4 +#: Writtable file was closed. +IN_CLOSE_WRITE = 0x8 +#: Unwrittable file closed. +IN_CLOSE_NOWRITE = 0x10 +#: Close. +IN_CLOSE = IN_CLOSE_WRITE | IN_CLOSE_NOWRITE +#: File was opened. +IN_OPEN = 0x20 +#: File was moved from X. +IN_MOVED_FROM = 0x40 +#: File was moved to Y. +IN_MOVED_TO = 0x80 +#: Moves. +IN_MOVE = IN_MOVED_FROM | IN_MOVED_TO +#: Subfile was created. +IN_CREATE = 0x100 +#: Subfile was deleted. +IN_DELETE = 0x200 +#: Self was deleted. +IN_DELETE_SELF = 0x400 +#: Self was moved. +IN_MOVE_SELF = 0x800 + +#: Backing fs was unmounted. +IN_UNMOUNT = 0x2000 +#: Event queued overflowed. +IN_Q_OVERFLOW = 0x4000 +#: File was ignored. +IN_IGNORED = 0x8000 + +#: Only watch the path if it is a directory. +IN_ONLYDIR = 0x1000000 +#: DO not follow a sym link. +IN_DONT_FOLLOW = 0x2000000 +#: Exclude events on unlinked objects. +IN_EXCL_UNLINK = 0x4000000 +#: Add the mask of an already existing watch. +IN_MASK_ADD = 0x20000000 +#: Event occurred against dir. +IN_ISDIR = 0x40000000 +#: Only send event once. +IN_ONESHOT = 0x80000000 + +#: All events which a program can wait on. +IN_ALL_EVENTS = (IN_ACCESS | IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | + IN_CLOSE_NOWRITE | IN_OPEN | IN_MOVED_FROM | IN_MOVED_TO | + IN_CREATE | IN_DELETE | IN_DELETE_SELF | IN_MOVE_SELF) + + +class InotifyError(OSError): + '''Raised when an error is returned by inotify''' + + @classmethod + def from_c_err(cls): + errno = _errno().contents.value + return cls(errno, os.strerror(errno)) + + +class Inotify(object): + '''Wrapper class for Linux inotify capabilities''' + + STRUCT_FMT = '@iIII' + STRUCT_SIZE = struct.calcsize(STRUCT_FMT) + BUFSIZE = STRUCT_SIZE + 256 + + def __init__(self): + fd = _libc.inotify_init() + if fd == -1: + raise InotifyError.from_c_err() + self.__fd = fd + self.__watches = {} + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, tb): + return False + + def fileno(self): + '''Return the underlying inotify file descriptor''' + + return self.__fd + + def add_watch(self, pathname, mask): + '''Add a new watch + + :param pathname: The path to the file or directory to watch + :param mask: The events to watch, as a bit field + :returns: The new watch descriptor + :raises: :py:exc:`InotifyError` + ''' + + wd = _libc.inotify_add_watch(self.__fd, pathname, mask) + if wd == -1: + raise InotifyError.from_c_err() + self.__watches[wd] = pathname + return wd + + def rm_watch(self, wd): + '''Remove an existing watch + + :param wd: The watch descriptor to remove + :raises: :py:exc:`InotifyError` + ''' + + ret = _libc.inotify_rm_watch(self.__fd, wd) + if ret == -1: + raise InotifyError.from_c_err() + + def read(self): + '''Iterate over received events + + :returns: An iterator that yields :py:class:`Event` objects + + This method returns an iterator for all of the events received + in a single batch. Iterating over the returned value will block + until an event is received + ''' + + buf = memoryview(os.read(self.__fd, self.BUFSIZE)) + nread = len(buf) + pos = 0 + while pos < nread: + packed = buf[pos:pos + self.STRUCT_SIZE] + pos += self.STRUCT_SIZE + wd, mask, cookie, sz = self._unpack(packed) + if sz: + name = buf[pos:pos + sz].tobytes() + name = name[:name.index(b'\x00')] + pos += sz + else: + name = None + pathname = self.__watches[wd] + yield Event(wd, mask, cookie, name, pathname) + + def close(self): + '''Close all watch descriptors and the inotify descriptor''' + + for wd in self.__watches: + try: + self.rm_watch(wd) + except: + pass + os.close(self.__fd) + + @classmethod + def _unpack(cls, buf): + return struct.unpack(cls.STRUCT_FMT, buf[:cls.STRUCT_SIZE]) + + +Event = collections.namedtuple('Event', ( + 'wd', + 'mask', + 'cookie', + 'name', + 'pathname', +)) +'''A tuple containing information about a single event + +Each tuple contains the following items: + +.. py:attribute:: wd + + identifies the watch for which this event occurs. It is one of the + watch descriptors returned by a previous call to + :py:meth:`Inotify.add_watch` + +.. py:attribute:: mask + + contains bits that describe the event that occurred + +.. py:attribute:: cookie + + A unique integer that connects related events. Currently this is used + only for rename events, and allows the resulting pair of + :py:data:`IN_MOVED_FROM` and :py:data`IN_MOVED_TO` events to be + connected by the application. For all other event types, cookie is set + to ``0``. + +.. py:attribute:: name + + The ``name`` field is present only when an event is returned for a + file inside a watched directory; it identifies the filename within to + the watched directory. + +.. py:attribute:: pathname + + The path of the watched file or directory that emitted the event +''' diff --git a/src/rupert/main.py b/src/rupert/main.py new file mode 100644 index 0000000..0f37e35 --- /dev/null +++ b/src/rupert/main.py @@ -0,0 +1,217 @@ +import logging +import os +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +import typer +from rich.console import Console, ConsoleOptions, RenderResult +from rich.live import Live +from rich.logging import RichHandler +from rich.measure import Measurement +from rich.prompt import Prompt +from rich.spinner import Spinner +from rich.table import Table +from rich.text import Text + +from .disc import DiscDrive +from .musicbrainz import Release, get_release_by_id, get_releases_from_disc +from .ripper import Ripper, TrackStatus + + +RELEASE_INFO_TMPL = ( + '[bright_white][b]{artist} - [i]{title}[/i][/b][/bright_white] ' + '([magenta]{year}[/magenta]): [gray]{more_info}[/gray]' +) + + +class Status: + def __init__(self, status: TrackStatus) -> None: + self.running = True + self.set_status(status) + + def __rich_console__( + self, + console: Console, + options: ConsoleOptions, # pylint: disable=unused-argument + ) -> RenderResult: + time = console.get_time() + if self.spinner.start_time is None: + self.spinner.start_time = time + yield self.render(time - self.spinner.start_time) + + def __rich_measure__( + self, console: Console, max_width: int + ) -> Measurement: + text = self.render(0) + return Measurement.get(console, text, max_width) + + def set_status(self, status: TrackStatus) -> None: + if status is TrackStatus.done: + self.running = False + return + elif status is TrackStatus.encoding: + spinner = 'arrow' + else: + spinner = 'arc' + text = f'{status.value} ...' + self.spinner = Spinner(spinner, text, style='status.spinner') + + def render(self, time: float) -> Text: + if self.running: + return self.spinner.render(time) + else: + return Text('Done!') + + +def format_release(release: Release) -> str: + more_info = [ + '[bright_blue]{} disc(s)[/bright_blue]'.format( + len(release.medium_list) + ) + ] + if release.packaging: + more_info.append(f'[bright_red]{release.packaging}[/bright_red]') + if release.country: + more_info.append(f'[bright_green]{release.country}[/bright_green]') + if release.label_info: + for label_info in release.label_info: + if label_info.catalog_number: + more_info.append( + f'[bright_cyan]{label_info.catalog_number}[/bright_cyan]' + ) + break + return RELEASE_INFO_TMPL.format( + artist=release.artist_credit_phrase, + title=release.title, + year=release.date.year, + more_info=', '.join(more_info), + ) + + +def prompt_menu(console: Console, choices: Iterable[Any]) -> int: + max_ = 0 + for idx, choice in enumerate(choices): + console.print(f'[bright_yellow]{idx + 1})[/bright_yellow]: {choice}') + max_ += 1 + while 1: + choice = Prompt.ask('Selection') + try: + i = int(choice) + except ValueError: + console.print(f'[red]Invalid input: {choice}[/red]') + continue + if i < 1 or i > max_: + console.print(f'[red]Invalid selection: {i}[/red]') + continue + return i - 1 + + +def prompt_release(console: Console, drive: DiscDrive) -> Release: + releases = get_releases_from_disc(drive.get_disc()).release_list + if not releases: + console.print('[red]Could not find a matching MusicBrainz release') + raise SystemExit(1) + if len(releases) == 1: + return releases[0] + console.print( + 'Multiple matching releases found. ' + 'Please select the correct release' + ) + choice = prompt_menu(console, (format_release(r) for r in releases)) + return releases[choice] + + +def prompt_select_disc(console: Console, num_discs: int) -> int: + console.print( + 'Found part of a multi-disc album. Please select the disc number' + ) + return prompt_menu(console, (f'Disc {x}' for x in range(1, num_discs + 1))) + + +def run( + tracks: Optional[List[str]] = typer.Option( + None, + '-t', + '--tracks', + metavar='TRACKS', + help='Select tracks/track sequences (e.g. 1, 1-, 5-9)', + ), + use_libcdio: bool = typer.Option( + False, help='Use cd-paranoia from libcdio instead of cdparanoia' + ), + device: Optional[Path] = typer.Option( + None, + '--device', + '-d', + metavar='PATH', + help='Path to the CD-ROM device', + ), + mbid: Optional[str] = typer.Option(None, help='MusicBrainz release ID'), + verbose: int = typer.Option( + 0, + '--verbose', + '-v', + count=True, + help='Increase log level (can be repated)', + ), +): + console = Console(highlight=False) + if verbose < 1: + level = logging.WARNING + elif verbose < 2: + level = logging.INFO + else: + level = logging.DEBUG + logging.basicConfig( + level=level, + format='%(threadName)s [%(name)s] %(message)s', + datefmt='[%X]', + handlers=[RichHandler(console=console, show_path=False)], + ) + logging.getLogger('musicbrainzngs').setLevel(logging.ERROR) + try: + dev = DiscDrive.from_path(device) + except FileNotFoundError as e: + console.print(f'[bold red]File not found: {e}') + raise SystemExit(os.EX_OSFILE) + + if mbid is not None: + release = get_release_by_id(mbid) + else: + release = prompt_release(console, dev) + console.print(f'Ripping {format_release(release)}') + + num_discs = len(release.medium_list) + if len(release.medium_list) > 1: + discno = prompt_select_disc(console, num_discs) + else: + discno = 0 + + table = Table() + table.add_column("Track", justify='right') + table.add_column("Status", min_width=14) + + with Live(table, console=console, refresh_per_second=12): + ripper = Ripper(dev, release, discno, tracks or None, use_libcdio) + trackdict: Dict[int, Status] = {} + for track, status in ripper.rip(): + if track is None: + assert not isinstance(status, TrackStatus) + console.print( + status[0], style='bold red' if status[1] else None + ) + elif track in trackdict: + assert isinstance(status, TrackStatus) + trackdict[track].set_status(status) + else: + assert isinstance(status, TrackStatus) + trackdict[track] = s = Status(status) + table.add_row(str(track), s) + + +def main(): + typer.run(run) + + +if __name__ == "__main__": + main() diff --git a/src/rupert/musicbrainz.py b/src/rupert/musicbrainz.py new file mode 100644 index 0000000..6644bbc --- /dev/null +++ b/src/rupert/musicbrainz.py @@ -0,0 +1,77 @@ +import datetime +from typing import List, Optional + +import musicbrainzngs +import pydantic + +from .disc import Disc + + +musicbrainzngs.set_useragent('DCPlayer', '0.0.1', 'https://dcplayer.audio/') + + +class Artist(pydantic.BaseModel): + id: str + name: str + + +class ArtistCredit(pydantic.BaseModel): + artist: Artist + + +class Recording(pydantic.BaseModel): + id: str + title: str + + +class Track(pydantic.BaseModel): + id: str + recording: Recording + + +class Medium(pydantic.BaseModel): + track_list: List[Track] = pydantic.Field(alias='track-list') + + +class LabelInfo(pydantic.BaseModel): + catalog_number: Optional[str] = pydantic.Field( + None, alias='catalog-number' + ) + + +class Release(pydantic.BaseModel): + id: str + title: str + artist_credit: List[ArtistCredit] = pydantic.Field(alias='artist-credit') + artist_credit_phrase: str = pydantic.Field(alias='artist-credit-phrase') + medium_list: List[Medium] = pydantic.Field(alias='medium-list') + date: datetime.date + packaging: Optional[str] = None + country: Optional[str] = None + label_info: Optional[List[LabelInfo]] = pydantic.Field( + None, alias='label-info-list' + ) + + +class ReleaseResponse(pydantic.BaseModel): + release_list: List[Release] = pydantic.Field(alias='release-list') + release_count: int = pydantic.Field(alias='release-count') + + +def get_releases_from_disc(disc: Disc) -> ReleaseResponse: + res = musicbrainzngs.get_releases_by_discid( + disc.disc_id, + toc=disc.toc_string, + includes=['artists', 'recordings', 'labels'], + ) + if 'disc' in res: + return ReleaseResponse.parse_obj(res['disc']) + else: + return ReleaseResponse.parse_obj(res) + + +def get_release_by_id(mbid: str) -> Release: + res = musicbrainzngs.get_release_by_id( + mbid, includes=['artists', 'recordings', 'labels'] + ) + return Release.parse_obj(res['release']) diff --git a/src/rupert/ripper.py b/src/rupert/ripper.py new file mode 100644 index 0000000..3360d17 --- /dev/null +++ b/src/rupert/ripper.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import enum +import glob +import logging +import os +import queue +import select +import subprocess +import sys +import threading +import time +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Callable, + Iterable, + Optional, + Tuple, + Type, + Union, +) + +import mutagen + +from . import inotify +from .disc import DiscDrive +from .musicbrainz import Release + + +log = logging.getLogger(__name__) + +TrackList = Iterable[Union[str, int]] + + +class TrackStatus(enum.Enum): + ripping = 'Ripping' + encoding = 'Encoding' + done = 'Done' + + +StatusCallback = Callable[[str, TrackStatus], None] +CompleteCallback = Callable[[str, bool], None] +StatusMessage = Tuple[Optional[int], Union[Tuple[str, bool], TrackStatus]] + +if TYPE_CHECKING: + ProcessQueue = queue.Queue[ # pylint: disable=unsubscriptable-object + Optional[str] + ] + StatusQueue = queue.Queue[ # pylint: disable=unsubscriptable-object + StatusMessage + ] +else: + ProcessQueue = queue.Queue + StatusQueue = queue.Queue + + +# fmt: off +FILENAME_SAFE_MAP = { + '’': "'", + ':': ' - ', + '/': '-', +} +# fmt: on + + +class RipThread(threading.Thread): + def __init__( + self, + device: DiscDrive, + tracks: Optional[TrackList] = None, + use_libcdio: bool = False, + ): + super().__init__(name='RipThread') + if not tracks: + tracks = ('1-',) + self.tracks = tracks + self.device = device + self.use_libcdio = bool(use_libcdio) + self.on_complete: Optional[CompleteCallback] = None + + def run(self): + log.info('Starting rip from device %s', self.device.device_node) + log.debug( + 'Using offset %d for %s', self.device.offset, self.device.model + ) + + if self.use_libcdio: + cmd = ['cd-paranoia'] + else: + cmd = ['cdparanoia'] + cmd += ( + '--batch', + '--quiet', + '--force-read-speed', + '1', + '--output-wav', + '--sample-offset', + str(self.device.offset), + '--force-cdrom-device', + str(self.device.device_node), + '--verbose', + ) + cmd += (str(t) for t in self.tracks) + log.debug('Running command: %s', cmd) + with open('rupert-rip.out', 'wb') as f: + p = subprocess.run( + cmd, + stdout=f, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + check=False, + ) + self._complete( + f'{cmd[0]} exited with status {p.returncode}', p.returncode != 0 + ) + + def _complete(self, message: str, is_err: bool) -> None: + if self.on_complete is not None: + self.on_complete(message, is_err) + + +class ProcessThread(threading.Thread): + + encoding = sys.getfilesystemencoding() + + def __init__(self, q: ProcessQueue) -> None: + super().__init__(name='ProcessThread') + self.q = q + self.quitpipe = None + self.on_status: Optional[StatusCallback] = None + + def handle_event(self, evt): + filename = evt.name.decode(self.encoding) + if filename.endswith('.wav'): + if evt.mask & inotify.IN_CREATE: + log.debug('Started ripping %s', filename) + self._status(filename, TrackStatus.ripping) + if evt.mask & inotify.IN_CLOSE_WRITE: + log.debug('Finished ripping %s', filename) + self._status(filename, TrackStatus.encoding) + self.q.put(filename) + + def run(self): + self.quitpipe = os.pipe() + try: + with inotify.Inotify() as inot: + inot.add_watch( + b'.', inotify.IN_CLOSE_WRITE | inotify.IN_CREATE + ) + while True: + ready = select.select((self.quitpipe[0], inot), (), ())[0] + if inot in ready: + for evt in inot.read(): + if not evt.name: + continue + self.handle_event(evt) + if self.quitpipe[0] in ready: + log.debug('Shutting down') + break + finally: + os.close(self.quitpipe[0]) + self.q.put(None) + + def stop(self): + log.debug('Stopping process thread') + if self.quitpipe is not None: + os.close(self.quitpipe[1]) + + def _status(self, filename: str, status: TrackStatus) -> None: + if self.on_status is not None: + self.on_status(filename, status) + + +class EncodeThread(threading.Thread): + def __init__(self, release: Release, discno: int, q: ProcessQueue) -> None: + super().__init__(name='EncodeThread') + self.release = release + self.discno = discno + self.q = q + self.on_status: Optional[StatusCallback] = None + self.on_complete: Optional[CompleteCallback] = None + + def encode(self, filename: str) -> None: + log.info('Encoding %s to flac', filename) + cmd = ['flac', '--silent', filename] + log.debug('Running command %s', cmd) + p = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + ) + assert p.stdout + codec = sys.getfilesystemencoding() + while 1: + d = p.stdout.readline().decode(codec, 'replace') + if not d: + break + log.debug(d.rstrip()) + p.wait() + log.info('flac exited with status %d', p.returncode) + + def tag(self, filename: str) -> None: + basename = os.path.splitext(filename)[0] + filename = basename + '.flac' + + log.info('Adding tags to %s', filename) + trackno = int(filename[5:7]) + artist = self.release.artist_credit[0].artist + album = self.release.title + medium = self.release.medium_list[self.discno] + track = medium.track_list[trackno - 1] + tags = mutagen.File(filename, easy=True) + tags['tracknumber'] = str(trackno) + tags['artist'] = tags['albumartist'] = artist.name + tags['album'] = album + tags['title'] = track.recording.title + tags['date'] = str(self.release.date.year) + if len(self.release.medium_list) > 1: + tags['discnumber'] = str(self.discno + 1) + tags['musicbrainz_albumid'] = self.release.id + tags['musicbrainz_artistid'] = artist.id + tags['musicbrainz_releasetrackid'] = track.id + tags.save() + + newname = '{track:02} {artist} - {title}.flac'.format( + track=trackno, artist=artist.name, title=track.recording.title + ) + log.info('Renaming "%s" to "%s"', filename, newname) + os.rename(filename, safe_name(newname)) + + def run(self) -> None: + while 1: + filename = self.q.get() + if filename is None: + break + try: + self.encode(filename) + self.tag(filename) + except Exception as e: # pylint: disable=broad-except + log.error('Error encoding/tagging %s: %s', filename, e) + finally: + os.unlink(filename) + self._status(filename, TrackStatus.done) + self._complete() + + def _complete(self) -> None: + if self.on_complete is not None: + self.on_complete('Finished encoding files', False) + + def _status(self, filename: str, status: TrackStatus) -> None: + if self.on_status is not None: + self.on_status(filename, status) + + +class Ripper: + def __init__( + self, + device: DiscDrive, + release: Release, + discno: int, + tracks: Optional[TrackList] = None, + use_libcdio: bool = False, + ) -> None: + self.device = device + self.release = release + self.discno = discno + self.tracks = tracks + self.use_libcdio = use_libcdio + self._status_queue: StatusQueue = queue.Queue() + q: ProcessQueue = queue.Queue() + self._rip_thread = RipThread( + self.device, self.tracks, self.use_libcdio + ) + self._rip_thread.on_complete = self.on_complete + self._process_thread = ProcessThread(q) + self._process_thread.on_status = self.on_status + self._encode_thread = EncodeThread(self.release, self.discno, q) + self._encode_thread.on_status = self.on_status + self._encode_thread.on_complete = self.on_complete + + def __enter__(self,) -> Ripper: + return self + + def __exit__( + self, + exc_type: Type[Exception], + exc_value: Exception, + tb: TracebackType, + ) -> None: + ... + + def rip(self) -> Iterable[StatusMessage]: + start = time.monotonic() + dirname = safe_name( + f'{self.release.artist_credit_phrase} - {self.release.title}' + ) + if not os.path.isdir(dirname): + log.info('Creating directory: %s', dirname) + os.mkdir(dirname) + os.chdir(dirname) + if len(self.release.medium_list) > 1: + subdirname = f'Disc {self.discno + 1}' + if not os.path.isdir(subdirname): + log.info('Creating directory: %s', subdirname) + os.mkdir(subdirname) + os.chdir(subdirname) + + for filename in glob.glob('track*.cdda.wav'): + os.unlink(filename) + + self._process_thread.start() + self._encode_thread.start() + self._rip_thread.start() + while 1: + track, status = self._status_queue.get() + yield track, status + if track is None: + break + self._process_thread.stop() + while 1: + track, status = self._status_queue.get() + yield track, status + if track is None: + break + end = time.monotonic() + log.info('Ripping/encoding took %d seconds', end - start) + + def on_status(self, filename: str, status: TrackStatus) -> None: + log.debug('Filename: %s, Status: %s', filename, status.name) + if filename.startswith('track') and filename.endswith('.cdda.wav'): + track = int(filename[5:-9]) + self._status_queue.put((track, status)) + + def on_complete(self, message: str, is_err: bool) -> None: + self._status_queue.put((None, (message, is_err))) + + +def safe_name(name: str) -> str: + for k, v in FILENAME_SAFE_MAP.items(): + name = name.replace(k, v) + return name