├── test ├── __init__.py ├── config │ └── empty.yml ├── 00_sanity_test.py └── helper.py ├── beetsplug ├── describe │ ├── config_default.yml │ ├── about.py │ ├── __init__.py │ ├── common.py │ └── command.py └── __init__.py ├── .coveragerc ├── MANIFEST.in ├── setup.cfg ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.md │ └── bug-report.md └── workflows │ └── test_release_deploy.yml ├── LICENSE.txt ├── .gitignore ├── setup.py └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /beetsplug/describe/config_default.yml: -------------------------------------------------------------------------------- 1 | auto: no 2 | -------------------------------------------------------------------------------- /test/config/empty.yml: -------------------------------------------------------------------------------- 1 | # Empty placeholder configuration file so that default plugin configuration can be tested -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */test/* 4 | beetsplug/__init__.py 5 | exclude_lines = 6 | raise NotImplementedError 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune test 2 | include LICENSE.txt 3 | include README.md 4 | include beetsplug/describe/about.py 5 | include beetsplug/describe/config_default.yml 6 | 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity = 1 3 | with-coverage = 1 4 | cover-package = beetsplug 5 | cover-erase = 1 6 | cover-html = 1 7 | cover-html-dir = coverage 8 | logging-clear-handlers = 1 9 | process-timeout = 30 10 | -------------------------------------------------------------------------------- /beetsplug/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/21/20, 11:28 AM 5 | # License: See LICENSE.txt 6 | 7 | from pkgutil import extend_path 8 | 9 | __path__ = extend_path(__path__, __name__) 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: Suggest a new idea for beets-describe 4 | 5 | --- 6 | 7 | ### Use case 8 | 9 | I'm trying to use the beets-describe plugin to... 10 | 11 | 12 | ### Solution 13 | 19 | 20 | -------------------------------------------------------------------------------- /beetsplug/describe/about.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | 5 | __author__ = u'Adam Jakab' 6 | __email__ = u'adam@jakab.pro' 7 | __copyright__ = u'Copyright (c) 2020, {} <{}>'.format(__author__, __email__) 8 | __license__ = u'License :: OSI Approved :: MIT License' 9 | __version__ = u'0.0.5' 10 | __status__ = u'Kickstarted' 11 | 12 | __PACKAGE_TITLE__ = u'Describe' 13 | __PACKAGE_NAME__ = u'beets-describe' 14 | __PACKAGE_DESCRIPTION__ = u'A beets plugin that describes attributes in depth', 15 | __PACKAGE_URL__ = u'https://github.com/adamjakab/BeetsPluginDescribe' 16 | __PLUGIN_NAME__ = u'describe' 17 | __PLUGIN_ALIAS__ = None 18 | __PLUGIN_SHORT_DESCRIPTION__ = u'describe a library item field' 19 | -------------------------------------------------------------------------------- /beetsplug/describe/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/21/20, 11:27 AM 5 | # License: See LICENSE.txt 6 | 7 | import os 8 | 9 | from beets.plugins import BeetsPlugin 10 | from confuse import ConfigSource, load_yaml 11 | 12 | from beetsplug.describe.command import DescribeCommand 13 | 14 | 15 | class DescribePlugin(BeetsPlugin): 16 | _default_plugin_config_file_name_ = 'config_default.yml' 17 | 18 | def __init__(self): 19 | super(DescribePlugin, self).__init__() 20 | config_file_path = os.path.join(os.path.dirname(__file__), self._default_plugin_config_file_name_) 21 | source = ConfigSource(load_yaml(config_file_path) or {}, config_file_path) 22 | self.config.add(source) 23 | 24 | def commands(self): 25 | return [DescribeCommand(self.config)] 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Report a problem with beets-describe 4 | 5 | --- 6 | 7 | 13 | 14 | ### Problem 15 | 16 | Running your command in verbose (`-vv`) mode: 17 | 18 | ```sh 19 | $ beet -vv describe (... paste here the rest...) 20 | ``` 21 | 22 | Led to this problem: 23 | 24 | ``` 25 | (paste here) 26 | ``` 27 | 28 | 29 | ### Setup 30 | 31 | * OS: 32 | * Python version: 33 | * Beets version: 34 | * Turning off other plugins made problem go away (yes/no): 35 | 36 | My configuration (output of `beet config`) is: 37 | 38 | ```yaml 39 | (paste here) 40 | ``` 41 | 42 | My plugin version (output of `beet describe -v`) is: 43 | 44 | ```text 45 | (paste here) 46 | ``` 47 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Jakab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## IDE (JetBrains/Eclipse) 2 | .idea/ 3 | .project 4 | .pydevproject 5 | .settings 6 | 7 | ## OS specific 8 | .DS_Store 9 | 10 | ## Project 11 | coverage/ 12 | BEETSDIR/ 13 | 14 | ## Python specific 15 | __pycache__/ 16 | 17 | # Distribution / packaging 18 | .Python 19 | build/ 20 | dist/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | .dmypy.json 110 | dmypy.json 111 | 112 | # Pyre type checker 113 | .pyre/ 114 | 115 | 116 | -------------------------------------------------------------------------------- /.github/workflows/test_release_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test & Release & Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python (${{ matrix.python-version }}) 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install beets numpy pandas termtables termplotlib 25 | pip install pytest nose coverage mock six pyyaml requests 26 | - name: Test 27 | run: | 28 | pytest 29 | release: 30 | name: Release 31 | runs-on: ubuntu-latest 32 | needs: ["test"] 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Create Release 36 | uses: ncipollo/release-action@v1 37 | # ref.: https://github.com/ncipollo/release-action 38 | with: 39 | name: ${{ github.ref_name }} 40 | draft: false 41 | generateReleaseNotes: true 42 | deploy: 43 | name: Deploy 44 | runs-on: ubuntu-latest 45 | needs: ["release"] 46 | steps: 47 | - uses: actions/checkout@v4 48 | - name: Set up Python 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: "3.12" 52 | - name: Install dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | pip install build twine 56 | - name: Build and publish 57 | env: 58 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 59 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 60 | run: | 61 | python -m build 62 | twine check dist/* 63 | twine upload dist/* 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/21/20, 11:27 AM 5 | # License: See LICENSE.txt 6 | 7 | import pathlib 8 | from setuptools import setup 9 | from distutils.util import convert_path 10 | 11 | # The directory containing this file 12 | HERE = pathlib.Path(__file__).parent 13 | 14 | # The text of the README file 15 | README = (HERE / "README.md").read_text() 16 | 17 | plg_ns = {} 18 | about_path = convert_path('beetsplug/describe/about.py') 19 | with open(about_path) as about_file: 20 | exec(about_file.read(), plg_ns) 21 | 22 | # Setup 23 | setup( 24 | name=plg_ns['__PACKAGE_NAME__'], 25 | version=plg_ns['__version__'], 26 | description=plg_ns['__PACKAGE_DESCRIPTION__'], 27 | author=plg_ns['__author__'], 28 | author_email=plg_ns['__email__'], 29 | url=plg_ns['__PACKAGE_URL__'], 30 | license='MIT', 31 | long_description=README, 32 | long_description_content_type='text/markdown', 33 | platforms='ALL', 34 | 35 | include_package_data=True, 36 | test_suite='test', 37 | packages=['beetsplug.describe'], 38 | 39 | python_requires='>=3.8', 40 | 41 | install_requires=[ 42 | 'beets>=1.4.9', 43 | 'numpy', 44 | 'pandas', 45 | 'termtables', 46 | 'termplotlib', 47 | ], 48 | 49 | tests_require=[ 50 | 'pytest', 'nose', 'coverage', 51 | 'mock', 'six', 'pyyaml', 52 | ], 53 | 54 | # Extras needed during testing 55 | extras_require={ 56 | 'tests': [], 57 | }, 58 | 59 | classifiers=[ 60 | 'Topic :: Multimedia :: Sound/Audio', 61 | 'License :: OSI Approved :: MIT License', 62 | 'Environment :: Console', 63 | 'Programming Language :: Python :: 3', 64 | 'Programming Language :: Python :: 3.8', 65 | 'Programming Language :: Python :: 3.9', 66 | 'Programming Language :: Python :: 3.10', 67 | 'Programming Language :: Python :: 3.11', 68 | 'Programming Language :: Python :: 3.12', 69 | ], 70 | ) 71 | -------------------------------------------------------------------------------- /test/00_sanity_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/26/20, 4:50 PM 5 | # License: See LICENSE.txt 6 | 7 | 8 | from beetsplug.describe import about 9 | from test.helper import TestHelper, Assertions, \ 10 | PLUGIN_NAME, PLUGIN_SHORT_DESCRIPTION, PACKAGE_NAME, PACKAGE_TITLE, \ 11 | PLUGIN_VERSION, \ 12 | capture_log 13 | 14 | plg_log_ns = 'beets.{}'.format(PLUGIN_NAME) 15 | 16 | 17 | class CompletionTest(TestHelper, Assertions): 18 | """Test invocation of the plugin and basic package health. 19 | """ 20 | 21 | def test_about_descriptor_file(self): 22 | self.assertTrue(hasattr(about, "__author__")) 23 | self.assertTrue(hasattr(about, "__email__")) 24 | self.assertTrue(hasattr(about, "__copyright__")) 25 | self.assertTrue(hasattr(about, "__license__")) 26 | self.assertTrue(hasattr(about, "__version__")) 27 | self.assertTrue(hasattr(about, "__status__")) 28 | self.assertTrue(hasattr(about, "__PACKAGE_TITLE__")) 29 | self.assertTrue(hasattr(about, "__PACKAGE_NAME__")) 30 | self.assertTrue(hasattr(about, "__PACKAGE_DESCRIPTION__")) 31 | self.assertTrue(hasattr(about, "__PACKAGE_URL__")) 32 | self.assertTrue(hasattr(about, "__PLUGIN_NAME__")) 33 | self.assertTrue(hasattr(about, "__PLUGIN_ALIAS__")) 34 | self.assertTrue(hasattr(about, "__PLUGIN_SHORT_DESCRIPTION__")) 35 | 36 | def test_application(self): 37 | output = self.runcli() 38 | self.assertIn(PLUGIN_NAME, output) 39 | self.assertIn(PLUGIN_SHORT_DESCRIPTION, output) 40 | 41 | def test_application_plugin_list(self): 42 | output = self.runcli("version") 43 | self.assertIn("plugins: {0}".format(PLUGIN_NAME), output) 44 | 45 | def test_run_plugin(self): 46 | output = self.runcli(PLUGIN_NAME) 47 | self.assertIn("Usage: beet describe field_name [options] [QUERY...]", 48 | output) 49 | 50 | def test_plugin_version(self): 51 | with capture_log(plg_log_ns) as logs: 52 | self.runcli(PLUGIN_NAME, "--version") 53 | 54 | versioninfo = "{pt}({pn}) plugin for Beets: v{ver}".format( 55 | pt=PACKAGE_TITLE, 56 | pn=PACKAGE_NAME, 57 | ver=PLUGIN_VERSION 58 | ) 59 | self.assertIn(versioninfo, "\n".join(logs)) 60 | -------------------------------------------------------------------------------- /beetsplug/describe/common.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/21/20, 11:28 AM 5 | # License: See LICENSE.txt 6 | 7 | import logging 8 | import os 9 | import sys 10 | 11 | from beets import library 12 | from beets.dbcore import types 13 | from beets.library import Item 14 | 15 | # Get values as: plg_ns['__PLUGIN_NAME__'] 16 | plg_ns = {} 17 | about_path = os.path.join(os.path.dirname(__file__), u'about.py') 18 | with open(about_path) as about_file: 19 | exec(about_file.read(), plg_ns) 20 | 21 | __logger__ = logging.getLogger( 22 | 'beets.{plg}'.format(plg=plg_ns['__PLUGIN_NAME__'])) 23 | 24 | KNOWN_NUMERIC_FLEX_ATTRIBUTES = [ 25 | "average_loudness", 26 | "chords_changes_rate", 27 | "chords_number_rate", 28 | "danceable", 29 | "key_strength", 30 | "mood_acoustic", 31 | "mood_aggressive", 32 | "mood_electronic", 33 | "mood_happy", 34 | "mood_party", 35 | "mood_relaxed", 36 | "mood_sad", 37 | "rhythm", 38 | "tonal", 39 | ] 40 | 41 | 42 | def is_numeric(field_type, field_type_auto): 43 | if field_type: 44 | f_numeric = field_type in get_dbcore_numeric_types() 45 | else: 46 | f_numeric = field_type_auto in get_dbcore_numeric_types() 47 | 48 | return f_numeric 49 | 50 | 51 | def get_automatic_type_for_field(field): 52 | field_type = types.String 53 | 54 | if field in KNOWN_NUMERIC_FLEX_ATTRIBUTES: 55 | field_type = types.Float 56 | 57 | type_name = "{}.{}".format(field_type.__module__, field_type.__name__) 58 | 59 | print("TYPE({}): {}".format(field, field_type)) 60 | 61 | return type_name 62 | 63 | 64 | def get_dbcore_numeric_types(): 65 | type_classes = [] 66 | 67 | dbcore_types = [ 68 | types.Integer, 69 | types.Float, 70 | types.NullFloat, 71 | types.PaddedInt, 72 | types.NullPaddedInt, 73 | types.ScaledInt, 74 | library.DurationType 75 | ] 76 | 77 | for dt in dbcore_types: 78 | type_classes.append("{}.{}".format(dt.__module__, dt.__name__)) 79 | 80 | return type_classes 81 | 82 | 83 | def get_field_type(field): 84 | fld_type = None 85 | 86 | # Field types declared by Item 87 | if field in Item._fields: 88 | ft = Item._fields[field] 89 | fld_type = "{}.{}".format(ft.__module__, ft.__class__.__name__) 90 | 91 | # Field types declared/overridden by Plugins 92 | if not fld_type: 93 | if field in Item._types: 94 | ft = Item._types[field] 95 | fld_type = "{}.{}".format(ft.__module__, ft.__class__.__name__) 96 | 97 | return fld_type 98 | 99 | 100 | def say(msg, log_only=True, is_error=False): 101 | _level = logging.DEBUG 102 | _level = _level if log_only else logging.INFO 103 | _level = _level if not is_error else logging.ERROR 104 | __logger__.log(level=_level, msg=msg) 105 | -------------------------------------------------------------------------------- /test/helper.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/26/20, 4:50 PM 5 | # License: See LICENSE.txt 6 | 7 | import os 8 | import shutil 9 | import sys 10 | import tempfile 11 | from contextlib import contextmanager 12 | from unittest import TestCase 13 | 14 | import beets 15 | import six 16 | import yaml 17 | from beets import logging 18 | from beets import plugins 19 | from beets import ui 20 | from beets import util 21 | from beets.library import Item 22 | from beets.util import ( 23 | syspath, 24 | bytestring_path, 25 | displayable_path, 26 | ) 27 | from confuse import Subview, Dumper 28 | from six import StringIO 29 | 30 | from beetsplug import describe 31 | from beetsplug.describe import common 32 | 33 | logging.getLogger('beets').propagate = True 34 | 35 | # Values 36 | PLUGIN_NAME = common.plg_ns['__PLUGIN_NAME__'] 37 | PLUGIN_SHORT_DESCRIPTION = common.plg_ns['__PLUGIN_SHORT_DESCRIPTION__'] 38 | PLUGIN_VERSION = common.plg_ns['__version__'] 39 | PACKAGE_NAME = common.plg_ns['__PACKAGE_NAME__'] 40 | PACKAGE_TITLE = common.plg_ns['__PACKAGE_TITLE__'] 41 | 42 | 43 | class LogCapture(logging.Handler): 44 | 45 | def __init__(self): 46 | super(LogCapture, self).__init__() 47 | self.messages = [] 48 | 49 | def emit(self, record): 50 | self.messages.append(six.text_type(record.msg)) 51 | 52 | 53 | @contextmanager 54 | def capture_log(logger='beets', suppress_output=True): 55 | capture = LogCapture() 56 | log = logging.getLogger(logger) 57 | log.propagate = True 58 | if suppress_output: 59 | # Is this too violent? 60 | log.handlers = [] 61 | log.addHandler(capture) 62 | try: 63 | yield capture.messages 64 | finally: 65 | log.removeHandler(capture) 66 | 67 | 68 | @contextmanager 69 | def capture_stdout(suppress_output=True): 70 | """Save stdout in a StringIO. 71 | >>> with capture_stdout() as output: 72 | ... print('spam') 73 | ... 74 | >>> output.getvalue() 75 | 'spam' 76 | """ 77 | org = sys.stdout 78 | sys.stdout = capture = StringIO() 79 | try: 80 | yield sys.stdout 81 | finally: 82 | sys.stdout = org 83 | # if not suppress_output: 84 | print(capture.getvalue()) 85 | 86 | 87 | @contextmanager 88 | def control_stdin(userinput=None): 89 | """Sends ``input`` to stdin. 90 | >>> with control_stdin('yes'): 91 | ... input() 92 | 'yes' 93 | """ 94 | org = sys.stdin 95 | sys.stdin = StringIO(userinput) 96 | try: 97 | yield sys.stdin 98 | finally: 99 | sys.stdin = org 100 | 101 | 102 | def _convert_args(args): 103 | """Convert args to strings 104 | """ 105 | for i, elem in enumerate(args): 106 | if isinstance(elem, bytes): 107 | args[i] = elem.decode(util.arg_encoding()) 108 | 109 | return args 110 | 111 | 112 | class Assertions(object): 113 | def assertIsFile(self: TestCase, path): 114 | self.assertTrue(os.path.isfile(syspath(path)), 115 | msg=u'Path is not a file: {0}'.format( 116 | displayable_path(path))) 117 | 118 | 119 | class TestHelper(TestCase, Assertions): 120 | _test_config_dir_ = os.path.join(bytestring_path(os.path.dirname(__file__)), 121 | b'config') 122 | _test_fixture_dir = os.path.join(bytestring_path(os.path.dirname(__file__)), 123 | b'fixtures') 124 | _test_target_dir = bytestring_path("/tmp/beets-autofix") 125 | 126 | def setUp(self): 127 | """Setup required for running test. Must be called before running any 128 | tests. 129 | """ 130 | self.reset_beets(config_file=b"empty.yml") 131 | 132 | def tearDown(self): 133 | self.teardown_beets() 134 | 135 | def reset_beets(self, config_file: bytes): 136 | self.teardown_beets() 137 | plugins._classes = {describe.DescribePlugin} 138 | self._setup_beets(config_file) 139 | 140 | def _setup_beets(self, config_file: bytes): 141 | self.addCleanup(self.teardown_beets) 142 | os.environ['BEETSDIR'] = self.mkdtemp() 143 | 144 | self.config = beets.config 145 | self.config.clear() 146 | 147 | # add user configuration 148 | config_file = format( 149 | os.path.join(self._test_config_dir_, config_file).decode()) 150 | shutil.copyfile(config_file, self.config.user_config_path()) 151 | self.config.read() 152 | 153 | self.config['plugins'] = [] 154 | self.config['verbose'] = True 155 | self.config['ui']['color'] = False 156 | self.config['threaded'] = False 157 | self.config['import']['copy'] = False 158 | 159 | os.makedirs(self._test_target_dir, exist_ok=True) 160 | 161 | libdir = self.mkdtemp() 162 | self.config['directory'] = libdir 163 | self.libdir = bytestring_path(libdir) 164 | 165 | self.lib = beets.library.Library(':memory:', self.libdir) 166 | 167 | # This will initialize (create instance) of the plugins 168 | plugins.find_plugins() 169 | 170 | def teardown_beets(self): 171 | self.unload_plugins() 172 | 173 | shutil.rmtree(self._test_target_dir, ignore_errors=True) 174 | 175 | if hasattr(self, '_tempdirs'): 176 | for tempdir in self._tempdirs: 177 | if os.path.exists(tempdir): 178 | shutil.rmtree(syspath(tempdir), ignore_errors=True) 179 | self._tempdirs = [] 180 | 181 | if hasattr(self, 'lib'): 182 | if hasattr(self.lib, '_connections'): 183 | del self.lib._connections 184 | 185 | if 'BEETSDIR' in os.environ: 186 | del os.environ['BEETSDIR'] 187 | 188 | if hasattr(self, 'config'): 189 | self.config.clear() 190 | 191 | # beets.config.read(user=False, defaults=True) 192 | 193 | def mkdtemp(self): 194 | # This return a str path, i.e. Unicode on Python 3. We need this in 195 | # order to put paths into the configuration. 196 | path = tempfile.mkdtemp() 197 | self._tempdirs.append(path) 198 | return path 199 | 200 | @staticmethod 201 | def unload_plugins(): 202 | for plugin in plugins._classes: 203 | plugin.listeners = None 204 | plugins._classes = set() 205 | plugins._instances = {} 206 | 207 | def runcli(self, *args): 208 | # TODO mock stdin 209 | with capture_stdout() as out: 210 | try: 211 | ui._raw_main(_convert_args(list(args)), self.lib) 212 | except ui.UserError as u: 213 | # TODO remove this and handle exceptions in tests 214 | print(u.args[0]) 215 | return out.getvalue() 216 | 217 | def lib_path(self, path): 218 | return os.path.join(self.libdir, 219 | path.replace(b'/', bytestring_path(os.sep))) 220 | 221 | @staticmethod 222 | def _dump_config(cfg: Subview): 223 | # print(json.dumps(cfg.get(), indent=4, sort_keys=False)) 224 | flat = cfg.flatten() 225 | print(yaml.dump(flat, Dumper=Dumper, default_flow_style=None, indent=2, 226 | width=1000)) 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test & Release & Deploy](https://github.com/adamjakab/BeetsPluginDescribe/actions/workflows/test_release_deploy.yml/badge.svg)](https://github.com/adamjakab/BeetsPluginDescribe/actions/workflows/test_release_deploy.yml) 2 | [![Coverage Status](https://coveralls.io/repos/github/adamjakab/BeetsPluginDescribe/badge.svg?branch=master)](https://coveralls.io/github/adamjakab/BeetsPluginDescribe?branch=master) 3 | [![PyPi](https://img.shields.io/pypi/v/beets-describe.svg)](https://pypi.org/project/beets-describe/) 4 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/beets-describe.svg)](https://pypi.org/project/beets-describe/) 5 | [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt) 6 | 7 | # Describe (Beets Plugin) 8 | 9 | The _beets-describe_ plugin attempts to give you the full picture on a single attribute of your library item. 10 | 11 | **NOTE: Under heavy development but works!** 12 | 13 | ## Installation: 14 | 15 | ```shell script 16 | $ pip install beets-describe 17 | ``` 18 | 19 | and activate the plugin the usual way 20 | 21 | ```yaml 22 | plugins: 23 | - describe 24 | ``` 25 | 26 | ## Usage: 27 | 28 | ```bash 29 | beet describe field_name 30 | ``` 31 | 32 | You can of course add any queries after the name of the field to describe such as: 33 | 34 | ```bash 35 | beet describe genre albumartist:'Various Artists' 36 | ``` 37 | 38 | ## Sample Output 39 | 40 | `beet describe bpm` 41 | 42 | ```text 43 | ┌────────────────┬────────────────────────────┐ 44 | │ Name │ Value │ 45 | ╞════════════════╪════════════════════════════╡ 46 | │ Field name │ bpm │ 47 | ├────────────────┼────────────────────────────┤ 48 | │ Field type │ beets.dbcore.types.Integer │ 49 | ├────────────────┼────────────────────────────┤ 50 | │ Count │ 1392 │ 51 | ├────────────────┼────────────────────────────┤ 52 | │ Min │ 65.9922409058 │ 53 | ├────────────────┼────────────────────────────┤ 54 | │ Max │ 185.0 │ 55 | ├────────────────┼────────────────────────────┤ 56 | │ Mean │ 122.99097545119291 │ 57 | ├────────────────┼────────────────────────────┤ 58 | │ Median │ 122.0 │ 59 | ├────────────────┼────────────────────────────┤ 60 | │ Empty │ 0 │ 61 | ├────────────────┼────────────────────────────┤ 62 | │ Unique │ 649 │ 63 | ├────────────────┼────────────────────────────┤ 64 | │ Most frequent │ 122.0(22) │ 65 | ├────────────────┼────────────────────────────┤ 66 | │ Least frequent │ 117.853546143(1) │ 67 | └────────────────┴────────────────────────────┘ 68 | Distribution(bins=10) histogram 69 | 66.0 - 77.9 [ 30] ████▊ 70 | 77.9 - 89.8 [ 73] ███████████▍ 71 | 89.8 - 101.7 [203] ███████████████████████████████▊ 72 | 101.7 - 113.6 [221] ██████████████████████████████████▌ 73 | 113.6 - 125.5 [256] ████████████████████████████████████████ 74 | 125.5 - 137.4 [208] ████████████████████████████████▌ 75 | 137.4 - 149.3 [183] ████████████████████████████▋ 76 | 149.3 - 161.2 [ 87] █████████████▋ 77 | 161.2 - 173.1 [107] ████████████████▊ 78 | 173.1 - 185.0 [ 24] ███▊ 79 | ``` 80 | 81 | `beet describe genre` 82 | 83 | ```text 84 | ┌────────────────┬───────────────────────────┐ 85 | │ Name │ Value │ 86 | ╞════════════════╪═══════════════════════════╡ 87 | │ Field name │ genre │ 88 | ├────────────────┼───────────────────────────┤ 89 | │ Field type │ beets.dbcore.types.String │ 90 | ├────────────────┼───────────────────────────┤ 91 | │ Count │ 1392 │ 92 | ├────────────────┼───────────────────────────┤ 93 | │ Empty │ 19 │ 94 | ├────────────────┼───────────────────────────┤ 95 | │ Unique │ 91 │ 96 | ├────────────────┼───────────────────────────┤ 97 | │ Most frequent │ Oldies(202) │ 98 | ├────────────────┼───────────────────────────┤ 99 | │ Least frequent │ Post-Punk(1) │ 100 | └────────────────┴───────────────────────────┘ 101 | Unique element histogram 102 | Oldies [202] ████████████████████████████████████████ 103 | Classic Rock [139] ███████████████████████████▌ 104 | Soul [124] ████████████████████████▌ 105 | Blues [120] ███████████████████████▊ 106 | Rock [109] █████████████████████▋ 107 | Pop [105] ████████████████████▊ 108 | Dance [ 86] █████████████████ 109 | New Wave [ 48] █████████▌ 110 | Reggae [ 44] ████████▊ 111 | Heavy Metal [ 33] ██████▌ 112 | Trance [ 24] ████▊ 113 | Blues Rock [ 20] ████ 114 | Jazz [ 20] ████ 115 | [ 19] ███▊ 116 | Soundtrack [ 17] ███▍ 117 | Ska [ 16] ███▏ 118 | Synthpop [ 16] ███▏ 119 | Rap [ 15] ███ 120 | Pop Rock [ 14] ██▊ 121 | Funk [ 12] ██▍ 122 | Metal [ 12] ██▍ 123 | Alternative Metal [ 12] ██▍ 124 | Alternative Rock [ 11] ██▏ 125 | Soft Rock [ 10] ██ 126 | Hard Rock [ 10] ██ 127 | Singer-Songwriter [ 9] █▊ 128 | Rockabilly [ 8] █▋ 129 | Metalcore [ 6] █▎ 130 | Electronic [ 6] █▎ 131 | Rock And Roll [ 6] █▎ 132 | R&B [ 6] █▎ 133 | House [ 5] █ 134 | Disco [ 5] █ 135 | Progressive Rock [ 5] █ 136 | Psychedelic Rock [ 5] █ 137 | Punk Rock [ 4] ▊ 138 | Thrash Metal [ 4] ▊ 139 | Progressive Metal [ 4] ▊ 140 | Contemporary R&B [ 3] ▋ 141 | Nu Metal [ 3] ▋ 142 | Symphonic Metal [ 3] ▋ 143 | Funk Soul [ 3] ▋ 144 | World Music [ 3] ▋ 145 | Death Metal [ 3] ▋ 146 | Britpop [ 2] ▍ 147 | Industrial Metal [ 2] ▍ 148 | Contemporary Classical [ 2] ▍ 149 | Post-Grunge [ 2] ▍ 150 | Psychedelic [ 2] ▍ 151 | Motown [ 2] ▍ 152 | Glam Rock [ 2] ▍ 153 | Rock, Hard Rock, Metal [ 2] ▍ 154 | Blue-Eyed Soul [ 2] ▍ 155 | Black Metal [ 2] ▍ 156 | Indie Rock [ 2] ▍ 157 | Indie Pop [ 2] ▍ 158 | Industrial [ 2] ▍ 159 | Pop Punk [ 2] ▍ 160 | Surf Rock [ 2] ▍ 161 | Hip Hop [ 1] ▎ 162 | Gospel [ 1] ▎ 163 | Ragga [ 1] ▎ 164 | Indie [ 1] ▎ 165 | Speed Metal [ 1] ▎ 166 | Gypsy Jazz [ 1] ▎ 167 | ``` 168 | 169 | ## Configuration 170 | 171 | There are no configuration options for this plugin. 172 | 173 | ## Issues 174 | 175 | - If something is not working as expected please use the Issue tracker. 176 | - If the documentation is not clear please use the Issue tracker. 177 | - If you have a feature request please use the Issue tracker. 178 | - In any other situation please use the Issue tracker. 179 | 180 | ## Other plugins by the same author 181 | 182 | - [beets-goingrunning](https://github.com/adamjakab/BeetsPluginGoingRunning) 183 | - [beets-xtractor](https://github.com/adamjakab/BeetsPluginXtractor) 184 | - [beets-yearfixer](https://github.com/adamjakab/BeetsPluginYearFixer) 185 | - [beets-autofix](https://github.com/adamjakab/BeetsPluginAutofix) 186 | - [beets-describe](https://github.com/adamjakab/BeetsPluginDescribe) 187 | - [beets-bpmanalyser](https://github.com/adamjakab/BeetsPluginBpmAnalyser) 188 | - [beets-template](https://github.com/adamjakab/BeetsPluginTemplate) 189 | 190 | ## Final Remarks 191 | 192 | Enjoy! 193 | -------------------------------------------------------------------------------- /beetsplug/describe/command.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/21/20, 11:28 AM 5 | # License: See LICENSE.txt 6 | 7 | from optparse import OptionParser 8 | import numpy as np 9 | import pandas as pd 10 | import termplotlib as tpl 11 | import termtables as tt 12 | from beets import library 13 | 14 | from beets.dbcore import types 15 | from beets.library import Library, Item, parse_query_parts 16 | from beets.ui import Subcommand, decargs 17 | from confuse import Subview 18 | 19 | from beetsplug.describe import common 20 | 21 | 22 | class DescribeCommand(Subcommand): 23 | config: Subview = None 24 | lib: Library = None 25 | query = None 26 | parser: OptionParser = None 27 | 28 | def __init__(self, cfg): 29 | self.config = cfg 30 | 31 | self.parser = OptionParser( 32 | usage='beet {plg} field_name [options] [QUERY...]'.format( 33 | plg=common.plg_ns['__PLUGIN_NAME__'] 34 | )) 35 | 36 | self.parser.add_option( 37 | '-v', '--version', 38 | action='store_true', dest='version', default=False, 39 | help=u'show plugin version' 40 | ) 41 | 42 | # Keep this at the end 43 | super(DescribeCommand, self).__init__( 44 | parser=self.parser, 45 | name=common.plg_ns['__PLUGIN_NAME__'], 46 | aliases=[common.plg_ns['__PLUGIN_ALIAS__']] if 47 | common.plg_ns['__PLUGIN_ALIAS__'] else [], 48 | help=common.plg_ns['__PLUGIN_SHORT_DESCRIPTION__'] 49 | ) 50 | 51 | def func(self, lib: Library, options, arguments): 52 | self.lib = lib 53 | self.query = decargs(arguments) 54 | 55 | # You must either pass a training name or request listing 56 | if len(self.query) < 1 and not options.version: 57 | self.parser.print_help() 58 | return 59 | 60 | if options.version: 61 | self.show_version_information() 62 | return 63 | 64 | self.handle_display() 65 | 66 | def handle_display(self): 67 | field_to_examine = self.query.pop(0) 68 | fields = [field_to_examine] 69 | 70 | # field_to_examine = "genre" 71 | # fields = ["id", "bpm", "year", "country", "acoustid_id", "mood_aggressive", field_to_examine] 72 | 73 | lib_items = self._retrieve_library_items() 74 | data = self._extract_data_from_items(lib_items, fields) 75 | data_desc = self._describe(data, field_to_examine) 76 | 77 | self.print_describe_table(data_desc) 78 | self.plot_field_data(data_desc) 79 | 80 | def print_describe_table(self, desc): 81 | table_data = [] 82 | for key in desc: 83 | data = desc[key] 84 | if "label" in data and "value" in data: 85 | table_data.append([data["label"], data["value"]]) 86 | 87 | tt.print( 88 | table_data, 89 | header=["Name", "Value"], 90 | padding=(0, 1), 91 | alignment="lr" 92 | ) 93 | 94 | def plot_field_data(self, desc): 95 | field_name = desc["field_name"]["value"] 96 | field_type = desc["field_type"]["value"] 97 | field_type_auto = desc["field_type_auto"]["value"] 98 | df = desc["df"] 99 | vec = df[field_name] 100 | 101 | if common.is_numeric(field_type, field_type_auto): 102 | vec = pd.to_numeric(vec, errors='coerce').fillna(0) 103 | 104 | # todo: put option/config for bins 105 | num_bins = 10 106 | 107 | self._say("Distribution(bins={bins}) histogram".format(bins=num_bins), 108 | log_only=False) 109 | 110 | bins = np.linspace(vec.min(), vec.max(), (num_bins + 1)) 111 | closed_bins = list(bins[:-1]) 112 | groups = df.groupby(np.digitize(vec, closed_bins)) 113 | 114 | values = list(groups[field_name].count()) 115 | 116 | bin_values = list(bins) 117 | keys = [] 118 | for i in range(0, len(closed_bins)): 119 | low = str(round(float(bin_values[i]), 1)) 120 | high = str(round(float(bin_values[i + 1]), 1)) 121 | key = "{} - {}".format(low, high) 122 | keys.append(key) 123 | else: 124 | self._say("Unique element histogram", log_only=False) 125 | vc: pd.Series = vec.value_counts(sort=True, dropna=False) 126 | keys = list(vc.keys()) 127 | values = vc.values 128 | 129 | fig = tpl.figure() 130 | fig.barh(values, keys, force_ascii=False) 131 | fig.show() 132 | 133 | def _describe(self, data, field): 134 | desc = {} 135 | 136 | df = pd.DataFrame(data) 137 | vec = df[field] 138 | 139 | # Field name 140 | desc["field_name"] = {'label': 'Field name', 'value': field} 141 | 142 | # Store the DataFrame 143 | desc["df"] = df 144 | 145 | # Field type 146 | field_type = common.get_field_type(field) 147 | desc["field_type"] = {'label': 'Field type', 'value': field_type} 148 | 149 | # Field type Auto 150 | field_type_auto = None 151 | if not field_type: 152 | field_type_auto = common.get_automatic_type_for_field(field) 153 | desc["field_type_auto"] = {'label': 'Auto type', 'value': field_type_auto} 154 | 155 | # Total count 156 | total_count = vec.count() 157 | desc["total_count"] = {'label': 'Count', 'value': total_count} 158 | 159 | if common.is_numeric(field_type, field_type_auto): 160 | vec = pd.to_numeric(vec, errors='coerce').dropna() 161 | 162 | # Min 163 | min = vec.min() 164 | desc["min"] = {'label': 'Min', 'value': min} 165 | 166 | # Max 167 | max = vec.max() 168 | desc["max"] = {'label': 'Max', 'value': max} 169 | 170 | # Mean 171 | mean = vec.mean() 172 | desc["mean"] = {'label': 'Mean', 'value': mean} 173 | 174 | # Median 175 | median = vec.median() 176 | desc["median"] = {'label': 'Median', 'value': median} 177 | 178 | # Null Count (na is dropped on vec) 179 | null_count = total_count - vec.count() 180 | desc["null_count"] = {'label': 'Empty', 'value': null_count} 181 | else: 182 | vc: pd.Series = vec.value_counts(sort=True, dropna=False) 183 | 184 | # Unique count 185 | unique_count = vc.count() 186 | desc["unique_count"] = {'label': 'Unique', 'value': unique_count} 187 | 188 | # Unique First 189 | unique_keys = list(vc.keys()) 190 | unique_first_key = unique_keys[0] 191 | unique_first_val = vc.max() 192 | unique_first_str = "{}({})".format(unique_first_key, unique_first_val) 193 | desc["unique_first"] = {'label': 'Most frequent', 'value': unique_first_str} 194 | 195 | # Unique First 196 | unique_last_key = unique_keys[-1] 197 | unique_last_val = vc.min() 198 | unique_last_str = "{}({})".format(unique_last_key, unique_last_val) 199 | desc["unique_last"] = {'label': 'Least frequent', 'value': unique_last_str} 200 | 201 | null_count = (df[field] == '').sum() 202 | desc["null_count"] = {'label': 'Empty', 'value': null_count} 203 | 204 | return desc 205 | 206 | def _extract_data_from_items(self, items, fields): 207 | data = [] 208 | 209 | for item in items: 210 | item: Item 211 | item_data = {} 212 | 213 | for field in fields: 214 | item_data[field] = item.get(field, default="") 215 | 216 | data.append(item_data) 217 | 218 | return data 219 | 220 | def _retrieve_library_items(self): 221 | full_query = self.query 222 | 223 | # parsed_query = parse_query_string(" ".join(full_query), Item)[0] 224 | parsed_query = parse_query_parts(full_query, Item)[0] 225 | 226 | self._say("Selection query: {}".format(parsed_query), log_only=True) 227 | 228 | return self.lib.items(parsed_query) 229 | 230 | def show_version_information(self): 231 | self._say("{pt}({pn}) plugin for Beets: v{ver}".format( 232 | pt=common.plg_ns['__PACKAGE_TITLE__'], 233 | pn=common.plg_ns['__PACKAGE_NAME__'], 234 | ver=common.plg_ns['__version__'] 235 | ), log_only=False) 236 | 237 | def _say(self, msg, log_only=False): 238 | common.say(msg, log_only) 239 | --------------------------------------------------------------------------------