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*.pull/3/head
commit
a997be4515
|
@ -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"
|
|
@ -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