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*.
commit
f44e26cc84
|
@ -0,0 +1,2 @@
|
|||
/.venv
|
||||
/dist
|
|
@ -0,0 +1,8 @@
|
|||
[MASTER]
|
||||
extension-pkg-whitelist = pydantic
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable =
|
||||
invalid-name,
|
||||
no-else-return,
|
||||
raise-missing-from,
|
|
@ -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!
|
Binary file not shown.
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"python.pythonPath": ".venv/bin/python",
|
||||
"python.linting.mypyEnabled": true,
|
||||
}
|
|
@ -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
|
|
@ -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"},
|
||||
]
|
|
@ -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"
|
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.
|
@ -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)
|
|
@ -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
|
||||
'''
|
|
@ -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()
|
|
@ -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'])
|
|
@ -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
|
Loading…
Reference in New Issue