├── .flake8 ├── .github ├── dependabot.yml ├── labels.yml ├── release-drafter.yml └── workflows │ ├── auto-merge-deps.yml │ ├── check-build.yml │ ├── labeler.yml │ ├── release-draft.yml │ └── testsuite.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── beetsplug ├── __init__.py └── summarize.py ├── pyproject.toml └── tests └── test_summarize.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | E203 4 | T201 5 | max-line-length = 88 6 | max-complexity = 20 7 | rst-roles = 8 | class 9 | func 10 | mod 11 | data 12 | const 13 | meth 14 | attr 15 | exc 16 | obj 17 | rst-directives = 18 | note 19 | warning 20 | versionadded 21 | versionchanged 22 | deprecated 23 | seealso 24 | per-file-ignores = 25 | tests/*:D 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: pip 8 | directory: "/.github/workflows" 9 | schedule: 10 | interval: monthly 11 | - package-ecosystem: pip 12 | directory: "/docs" 13 | schedule: 14 | interval: monthly 15 | - package-ecosystem: pip 16 | directory: "/" 17 | schedule: 18 | interval: monthly 19 | versioning-strategy: lockfile-only 20 | allow: 21 | - dependency-type: "all" 22 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Labels names are important as they are used by Release Drafter to decide 3 | # regarding where to record them in changelog or if to skip them. 4 | # 5 | # The repository labels will be automatically configured using this file and 6 | # the GitHub Action https://github.com/marketplace/actions/github-labeler. 7 | - name: breaking 8 | description: Breaking Changes 9 | color: bfd4f2 10 | - name: bug 11 | description: Something isn't working 12 | color: d73a4a 13 | - name: build 14 | description: Build System and Dependencies 15 | color: bfdadc 16 | - name: ci 17 | description: Continuous Integration 18 | color: 4a97d6 19 | - name: dependencies 20 | description: Pull requests that update a dependency file 21 | color: 0366d6 22 | - name: documentation 23 | description: Improvements or additions to documentation 24 | color: 0075ca 25 | - name: duplicate 26 | description: This issue or pull request already exists 27 | color: cfd3d7 28 | - name: enhancement 29 | description: New feature or request 30 | color: a2eeef 31 | - name: github_actions 32 | description: Pull requests that update Github_actions code 33 | color: "000000" 34 | - name: good first issue 35 | description: Good for newcomers 36 | color: 7057ff 37 | - name: help wanted 38 | description: Extra attention is needed 39 | color: 008672 40 | - name: invalid 41 | description: This doesn't seem right 42 | color: e4e669 43 | - name: performance 44 | description: Performance 45 | color: "016175" 46 | - name: python 47 | description: Pull requests that update Python code 48 | color: 2b67c6 49 | - name: question 50 | description: Further information is requested 51 | color: d876e3 52 | - name: refactoring 53 | description: Refactoring 54 | color: ef67c4 55 | - name: removal 56 | description: Removals and Deprecations 57 | color: 9ae7ea 58 | - name: style 59 | description: Style 60 | color: c120e5 61 | - name: testing 62 | description: Testing 63 | color: b1fc6f 64 | - name: wontfix 65 | description: This will not be worked on 66 | color: ffffff 67 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/marketplace/actions/release-drafter for configuration 2 | categories: 3 | - title: ":boom: Breaking Changes" 4 | label: "breaking" 5 | - title: ":rocket: Features" 6 | label: "enhancement" 7 | - title: ":fire: Removals and Deprecations" 8 | label: "removal" 9 | - title: ":beetle: Fixes" 10 | label: "bug" 11 | - title: ":racehorse: Performance" 12 | label: "performance" 13 | - title: ":rotating_light: Testing" 14 | label: "testing" 15 | - title: ":construction_worker: Continuous Integration" 16 | label: "ci" 17 | - title: ":books: Documentation" 18 | label: "documentation" 19 | - title: ":hammer: Refactoring" 20 | label: "refactoring" 21 | - title: ":lipstick: Style" 22 | label: "style" 23 | - title: ":package: Dependencies" 24 | labels: 25 | - "dependencies" 26 | - "build" 27 | name-template: 'v$RESOLVED_VERSION' 28 | tag-template: 'v$RESOLVED_VERSION' 29 | autolabeler: 30 | - label: 'documentation' 31 | files: 32 | - '*.md' 33 | branch: 34 | - '/.*docs{0,1}.*/' 35 | - label: 'bug' 36 | branch: 37 | - '/fix.*/' 38 | title: 39 | - '/fix/i' 40 | - label: 'enhancement' 41 | branch: 42 | - '/feature.*|add-.+/' 43 | title: 44 | - '/feat:.+|feature:.+/i' 45 | - label: "removal" 46 | title: 47 | - "/remove .*/i" 48 | - label: "performance" 49 | title: 50 | - "/.* performance .*/i" 51 | - label: "ci" 52 | files: 53 | - '.github/*' 54 | - '.pre-commit-config.yaml' 55 | - '.coveragrc' 56 | - label: "style" 57 | files: 58 | - ".flake8" 59 | - label: "refactoring" 60 | title: 61 | - "/.* refactor.*/i" 62 | 63 | template: | 64 | ## Changes 65 | 66 | $CHANGES 67 | -------------------------------------------------------------------------------- /.github/workflows/auto-merge-deps.yml: -------------------------------------------------------------------------------- 1 | name: auto-merge 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | auto-merge: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 12 | with: 13 | target: minor 14 | github-token: ${{ secrets.AUTO_MERGE }} 15 | -------------------------------------------------------------------------------- /.github/workflows/check-build.yml: -------------------------------------------------------------------------------- 1 | name: Check Distribution Build 2 | 3 | on: push 4 | 5 | jobs: 6 | check-build: 7 | name: Twine Check 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@master 11 | with: 12 | fetch-depth: 0 13 | 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | 18 | - name: Install Build Tools 19 | run: pip install build twine 20 | 21 | - name: Build a binary wheel 22 | run: | 23 | python -m build . 24 | 25 | - name: Check Distribution 26 | run: | 27 | twine check dist/* 28 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | labeler: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out the repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Run Labeler 16 | uses: crazy-max/ghaction-github-labeler@v5.3.0 17 | with: 18 | skip-delete: true 19 | -------------------------------------------------------------------------------- /.github/workflows/release-draft.yml: -------------------------------------------------------------------------------- 1 | name: Update Draft Release 2 | 3 | on: push 4 | 5 | jobs: 6 | draft-release: 7 | name: Update Draft Release 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@master 11 | with: 12 | fetch-depth: 0 13 | 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | 18 | - name: Publish the release notes 19 | uses: release-drafter/release-drafter@v6.1.0 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/testsuite.yaml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | on: [push, pull_request] 3 | 4 | 5 | jobs: 6 | tests: 7 | name: Test Suite 8 | runs-on: ${{ matrix.os }} 9 | # defaults: 10 | # run: 11 | # shell: bash -l {0} 12 | env: 13 | ENV_NAME: testing 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | python-version: [3.8, 3.9, "3.10", "3.11"] 19 | steps: 20 | - uses: actions/checkout@master 21 | with: 22 | fetch-depth: 1 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install Package and Test Deps 30 | run: | 31 | echo $(which python) 32 | python -m pip install .[tests] 33 | 34 | - name: Run Tests 35 | run: | 36 | python -m pytest --cov-config=.coveragerc --cov-report xml:./coverage.xml 37 | 38 | - uses: codecov/codecov-action@v5 39 | if: success() 40 | with: 41 | file: ./coverage.xml #optional 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | *.py~ 61 | 62 | \.idea/ 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^docs/conf.py' 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: check-added-large-files 9 | - id: check-ast 10 | - id: check-json 11 | - id: check-merge-conflict 12 | - id: check-xml 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | - id: requirements-txt-fixer 16 | - id: mixed-line-ending 17 | args: ['--fix=no'] 18 | 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 7.1.1 21 | hooks: 22 | - id: flake8 23 | additional_dependencies: 24 | - flake8-comprehensions 25 | - flake8-logging-format 26 | - flake8-builtins 27 | - flake8-eradicate 28 | - pep8-naming 29 | - flake8-pytest 30 | - flake8-docstrings 31 | - flake8-rst-docstrings 32 | - flake8-rst 33 | - flake8-copyright 34 | - flake8-markdown 35 | - flake8-bugbear 36 | - flake8-comprehensions 37 | - flake8-print 38 | 39 | 40 | - repo: https://github.com/psf/black 41 | rev: 25.1.0 42 | hooks: 43 | - id: black 44 | 45 | - repo: https://github.com/PyCQA/isort 46 | rev: 6.0.0 47 | hooks: 48 | - id: isort 49 | 50 | - repo: https://github.com/pre-commit/pygrep-hooks 51 | rev: v1.10.0 52 | hooks: 53 | - id: rst-backticks 54 | 55 | - repo: https://github.com/asottile/pyupgrade 56 | rev: v3.19.1 57 | hooks: 58 | - id: pyupgrade 59 | args: [--py38-plus] 60 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Steven Murray 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/steven-murray/beet-summarize/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and/or "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and/or "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | ``summarize`` could always use more documentation. At this point, documentation 42 | is confined to examples in the README, and these are always welcome. 43 | 44 | Submit Feedback 45 | ~~~~~~~~~~~~~~~ 46 | 47 | The best way to send feedback is to file an issue at https://github.com/steven-murray/beet-summarize/issues. 48 | 49 | If you are proposing a feature: 50 | 51 | * Explain in detail how it would work. 52 | * Keep the scope as narrow as possible, to make it easier to implement. 53 | * Remember that this is a volunteer-driven project, and that contributions 54 | are welcome :-) 55 | 56 | Get Started! 57 | ------------ 58 | 59 | Ready to contribute? Here's how to set up ``summarize`` for local development. 60 | 61 | 1. Fork the ``beet-summarize`` repo on GitHub. 62 | 2. Clone your fork locally:: 63 | 64 | $ git clone git@github.com:your_name_here/beet-summarize.git 65 | 66 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 67 | 68 | $ mkvirtualenv summarize 69 | $ cd beet-summarize/ 70 | $ python setup.py develop 71 | 72 | 4. Create a branch for local development:: 73 | 74 | $ git checkout -b name-of-your-bugfix-or-feature 75 | 76 | Now you can make your changes locally. 77 | 78 | 5. When you're done making changes, check that your changes pass flake8 and the 79 | tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 summarize tests 82 | $ python setup.py test or py.test 83 | $ tox 84 | 85 | To get flake8 and tox, just pip install them into your virtualenv. 86 | 87 | 6. Commit your changes and push your branch to GitHub:: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 7. Submit a pull request through the GitHub website. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.rst. 104 | 3. The pull request should work for Python 3.5, 3.6 and 3.7. Check 105 | https://travis-ci.org/steven-murray/beet-summarize/pull_requests 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | $ py.test tests.test_summarize 114 | 115 | 116 | Deploying 117 | --------- 118 | 119 | A reminder for the maintainers on how to deploy. 120 | Make sure all your changes are committed (including an entry in HISTORY.rst). 121 | Then run:: 122 | 123 | $ bumpversion patch # possible: major / minor / patch 124 | $ git push 125 | $ git push --tags 126 | 127 | Travis will then deploy to PyPI if tests pass. 128 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.0 [05/07/2019] 6 | ------------------ 7 | 8 | * First basically-working release. 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | GNU LESSER GENERAL PUBLIC LICENSE 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | 10 | This version of the GNU Lesser General Public License incorporates 11 | the terms and conditions of version 3 of the GNU General Public 12 | License, supplemented by the additional permissions listed below. 13 | 14 | 0. Additional Definitions. 15 | 16 | As used herein, "this License" refers to version 3 of the GNU Lesser 17 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 18 | General Public License. 19 | 20 | "The Library" refers to a covered work governed by this License, 21 | other than an Application or a Combined Work as defined below. 22 | 23 | An "Application" is any work that makes use of an interface provided 24 | by the Library, but which is not otherwise based on the Library. 25 | Defining a subclass of a class defined by the Library is deemed a mode 26 | of using an interface provided by the Library. 27 | 28 | A "Combined Work" is a work produced by combining or linking an 29 | Application with the Library. The particular version of the Library 30 | with which the Combined Work was made is also called the "Linked 31 | Version". 32 | 33 | The "Minimal Corresponding Source" for a Combined Work means the 34 | Corresponding Source for the Combined Work, excluding any source code 35 | for portions of the Combined Work that, considered in isolation, are 36 | based on the Application, and not on the Linked Version. 37 | 38 | The "Corresponding Application Code" for a Combined Work means the 39 | object code and/or source code for the Application, including any data 40 | and utility programs needed for reproducing the Combined Work from the 41 | Application, but excluding the System Libraries of the Combined Work. 42 | 43 | 1. Exception to Section 3 of the GNU GPL. 44 | 45 | You may convey a covered work under sections 3 and 4 of this License 46 | without being bound by section 3 of the GNU GPL. 47 | 48 | 2. Conveying Modified Versions. 49 | 50 | If you modify a copy of the Library, and, in your modifications, a 51 | facility refers to a function or data to be supplied by an Application 52 | that uses the facility (other than as an argument passed when the 53 | facility is invoked), then you may convey a copy of the modified 54 | version: 55 | 56 | a) under this License, provided that you make a good faith effort to 57 | ensure that, in the event an Application does not supply the 58 | function or data, the facility still operates, and performs 59 | whatever part of its purpose remains meaningful, or 60 | 61 | b) under the GNU GPL, with none of the additional permissions of 62 | this License applicable to that copy. 63 | 64 | 3. Object Code Incorporating Material from Library Header Files. 65 | 66 | The object code form of an Application may incorporate material from 67 | a header file that is part of the Library. You may convey such object 68 | code under terms of your choice, provided that, if the incorporated 69 | material is not limited to numerical parameters, data structure 70 | layouts and accessors, or small macros, inline functions and templates 71 | (ten or fewer lines in length), you do both of the following: 72 | 73 | a) Give prominent notice with each copy of the object code that the 74 | Library is used in it and that the Library and its use are 75 | covered by this License. 76 | 77 | b) Accompany the object code with a copy of the GNU GPL and this license 78 | document. 79 | 80 | 4. Combined Works. 81 | 82 | You may convey a Combined Work under terms of your choice that, 83 | taken together, effectively do not restrict modification of the 84 | portions of the Library contained in the Combined Work and reverse 85 | engineering for debugging such modifications, if you also do each of 86 | the following: 87 | 88 | a) Give prominent notice with each copy of the Combined Work that 89 | the Library is used in it and that the Library and its use are 90 | covered by this License. 91 | 92 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 93 | document. 94 | 95 | c) For a Combined Work that displays copyright notices during 96 | execution, include the copyright notice for the Library among 97 | these notices, as well as a reference directing the user to the 98 | copies of the GNU GPL and this license document. 99 | 100 | d) Do one of the following: 101 | 102 | 0) Convey the Minimal Corresponding Source under the terms of this 103 | License, and the Corresponding Application Code in a form 104 | suitable for, and under terms that permit, the user to 105 | recombine or relink the Application with a modified version of 106 | the Linked Version to produce a modified Combined Work, in the 107 | manner specified by section 6 of the GNU GPL for conveying 108 | Corresponding Source. 109 | 110 | 1) Use a suitable shared library mechanism for linking with the 111 | Library. A suitable mechanism is one that (a) uses at run time 112 | a copy of the Library already present on the user's computer 113 | system, and (b) will operate properly with a modified version 114 | of the Library that is interface-compatible with the Linked 115 | Version. 116 | 117 | e) Provide Installation Information, but only if you would otherwise 118 | be required to provide such information under section 6 of the 119 | GNU GPL, and only to the extent that such information is 120 | necessary to install and execute a modified version of the 121 | Combined Work produced by recombining or relinking the 122 | Application with a modified version of the Linked Version. (If 123 | you use option 4d0, the Installation Information must accompany 124 | the Minimal Corresponding Source and Corresponding Application 125 | Code. If you use option 4d1, you must provide the Installation 126 | Information in the manner specified by section 6 of the GNU GPL 127 | for conveying Corresponding Source.) 128 | 129 | 5. Combined Libraries. 130 | 131 | You may place library facilities that are a work based on the 132 | Library side by side in a single library together with other library 133 | facilities that are not Applications and are not covered by this 134 | License, and convey such a combined library under terms of your 135 | choice, if you do both of the following: 136 | 137 | a) Accompany the combined library with a copy of the same work based 138 | on the Library, uncombined with any other library facilities, 139 | conveyed under the terms of this License. 140 | 141 | b) Give prominent notice with the combined library that part of it 142 | is a work based on the Library, and explaining where to find the 143 | accompanying uncombined form of the same work. 144 | 145 | 6. Revised Versions of the GNU Lesser General Public License. 146 | 147 | The Free Software Foundation may publish revised and/or new versions 148 | of the GNU Lesser General Public License from time to time. Such new 149 | versions will be similar in spirit to the present version, but may 150 | differ in detail to address new problems or concerns. 151 | 152 | Each version is given a distinguishing version number. If the 153 | Library as you received it specifies that a certain numbered version 154 | of the GNU Lesser General Public License "or any later version" 155 | applies to it, you have the option of following the terms and 156 | conditions either of that published version or of any later version 157 | published by the Free Software Foundation. If the Library as you 158 | received it does not specify a version number of the GNU Lesser 159 | General Public License, you may choose any version of the GNU Lesser 160 | General Public License ever published by the Free Software Foundation. 161 | 162 | If the Library as you received it specifies that a proxy can decide 163 | whether future versions of the GNU Lesser General Public License shall 164 | apply, that proxy's public statement of acceptance of any version is 165 | permanent authorization for you to choose that version for the 166 | Library. 167 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Beet Summarize 2 | 3 | 4 | [![Tests](https://travis-ci.org/steven-murray/beet-summarize.svg?branch=master)](https://travis-ci.org/steven-murray/beet-summarize.svg?branch=master) 5 | [![Coverage Status](https://coveralls.io/repos/github/steven-murray/beet-summarize/badge.svg?branch=master)](https://coveralls.io/github/steven-murray/beet-summarize?branch=master) 6 | 7 | **Summarize your beets library** 8 | 9 | ``` 10 | $ beet summarize 11 | 12 | genre | count 13 | ---------------------- | ----- 14 | Rock | 340 15 | Classical | 268 16 | Folk | 248 17 | Pop | 248 18 | ``` 19 | 20 | ``beet-summarize`` is a plugin for the ``beets`` music organisation library, 21 | that provides the ability to summarize statistics according to fields. 22 | 23 | ## Installation 24 | 25 | ``` 26 | $ pip install git+https://github.com/steven-murray/beet-summarize.git 27 | ``` 28 | 29 | 30 | Then add ``summarize`` to your list of ``plugins`` in your beets' ``config.yml``. 31 | 32 | ## Usage 33 | 34 | ``beet-summarize`` adds the single sub-command ``summarize``. The interface is 35 | very simple: 36 | 37 | ``` 38 | Options: 39 | -g GROUP_BY, --group-by=GROUP_BY 40 | field to group by 41 | -s STATS, --stats=STATS 42 | stats to display 43 | -R, --not-reverse whether to not reverse the sort 44 | ``` 45 | 46 | In a bit more detail, ``-g`` will tell ``summarize`` which beets field it will 47 | summarize over. The default is ``genre``. We could also have grouped by year, 48 | and used the ``-R`` flag to reverse the results: 49 | 50 | ``` 51 | $ beet summarize -g year -R 52 | 53 | year | count 54 | ---- | ----- 55 | 1981 | 1 56 | 1991 | 4 57 | 1985 | 9 58 | 1982 | 10 59 | 1990 | 11 60 | ``` 61 | 62 | Perhaps most importantly, you can specify aggregate statistics to report via 63 | ``-s`` or ``--stats``. Each statistic is a valid field with optional pre-pending 64 | modifiers. Modifiers include an aggregation function (options are ``MIN``, 65 | ``MAX``, ``SUM``, ``COUNT``, ``AVG``, ``RANGE``), whether to only include 66 | ``UNIQUE`` entries, and converters for when the field is of str type 67 | (options are ``LEN`` and ``WORDS``). The default statistic is ``count``. 68 | You can give multiple statistics by enclosing them in quotes. The order of the results will 69 | be based on the first given statistic. The format for each statistic is 70 | ``aggregator<:modifier>|field``, except for ``count`` which is special 71 | and does not require a field. 72 | 73 | As an example: 74 | 75 | ``` 76 | $ beet summarize -g year -s "count avg|bitrate avg:words|lyrics count:unique|artist" 77 | 78 | year | count | avg|bitrate | avg:words|lyrics | count:unique|artist 79 | ---- | ----- | ----------------- | ------------------ | ------------------- 80 | 2006 | 317 | 648899.5741324921 | 273.51419558359623 | 41 81 | 2009 | 244 | 709426.0778688524 | 660.7786885245902 | 17 82 | 2005 | 241 | 754819.5145228215 | 681.6099585062241 | 24 83 | 2010 | 203 | 747686.5615763547 | 537.1133004926108 | 51 84 | ``` 85 | 86 | Here the first statistic is just the number of tracks in the library for each 87 | given year (and this sorts the table). The second column is the average 88 | bitrate of tracks per year. The third column is the average number of words 89 | in the lyrics of each track per year. The final column is the number of unique 90 | artists on tracks per year. 91 | 92 | ### String conversions 93 | 94 | For string-valued fields (eg. artist, lyrics), if the statistic is not ``count``, 95 | some way to convert the field string to a numerical value is required. A number 96 | of ways could be thought of, but currently ``summarize`` only supports the 97 | number of words or number of characters, specified by the modifiers ``words`` 98 | and ``len`` respectively. The latter is default. ``words`` are defined by 99 | characters separated by spaces. 100 | 101 | ### ``unique`` modifier 102 | 103 | The ``unique`` modifier is always applied directly on the field. This is usually 104 | what is wanted: eg. ``count:unique|artist`` produces the number of *different* 105 | artists. However, it can be a little confusing in scenarios such as 106 | ``sum:unique:words|lyrics``. This does *not* count the total number of unique 107 | words in all the lyrics for tracks in the given category. Instead, it applies 108 | the ``unique`` modifier to the field as a whole. So, it is the sum of the total 109 | number of words for each track that has unique lyrics. 110 | 111 | ## Ideas for improvement 112 | 113 | - More string conversions (and perhaps even the ability to not convert from 114 | string to number for some aggregators, eg. range could be defined directly 115 | on a string by ordering lexically). 116 | - Ability to specify ``unique`` in such a way that it is applied on elements 117 | of the field rather than the entire field, so that eg. getting the total number 118 | of unique words in all lyrics for a given year is possible. 119 | - Multi-level categories, i.e. being able to categorize by genre, then by year 120 | within the genre, and provide statistics at each level. 121 | - Ability to pass an ``-a`` flag to do stats at the album-level. 122 | -------------------------------------------------------------------------------- /beetsplug/__init__.py: -------------------------------------------------------------------------------- 1 | """Beets plug-in for generating statistics about your music library.""" 2 | 3 | from pkgutil import extend_path 4 | 5 | __version__ = "0.2.0" 6 | __path__ = extend_path(__path__, __name__) 7 | -------------------------------------------------------------------------------- /beetsplug/summarize.py: -------------------------------------------------------------------------------- 1 | """Summarize library statistics.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import OrderedDict 6 | 7 | from beets.plugins import BeetsPlugin 8 | from beets.ui import Subcommand, decargs 9 | 10 | summarize_command = Subcommand("summarize", help="summarize library statistics") 11 | 12 | summarize_command.parser.add_option( 13 | "-g", "--group-by", type="string", help="field to group by", default="genre" 14 | ) 15 | 16 | summarize_command.parser.add_option( 17 | "-s", "--stats", type="string", help="stats to display", default="count" 18 | ) 19 | 20 | summarize_command.parser.add_option( 21 | "-R", "--not-reverse", action="store_true", help="whether to not reverse the sort" 22 | ) 23 | 24 | 25 | def parse_stat(stat): 26 | """Parse a cmdline stat string. 27 | 28 | Parameters 29 | ---------- 30 | stat 31 | string specifying the statistic to obtain. Format is 32 | "aggregator<:modifier>|field". Available aggregators are {min, max, count, sum, 33 | avg, range}. Available modifiers are {unique, len, words}, where the final two 34 | are only available for string fields. Available `fields` are any beets field. 35 | 36 | Returns 37 | ------- 38 | dict 39 | The statistics. Keys are 'field' (str field), 'aggregator' (str aggregator), 40 | 'str_converter' (either 'len' or 'words' or None), and 'unique' (bool). 41 | """ 42 | aggregators = ["min", "max", "count", "sum", "avg", "range"] 43 | str_converters = ["len", "words"] 44 | 45 | this = {} 46 | 47 | # Special case: count 48 | if stat.lower() == "count": 49 | this["field"] = "title" # irrelevant 50 | this["aggregator"] = "count" 51 | this["str_converter"] = None 52 | this["unique"] = False 53 | return this 54 | 55 | # Get the field name of the statistic 56 | this["field"] = stat.split("|")[-1].lower() 57 | 58 | # There doesn't *need* to be any modifiers. 59 | modifiers = stat.split("|")[0].lower().split(":") if "|" in stat else [] 60 | 61 | # Determine the aggregator for this statistic 62 | aggregator = None 63 | for agg in aggregators: 64 | if agg in modifiers: 65 | if aggregator is None: 66 | this["aggregator"] = aggregator = agg 67 | else: 68 | raise ValueError(f"You have specified more than one aggregator: {stat}") 69 | 70 | if "aggregator" not in this: 71 | this["aggregator"] = "sum" 72 | 73 | # Get str converter 74 | this["str_converter"] = [m for m in modifiers if m in str_converters] 75 | if len(this["str_converter"]) > 1: 76 | raise ValueError(f"You have specified more than one str conversion: {stat}") 77 | if "str_converter" in this and this["str_converter"]: 78 | this["str_converter"] = this["str_converter"][0] 79 | else: 80 | this["str_converter"] = None 81 | 82 | # Get specific modifiers 83 | this["unique"] = "unique" in modifiers 84 | 85 | return this 86 | 87 | 88 | def parse_stats(stats: str) -> dict[str, dict]: 89 | """Parse a cmdline stats string. 90 | 91 | Parameters 92 | ---------- 93 | stats 94 | string with stats separated by spaces. For format of stats, see 95 | :func:`parse_stat`. 96 | 97 | Returns 98 | ------- 99 | OrderedDict 100 | keys are each full stat string in `stats`. Values are 101 | dictionaries from `parse_stat` for each stat. 102 | str 103 | the first stat. 104 | """ 105 | stats = stats.split(" ") 106 | return OrderedDict([(stat, parse_stat(stat)) for stat in stats]) 107 | 108 | 109 | def set_str_converter(stat, stat_type): 110 | """Set str_converter field for a stat dict. 111 | 112 | Only applies if the field type is str and the converter does not yet exist. 113 | """ 114 | # For strings arguments, require a way to turn 115 | # the string into a numerical value. By default, 116 | # use the length of the string. 117 | if stat_type is str and not stat["str_converter"]: 118 | stat["str_converter"] = "len" 119 | 120 | 121 | def group_by(category: str, items): 122 | """Group a list of items by a category. 123 | 124 | If the category is one that supports multiple values, split them by ";" and add 125 | the item to each of the groups. 126 | """ 127 | multifield_categories = ["albumartist", "artist", "genre"] 128 | 129 | out = {} 130 | for item in items: 131 | cat = getattr(item, category) 132 | if category in multifield_categories: 133 | cats = [c.strip() for c in cat.split(";")] 134 | else: 135 | cats = [cat] 136 | 137 | for cat in cats: 138 | 139 | if cat not in out: 140 | out[cat] = [] 141 | 142 | out[cat].append(item) 143 | 144 | return out 145 | 146 | 147 | def get_items_stat(items, stat): 148 | """Get a statistic for a list of items.""" 149 | collection = set() if stat["unique"] else [] 150 | 151 | # Collect all the stats 152 | for item in items: 153 | val = getattr(item, stat["field"]) 154 | 155 | if stat["unique"]: 156 | collection.add(val) 157 | else: 158 | collection.append(val) 159 | 160 | stat_type = type(getattr(items[0], stat["field"])) 161 | 162 | # We can turn the collection into a list now 163 | collection = list(collection) 164 | 165 | # Convert str stats 166 | if stat_type is str and stat["aggregator"] != "count": 167 | if stat["str_converter"] == "len": 168 | collection = [len(c) for c in collection] 169 | elif stat["str_converter"] == "words": 170 | collection = [len(c.split(" ")) for c in collection] 171 | 172 | # Aggregate 173 | if stat["aggregator"] == "min": 174 | return min(collection) 175 | elif stat["aggregator"] == "max": 176 | return max(collection) 177 | elif stat["aggregator"] == "count": 178 | return len(collection) 179 | elif stat["aggregator"] == "range": 180 | return max(collection) - min(collection) 181 | elif stat["aggregator"] == "sum": 182 | return sum(collection) 183 | elif stat["aggregator"] == "avg": 184 | return sum(collection) / len(collection) 185 | 186 | 187 | def print_dct_as_table(keys, dcts, cat_name=None, col_formats=None) -> str: 188 | """Pretty print a list of dictionaries as a dynamically sized table. 189 | 190 | If column names aren't specified, they will show in random order. 191 | """ 192 | columns = list(dcts[0].keys()) 193 | 194 | table = [[cat_name] + columns] if cat_name else [[""] + columns] 195 | 196 | for key, item in zip(keys, dcts): 197 | content = [ 198 | "{val:{fmt}}".format( 199 | val=item[col], fmt=col_formats[col] if col_formats else "" 200 | ) 201 | for col in columns 202 | ] 203 | table.append([str(key)] + content) 204 | 205 | col_size = [max(map(len, col)) for col in zip(*table)] 206 | 207 | fmt_str = " | ".join([f"{{:<{i}}}" for i in col_size]) 208 | table.insert(1, ["-" * i for i in col_size]) # Seperating line 209 | txt = "\n".join([fmt_str.format(*row) for row in table]) 210 | 211 | print(txt) 212 | return txt 213 | 214 | 215 | def print_results(res, cat_name, sort_stat, reverse): 216 | """Print the results of a summary.""" 217 | keys = sorted(res.keys(), key=lambda x: res[x][sort_stat], reverse=reverse) 218 | dcts = [res[key] for key in keys] 219 | 220 | return print_dct_as_table(keys, dcts, cat_name) 221 | 222 | 223 | def show_summary(lib, query, category, stats, reverse): 224 | """Show a summary of the statistics.""" 225 | # TODO: albums? 226 | 227 | items = lib.items(query) 228 | stats = parse_stats(stats) 229 | sort_stat = list(stats.keys())[0] 230 | 231 | for stat in stats.values(): 232 | set_str_converter(stat, type(getattr(items[0], stat["field"]))) 233 | 234 | groups = group_by(category, items) 235 | 236 | stat_dct = { 237 | g: {nm: get_items_stat(items, stat) for nm, stat in stats.items()} 238 | for g, items in groups.items() 239 | } 240 | return print_results(stat_dct, category, sort_stat, reverse) 241 | 242 | 243 | def summarize(lib, opts, args): 244 | """Summarize the library by a given field.""" 245 | show_summary(lib, decargs(args), opts.group_by, opts.stats, not opts.not_reverse) 246 | 247 | 248 | summarize_command.func = summarize 249 | 250 | 251 | class SuperPlug(BeetsPlugin): 252 | """Subclass of the BeetsPlugin to create the command.""" 253 | 254 | def commands(self): 255 | """Add the summarize command.""" 256 | return [summarize_command] 257 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools-scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | 7 | [tool.black] 8 | line-length = 88 9 | py36 = true 10 | exclude = ''' 11 | /( 12 | \.eggs 13 | | \.git 14 | | \.hg 15 | | \.mypy_cache 16 | | \.tox 17 | | \.venv 18 | | _build 19 | | buck-out 20 | | build 21 | | dist 22 | )/ 23 | ''' 24 | 25 | [project] 26 | name = "summarize" 27 | authors = [ 28 | {name = "Steven Murray", email = "steven.g.murray@asu.edu"}, 29 | ] 30 | description = "Summarize your beets library" 31 | readme = "README.md" 32 | requires-python = ">=3.8" 33 | license = {text = "MIT"} 34 | classifiers = [ 35 | "Development Status :: 4 - Beta", 36 | "License :: OSI Approved :: MIT License", 37 | "Natural Language :: English", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.8", 40 | "Programming Language :: Python :: 3.9", 41 | "Programming Language :: Python :: 3.10", 42 | "Programming Language :: Python :: 3.11", 43 | ] 44 | dependencies = [ 45 | 'beets>=1.5.0', 46 | ] 47 | dynamic = ["version"] 48 | 49 | [project.urls] 50 | Repository = "https://github.com/steven-murray/beet-summarize" 51 | Changelog = "https://github.com/steven-murray/beet-summarize/releases" 52 | 53 | [project.optional-dependencies] 54 | tests = [ 55 | "pytest", 56 | "pytest-cov" 57 | ] 58 | 59 | dev = [ 60 | "pre-commit", 61 | "summarize[tests]", 62 | ] 63 | -------------------------------------------------------------------------------- /tests/test_summarize.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from beetsplug import summarize as sm 6 | 7 | 8 | class MockItem: 9 | def __init__( 10 | self, title: str, year: int, artist: str, album: str, bitrate: int, lyrics: str 11 | ): 12 | self.title = title 13 | self.year = year 14 | self.artist = artist 15 | self.album = album 16 | self.bitrate = bitrate 17 | self.lyrics = lyrics 18 | 19 | 20 | class MockLibrary: 21 | def __init__(self): 22 | self._items = [] 23 | 24 | def add(self, item: MockItem): 25 | self._items.append(item) 26 | 27 | def items(self, query): 28 | return self._items # ignore query for now 29 | 30 | 31 | @pytest.fixture(scope="module") 32 | def lib(): 33 | lib = MockLibrary() 34 | lib.add(MockItem("song1", 2000, "artist1", "album1", 128, "lyrics1")) 35 | lib.add(MockItem("song2", 2001, "artist2", "album2", 256, "lyrics2")) 36 | lib.add(MockItem("song3", 2002, "artist3", "album3", 512, "lyrics3")) 37 | lib.add( 38 | MockItem( 39 | "song4", 40 | 2003, 41 | "artist3; artist4", 42 | "album4", 43 | 700, 44 | "so many lyrics in this song", 45 | ) 46 | ) 47 | 48 | return lib 49 | 50 | 51 | def test_parse_stat(): 52 | print(sys.path) 53 | 54 | out = sm.parse_stat("count") 55 | 56 | assert out["aggregator"] == "count" 57 | assert out["str_converter"] is None 58 | assert not out["unique"] 59 | 60 | out = sm.parse_stat("avg|bitrate") 61 | assert out["aggregator"] == "avg" 62 | assert out["str_converter"] is None 63 | assert not out["unique"] 64 | assert out["field"] == "bitrate" 65 | 66 | out = sm.parse_stat("bitrate") 67 | assert out["aggregator"] == "sum" # default aggregator 68 | assert out["str_converter"] is None 69 | assert not out["unique"] 70 | assert out["field"] == "bitrate" 71 | 72 | out = sm.parse_stat("unique:words|lyrics") 73 | assert out["aggregator"] == "sum" 74 | assert out["str_converter"] == "words" 75 | assert out["unique"] 76 | assert out["field"] == "lyrics" 77 | 78 | out = sm.parse_stat("range:unique:words|artist") 79 | assert out["aggregator"] == "range" 80 | assert out["str_converter"] == "words" 81 | assert out["unique"] 82 | assert out["field"] == "artist" 83 | 84 | 85 | def test_show_summary(lib): 86 | stats = "count avg|bitrate unique:words|lyrics range:unique:words|artist" 87 | txt = sm.show_summary(lib, "query", category="year", stats=stats, reverse=False) 88 | 89 | assert "2000 |" in txt 90 | assert "2001 |" in txt 91 | assert "2002 |" in txt 92 | 93 | 94 | def test_show_summary_artist(lib): 95 | stats = "count" 96 | txt = sm.show_summary(lib, "query", category="artist", stats=stats, reverse=False) 97 | 98 | assert "artist1 | 1" in txt 99 | assert "artist2 | 1" in txt 100 | assert "artist3 | 2" in txt # in the multi-field 101 | assert "artist4 | 1" in txt # in the multi-field 102 | --------------------------------------------------------------------------------