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*.
Dustin 2021-01-17 00:14:53 -06:00
commit f44e26cc84
22 changed files with 1673 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.venv
/dist

8
.pylintrc Normal file
View File

@ -0,0 +1,8 @@
[MASTER]
extension-pkg-whitelist = pydantic
[MESSAGES CONTROL]
disable =
invalid-name,
no-else-return,
raise-missing-from,

114
.vscode/.ropeproject/config.py vendored Normal file
View File

@ -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 <package> import <module>` 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!

BIN
.vscode/.ropeproject/objectdb vendored Normal file

Binary file not shown.

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"python.pythonPath": ".venv/bin/python",
"python.linting.mypyEnabled": true,
}

25
mypy.ini Normal file
View File

@ -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

509
poetry.lock generated Normal file
View File

@ -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"},
]

42
pyproject.toml Normal file
View File

@ -0,0 +1,42 @@
[tool.poetry]
name = "rupert"
version = "0.0.0"
description = ""
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
[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"

0
src/rupert/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

83
src/rupert/disc.py Normal file
View File

@ -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)

249
src/rupert/inotify.py Normal file
View File

@ -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
'''

217
src/rupert/main.py Normal file
View File

@ -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()

77
src/rupert/musicbrainz.py Normal file
View File

@ -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'])

343
src/rupert/ripper.py Normal file
View File

@ -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