├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md └── workflows │ └── test_release_deploy.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── beetsplug ├── __init__.py └── goingrunning │ ├── __init__.py │ ├── about.py │ ├── command.py │ ├── common.py │ ├── config_default.yml │ ├── itemexport.py │ ├── itemorder.py │ └── itempick.py ├── docs ├── CHANGELOG.md └── ROADMAP.md ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── config ├── default.yml ├── empty.yml └── obsolete.yml ├── fixtures └── song.mp3 ├── functional ├── 000_basic_test.py ├── 001_configuration_test.py ├── 002_command_test.py └── __init__.py ├── helper.py ├── tmp └── helper.py └── unit ├── 000_init_test.py ├── 001_about_test.py ├── 002_common_test.py ├── 003_command_test.py └── __init__.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */test/* 4 | beetsplug/__init__.py 5 | exclude_lines = 6 | raise NotImplementedError 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug report" 3 | about: Report a problem with beets-goingrunning 4 | --- 5 | 6 | 12 | 13 | ### Problem 14 | 15 | Running your command in verbose (`-vv`) mode: 16 | 17 | ```sh 18 | $ beet -vv goingrunning (... paste here the rest...) 19 | ``` 20 | 21 | Led to this problem: 22 | 23 | ``` 24 | (paste here) 25 | ``` 26 | 27 | ### Setup 28 | 29 | - OS: 30 | - Python version: 31 | - Beets version: 32 | - Turning off other plugins made problem go away (yes/no): 33 | 34 | My configuration (output of `beet config`) is: 35 | 36 | ```yaml 37 | (paste here) 38 | ``` 39 | 40 | My plugin version (output of `beet goingrunning -v`) is: 41 | 42 | ```text 43 | (paste here) 44 | ``` 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature request" 3 | about: Suggest a new idea for beets-goingrunning 4 | --- 5 | 6 | ### Use case 7 | 8 | I'm trying to use the beets-goingrunning plugin to... 9 | 10 | ### Suggestion 11 | 12 | 18 | -------------------------------------------------------------------------------- /.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 alive-progress 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | prune test 2 | include LICENSE.txt 3 | include README.md 4 | include beetsplug/goingrunning/version.py 5 | include beetsplug/goingrunning/config_default.yml 6 | 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test & Release & Deploy](https://github.com/adamjakab/BeetsPluginGoingRunning/actions/workflows/test_release_deploy.yml/badge.svg)](https://github.com/adamjakab/BeetsPluginGoingRunning/actions/workflows/test_release_deploy.yml) 2 | [![Coverage Status](https://coveralls.io/repos/github/adamjakab/BeetsPluginGoingRunning/badge.svg?branch=master)](https://coveralls.io/github/adamjakab/BeetsPluginGoingRunning?branch=master) 3 | [![PyPi](https://img.shields.io/pypi/v/beets-goingrunning.svg)](https://pypi.org/project/beets-goingrunning/) 4 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/beets-goingrunning.svg)](https://pypi.org/project/beets-goingrunning/) 5 | [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt) 6 | 7 | # Going Running (Beets Plugin) 8 | 9 | The _beets-goingrunning_ is a [beets](https://github.com/beetbox/beets) plugin for obsessive-compulsive music geek runners. It lets you configure different training activities by filtering songs based on their tag attributes (bpm, length, mood, loudness, etc), generates a list of songs for that specific training and copies those songs to your player device. 10 | 11 | Have you ever tried to beat your PR and have good old Bob singing about ganja in the background? It doesn’t really work. Or don't you know how those recovery session end up with the Crüe kickstarting your heart? You'll be up in your Zone 4 in no time. 12 | 13 | The fact is that it is very difficult and time consuming to compile an appropriate playlist for a specific training session. This plugin tries to help runners with this by allowing them to use their own library. 14 | 15 | ## Introduction 16 | 17 | To use this plugin at its best and to benefit the most from your library, you will want to make sure that your songs have the most possible information on rhythm, moods, loudness, etc. 18 | 19 | Without going into much detail the most fundamental information you will want to harvest is `bpm`. Normally, when you run a fast pace training you will keep your pace (the number of times your feet hit the ground in a minute) around 170-180. If you are listening to songs with the same rhythm it helps a lot. If your library has many songs without the bpm information (check with `beet ls bpm:0`) you will not be able to use those songs. So, you should consider updating them. There are many tools you can use: 20 | 21 | 1. Use the built-in [acousticbrainz plugin](https://beets.readthedocs.io/en/stable/plugins/acousticbrainz.html) to fetch the bpm plus many other information about your songs. This is your starting point. It is as easy as `beet cousticbrainz` and it will do the rest. This tool is based on an on-line database so it will be able to fetch only what has been submitted by someone else. If you have many "uncommon" songs you will need to integrate it with other tools. (My library was still 30% uncovered after a full scan.) 22 | 23 | 2. Use the [bpmanalyser plugin](https://github.com/adamjakab/BeetsPluginBpmAnalyser). This will scan your songs and calculate the tempo (bpm) value for them. If you have a big collection it might take a while, but since this tool does not use an on-line database, you can potentially end up with 100% coverage. This plugin will only give you bpm info. 24 | 25 | 3. [Essentia extractors](https://essentia.upf.edu/index.html). The Acoustic Brainz (AB) project is based partly on these low and high level extractors. There is currently a highly under-development project [xtractor plugin](https://github.com/adamjakab/BeetsPluginXtractor) which aims to bring your library to 100% coverage. However, for the time being there are no distributable static extractors, so wou will have to compile your own extractors. 26 | 27 | There are many other ways and tools we could list here but I think you got the point... 28 | 29 | ## Installation 30 | 31 | The plugin can be installed via: 32 | 33 | ```shell script 34 | $ pip install beets-goingrunning 35 | ``` 36 | 37 | Activate the plugin in your configuration file by adding `goingrunning` to the plugins section: 38 | 39 | ```yaml 40 | plugins: 41 | - goingrunning 42 | ``` 43 | 44 | Check if plugin is loaded with `beet version`. It should list 'goingrunning' amongst the loaded plugins. 45 | 46 | If you already have the plugin installed but a newer version is available you can use `pip install --upgrade beets-goingrunning` to upgrade it. 47 | 48 | ## Usage 49 | 50 | Invoke the plugin as: 51 | 52 | $ beet goingrunning training [options] [QUERY...] 53 | 54 | or with the shorthand alias `run`: 55 | 56 | $ beet run training [options] [QUERY...] 57 | 58 | The following command line options are available: 59 | 60 | **--list [-l]**: List all the configured trainings. With `beet goingrunning --list` you will be presented the list of the trainings you have configured in your configuration file. 61 | 62 | **--count [-c]**: Count the number of songs available for a specific training. With `beet goingrunning longrun --count` you can see how many of your songs will fit the specifications for the `longrun` training. 63 | 64 | **--dry-run [-r]**: Only display what would be done without actually making changes to the file system. The plugin will run without clearing the destination and without copying any files. 65 | 66 | **--quiet [-q]**: Do not display any output from the command. 67 | 68 | **--version [-v]**: Display the version number of the plugin. Useful when you need to report some issue and you have to state the version of the plugin you are using. 69 | 70 | ## Configuration 71 | 72 | All your configuration will need to be created under the key `goingrunning`. There are three concepts you need to know to configure the plugin: `targets`, `trainings` and `flavours`. They are explained in detail below. 73 | 74 | ### Targets 75 | 76 | Targets are named destinations on your file system to which you will be copying your songs. The `targets` key allows you to define multiple targets so that under a specific training session you will only need to refer to it with the `target` key. 77 | 78 | The configuration of the target names `MPD1` will look like this: 79 | 80 | ```yaml 81 | goingrunning: 82 | targets: 83 | MPD1: 84 | device_root: /media/MPD1/ 85 | device_path: MUSIC/AUTO/ 86 | clean_target: yes 87 | delete_from_device: 88 | - LIBRARY.DAT 89 | generate_playlist: yes 90 | copy_files: yes 91 | ``` 92 | 93 | The key `device_root` indicates where your operating system mounts the device. The key `device_path` indicates the folder inside the device to which your audio files will be copied. In the above example the final destination is `/media/MPD1/MUSIC/AUTO/`. It is assumed that the folder indicated in the `device_path` key exists. If it doesn't the plugin will exit with a warning. The device path can also be an empty string if you want to store the files in the root folder of the device. 94 | 95 | The key `clean_target`, when set to yes, instructs the plugin to clean the `device_path` folder before copying the new songs to the device. This will remove all audio songs and playlists found in that folder. 96 | 97 | Some devices might have library files or other data files which need to be deleted in order for the device to re-discover the new songs. These files can be added to the `delete_from_device` key. The files listed here are relative to the `device_root` directive. 98 | 99 | You can also generate a playlist by setting the `generate_playlist` option to `yes`. It will create an .m3u playlist file and store it to your `device_path` location. 100 | 101 | There might be some special conditions in which you do not want to copy files to the device. In fact, the destination folder (`device_root`/`device_path`) might refer to an ordinary folder on your computer and you might want to create only a playlist there. In this case, you want to disable the copying of the music files by setting `copy_files: no`. By default, `copy_files` is always enabled so in the above `MPD1` target it could also be omitted and files would be copied all the same. 102 | 103 | ### Trainings 104 | 105 | Trainings are the central concept behind the plugin. When you are "going running" you will already have in mind the type of training you will be doing. This configuration section allows you to preconfigure filters that will allow you to launch a `beet run 10K` command whilst you are tying your shoelaces and be out of the house as quick as possible. In fact, the `trainings` section is there for you to be able to preconfigure these trainings. 106 | 107 | The configuration of a hypothetical 10K training might look like this: 108 | 109 | ```yaml 110 | goingrunning: 111 | trainings: 112 | 10K: 113 | query: 114 | bpm: 160..180 115 | mood_aggressive: 0.6.. 116 | ^genre: Reggae 117 | ordering: 118 | bpm: 100 119 | average_loudness: 50 120 | use_flavours: [] 121 | duration: 60 122 | target: MPD1 123 | ``` 124 | 125 | #### query 126 | 127 | The keys under the `query` section are exactly the same ones that you use when you are using beets for any other operation. Whatever is described in the [beets query documentation](https://beets.readthedocs.io/en/stable/reference/query.html) applies here with two restriction: you must query specific fields in the form of `field: value` and (for now) regular expressions are not supported. 128 | 129 | #### ordering 130 | 131 | At the time being there is only one ordering algorithm (`ScoreBasedLinearPermutation`) which orders your songs based on a scoring system. What you indicate under the `ordering` section is the fields by which the songs will be ordered. Each field will have a weight from -100 to 100 indicating how important that field is with respect to the others. Negative numbers indicate a reverse ordering. (@todo: this probably needs an example.) 132 | 133 | #### use_flavours 134 | 135 | You will find that many of the query specification that you come up with will be repeated across different trainings. To reduce repetition and at the same time to be able to combine many different recipes you can use flavours. Similarly to targets, instead of defining the queries directly on your training you can define queries in a separate section called `flavours` (see below) and then use the `use_flavours` key to indicate which flavours to use. The order in which flavours are indicated is important: the first one has the highest priority meaning that it will overwrite any keys that might be found in subsequent flavours. 136 | 137 | #### duration 138 | 139 | The duration is expressed in minutes and serves the purpose of defining the total length of the training so that the plugin can select the exact number of songs. 140 | 141 | #### target 142 | 143 | This key indicates to which target (defined in the `targets` section) your songs will be copied to. 144 | 145 | #### the `fallback` training 146 | 147 | You might also define a special `fallback` training: 148 | 149 | ```yaml 150 | goingrunning: 151 | trainings: 152 | fallback: 153 | target: my_other_player 154 | ``` 155 | 156 | Any key not defined in a specific training will be looked up from the `fallback` training. So, if in the `10K` example you were to remove the `target` key, it would be looked up from the `fallback` training and your songs would be copied to the `my_other_device` target. 157 | 158 | #### Play count and favouring unplayed songs 159 | 160 | In the default configuration of the plugin, on the `fallback` training there are two disabled options that you might want to consider enabling: `increment_play_count` and `favour_unplayed`. They are meant to be used together. The `increment_play_count` option, on copying your songs to your device, will increment the `play_count` attribute by one and store it in your library and on your media file. The `favour_unplayed` option will instruct the algorithm that picks the songs from your selection to favour the songs that have lower `play_count`. This feature will make you discover songs in your library that you might have never heard. At the same time it ensures that the proposed songs are always changed even if you keep your selection query and your ordering unchanged. 161 | 162 | ### Flavours 163 | 164 | The flavours section serves the purpose of defining named queries. If you have 5 different high intensity trainings different in length but sharing queries about bpm, mood and loudness, you can create a single definition here, called flavour, and reuse that flavour in your different trainings with the `use_flavours` key. 165 | 166 | **Note**: Because flavours are only used to group query elements, the `query` key should not be used here (like it is in trainings). 167 | 168 | ```yaml 169 | goingrunning: 170 | flavours: 171 | overthetop: 172 | bpm: 170.. 173 | mood_aggressive: 0.8.. 174 | average_loudness: 50.. 175 | rocker: 176 | genre: Rock 177 | metallic: 178 | genre: Metal 179 | sunshine: 180 | genre: Reggae 181 | 60s: 182 | year: 1960..1969 183 | chillout: 184 | bpm: 1..120 185 | mood_happy: 0.5..1 186 | ``` 187 | 188 | This way, from the above flavours you might add `use_flavours: [overthetop, rock, 60s]` to one training and `use_flavours: [overthetop, metallic]` to another so they will share the same `overthetop` intensity definition whilst having different genre preferences. Similarly, your recovery session might use `use_flavours: [chillout, sunshine]`. 189 | 190 | ### Advanced queries 191 | 192 | When it comes to handling queries, this plugin introduces some major differences with respect to the beets core you need to be aware of. 193 | 194 | #### Recurring fields extend the selections 195 | 196 | You might define different flavours in which some of the same fields are defined, like the `genre` field in the `rocker` and the `metallic` flavours above. You can define a training that makes use of those flavours and optionally adding the same field through a direct query section, like this: 197 | 198 | ```yaml 199 | goingrunning: 200 | trainings: 201 | HM: 202 | query: 203 | genre: Folk 204 | use_flavours: [rocker, metallic] 205 | ``` 206 | 207 | The resulting query will include songs corresponding to any of the three indicated genres: `genre='Folk' OR genre='Rock' OR genre='Metal'`. This, of course, is applicable to all fields. 208 | 209 | #### Fields can be used as lists 210 | 211 | Sometimes it is cumbersome to define a separate flavour for each additional value of a specific field. For example, it would be nice to have the above `chillout` flavour to include a list of genres instead of having to combine it with multiple flavours. Well, you can just do that by using the list notation like this: 212 | 213 | ```yaml 214 | goingrunning: 215 | flavours: 216 | chillout: 217 | bpm: 1..120 218 | mood_happy: 0.5..1 219 | genre: [Soul, Oldies, Ballad] 220 | ``` 221 | 222 | or like this: 223 | 224 | ```yaml 225 | goingrunning: 226 | flavours: 227 | chillout: 228 | bpm: 1..120 229 | mood_happy: 0.5..1 230 | genre: 231 | - Soul 232 | - Oldies 233 | - Ballad 234 | ``` 235 | 236 | The resulting query will have the same effect including all indicated genres: `genre='Soul' OR genre='Oldies' OR genre='Ballad'`. This technique can be applied to all fields. 237 | 238 | #### Negated fields can also be used as lists 239 | 240 | What is described above also applies to negated fields. That is to say, you can also negate a field and use it as a list to query your library by excluding all those values: 241 | 242 | ```yaml 243 | goingrunning: 244 | flavours: 245 | not_good_for_running: 246 | ^genre: [Jazz, Psychedelic Rock, Gospel] 247 | ``` 248 | 249 | When the above flavour is compiled it will result in a query excluding all indicated genres: `genre!='Jazz' AND genre!='Psychedelic Rock' AND genre!='Gospel'`. This technique can be applied to all fields. 250 | 251 | ### Using a separate configuration file 252 | 253 | In my experience the configuration section can grow quite long depending on your needs, so I find it useful to keep my `goingrunning` specific configuration in a separate file and from the main configuration file include it like this: 254 | 255 | ```yaml 256 | include: 257 | - plg_goingrunning.yaml 258 | ``` 259 | 260 | ## Examples 261 | 262 | Show all the configured trainings: 263 | 264 | $ beet goingrunning --list 265 | 266 | Check what would be done for the `10K` training: 267 | 268 | $ beet goingrunning 10K --dry-run 269 | 270 | Let's go! Copy your songs to your device based on the `10K` training and using the plugin shorthand: 271 | 272 | $ beet run 10K 273 | 274 | Do the same as above but today you feel Ska: 275 | 276 | $ beet run 10K genre:ska 277 | 278 | ## Issues 279 | 280 | - If something is not working as expected please use the Issue tracker. 281 | - If the documentation is not clear please use the Issue tracker. 282 | - If you have a feature request please use the Issue tracker. 283 | - In any other situation please use the Issue tracker. 284 | 285 | ## Roadmap 286 | 287 | Please check the [ROADMAP](./docs/ROADMAP.md) file. If there is a feature you would like to see but which is not planned, create a feature request in the Issue tracker. 288 | 289 | ## Other plugins by the same author 290 | 291 | - [beets-goingrunning](https://github.com/adamjakab/BeetsPluginGoingRunning) 292 | - [beets-xtractor](https://github.com/adamjakab/BeetsPluginXtractor) 293 | - [beets-yearfixer](https://github.com/adamjakab/BeetsPluginYearFixer) 294 | - [beets-autofix](https://github.com/adamjakab/BeetsPluginAutofix) 295 | - [beets-describe](https://github.com/adamjakab/BeetsPluginDescribe) 296 | - [beets-bpmanalyser](https://github.com/adamjakab/BeetsPluginBpmAnalyser) 297 | - [beets-template](https://github.com/adamjakab/BeetsPluginTemplate) 298 | 299 | ## Final Remarks 300 | 301 | Enjoy! 302 | -------------------------------------------------------------------------------- /beetsplug/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | 5 | from pkgutil import extend_path 6 | __path__ = extend_path(__path__, __name__) 7 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | 5 | import os 6 | 7 | import mediafile 8 | from beets.dbcore import types 9 | from beets.plugins import BeetsPlugin 10 | from confuse import ConfigSource, load_yaml 11 | from beetsplug.goingrunning.command import GoingRunningCommand 12 | 13 | 14 | class GoingRunningPlugin(BeetsPlugin): 15 | _default_plugin_config_file_name_ = 'config_default.yml' 16 | 17 | def __init__(self): 18 | super(GoingRunningPlugin, self).__init__() 19 | 20 | # Read default configuration 21 | config_file_path = os.path.join(os.path.dirname(__file__), 22 | self._default_plugin_config_file_name_) 23 | source = ConfigSource(load_yaml(config_file_path) or {}, 24 | config_file_path) 25 | self.config.add(source) 26 | 27 | # Add `play_count` field support 28 | fld_name = u'play_count' 29 | if fld_name not in mediafile.MediaFile.fields(): 30 | field = mediafile.MediaField( 31 | mediafile.MP3DescStorageStyle(fld_name), 32 | mediafile.StorageStyle(fld_name), 33 | out_type=int 34 | ) 35 | self.add_media_field(fld_name, field) 36 | 37 | def commands(self): 38 | return [GoingRunningCommand(self.config)] 39 | 40 | @property 41 | def item_types(self): 42 | return {'play_count': types.INTEGER} 43 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/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'1.2.10' 10 | __status__ = u'Stable' 11 | 12 | __PACKAGE_TITLE__ = u'GoingRunning' 13 | __PACKAGE_NAME__ = u'beets-goingrunning' 14 | __PACKAGE_DESCRIPTION__ = u'A beets plugin for creating and exporting songs ' \ 15 | u'that match your running session.', 16 | __PACKAGE_URL__ = u'https://github.com/adamjakab/BeetsPluginGoingRunning' 17 | __PLUGIN_NAME__ = u'goingrunning' 18 | __PLUGIN_ALIAS__ = u'run' 19 | __PLUGIN_SHORT_DESCRIPTION__ = u'run with the music that matches your ' \ 20 | u'training sessions' 21 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/command.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | 5 | from optparse import OptionParser 6 | 7 | from beets import library 8 | from beets import logging 9 | from beets.dbcore import query 10 | from beets.dbcore.db import Results 11 | from beets.dbcore.queryparse import parse_query_part, construct_query_part 12 | from beets.library import Library, Item 13 | from beets.ui import Subcommand, decargs 14 | from confuse import Subview 15 | from beetsplug.goingrunning import common 16 | from beetsplug.goingrunning import itemexport 17 | from beetsplug.goingrunning import itemorder 18 | from beetsplug.goingrunning import itempick 19 | 20 | 21 | class GoingRunningCommand(Subcommand): 22 | config: Subview = None 23 | lib: Library = None 24 | query = [] 25 | parser: OptionParser = None 26 | 27 | verbose_log = False 28 | 29 | cfg_quiet = False 30 | cfg_count = False 31 | cfg_dry_run = False 32 | 33 | def __init__(self, cfg): 34 | self.config = cfg 35 | 36 | self.parser = OptionParser( 37 | usage='beet {plg} [training] [options] [QUERY...]'.format( 38 | plg=common.plg_ns['__PLUGIN_NAME__'] 39 | ) 40 | ) 41 | 42 | self.parser.add_option( 43 | '-l', '--list', 44 | action='store_true', dest='list', default=False, 45 | help=u'list the preconfigured training you have' 46 | ) 47 | 48 | self.parser.add_option( 49 | '-c', '--count', 50 | action='store_true', dest='count', default=False, 51 | help=u'count the number of songs available for a specific training' 52 | ) 53 | 54 | self.parser.add_option( 55 | '-d', '--dry-run', 56 | action='store_true', dest='dry_run', default=False, 57 | help=u'Do not delete/copy any songs. Just show what would be done' 58 | ) 59 | 60 | self.parser.add_option( 61 | '-q', '--cfg_quiet', 62 | action='store_true', dest='quiet', default=False, 63 | help=u'keep cfg_quiet' 64 | ) 65 | 66 | self.parser.add_option( 67 | '-v', '--version', 68 | action='store_true', dest='version', default=False, 69 | help=u'show plugin version' 70 | ) 71 | 72 | # Keep this at the end 73 | super(GoingRunningCommand, self).__init__( 74 | parser=self.parser, 75 | name=common.plg_ns['__PLUGIN_NAME__'], 76 | aliases=[common.plg_ns['__PLUGIN_ALIAS__']] \ 77 | if common.plg_ns['__PLUGIN_ALIAS__'] else [], 78 | help=common.plg_ns['__PLUGIN_SHORT_DESCRIPTION__'] 79 | ) 80 | 81 | def func(self, lib: Library, options, arguments): 82 | self.cfg_quiet = options.quiet 83 | self.cfg_count = options.count 84 | self.cfg_dry_run = options.dry_run 85 | 86 | self.lib = lib 87 | self.query = decargs(arguments) 88 | 89 | # Determine if -v option was set for more verbose logging 90 | logger = logging.getLogger('beets') 91 | self.verbose_log = True if logger.level == logging.DEBUG else False 92 | 93 | # TEMPORARY: Verify configuration upgrade! 94 | # There is a major backward incompatible upgrade in version 1.1.1 95 | try: 96 | self.verify_configuration_upgrade() 97 | except RuntimeError as e: 98 | self._say("*" * 80) 99 | self._say( 100 | "******************** INCOMPATIBLE PLUGIN CONFIGURATION " 101 | "*********************") 102 | self._say("*" * 80) 103 | self._say( 104 | "* Your configuration has been created for an older version " 105 | "of the plugin.") 106 | self._say( 107 | "* Since version 1.1.1 the plugin has implemented changes " 108 | "that require your " 109 | "current configuration to be updated.") 110 | self._say( 111 | "* Please read the updated documentation here and update your " 112 | "configuration.") 113 | self._say( 114 | "* Documentation: " 115 | "https://github.com/adamjakab/BeetsPluginGoingRunning/blob" 116 | "/master/README.md" 117 | "#configuration") 118 | self._say("* I promise it will not happen again ;)") 119 | self._say("* " + str(e)) 120 | self._say("* The plugin will exit now.") 121 | common.say("*" * 80) 122 | return 123 | 124 | # You must either pass a training name or request listing 125 | if len(self.query) < 1 and not (options.list or options.version): 126 | self.parser.print_help() 127 | return 128 | 129 | if options.version: 130 | self.show_version_information() 131 | return 132 | elif options.list: 133 | self.list_trainings() 134 | return 135 | 136 | self.handle_training() 137 | 138 | def handle_training(self): 139 | training_name = self.query.pop(0) 140 | training: Subview = self.config["trainings"][training_name] 141 | 142 | self._say("Handling training: {0}".format(training_name), 143 | log_only=False) 144 | 145 | # todo: create a sanity checker for training to check all attributes 146 | if not training.exists(): 147 | self._say( 148 | "There is no training with this name[{0}]!".format( 149 | training_name), log_only=False) 150 | return 151 | 152 | # Verify target device path path 153 | if not common.get_destination_path_for_training(training): 154 | self._say( 155 | "Invalid target!", log_only=False) 156 | return 157 | 158 | # Get the library items 159 | lib_items: Results = self._retrieve_library_items(training) 160 | 161 | # Show count only 162 | if self.cfg_count: 163 | self._say("Number of songs available: {}".format(len(lib_items)), 164 | log_only=False) 165 | return 166 | 167 | # Check count 168 | if len(lib_items) < 1: 169 | self._say( 170 | "No songs in your library match this training!", log_only=False) 171 | return 172 | 173 | duration = common.get_training_attribute(training, "duration") 174 | if not duration: 175 | self._say("There is no duration set for the selected training!", 176 | log_only=False) 177 | return 178 | 179 | # 1) order items by `ordering_strategy` 180 | sorted_items = itemorder.get_ordered_items(training, lib_items) 181 | # flds = ["ordering_score", "artist", "title"] 182 | # self.display_library_items(sorted_items, flds, prefix="SORTED: ") 183 | 184 | # 2) select items that cover the training duration 185 | itempick.favour_unplayed = \ 186 | common.get_training_attribute(training, "favour_unplayed") 187 | sel_items = itempick.get_items_for_duration(training, sorted_items, 188 | duration * 60) 189 | 190 | # 3) Show some info 191 | total_time = common.get_duration_of_items(sel_items) 192 | self._say("Available songs: {}".format(len(lib_items))) 193 | self._say("Selected songs: {}".format(len(sel_items))) 194 | self._say("Planned training duration: {0}".format( 195 | common.get_human_readable_time(duration * 60))) 196 | self._say("Total song duration: {}".format( 197 | common.get_human_readable_time(total_time))) 198 | 199 | # 4) Show the selected songs 200 | flds = self._get_training_query_element_keys(training) 201 | flds += ["play_count", "artist", "title"] 202 | self.display_library_items(sel_items, flds, prefix="Selected: ") 203 | 204 | # 5) Clean, Copy, Playlist, Run 205 | itemexport.generate_output(training, sel_items, self.cfg_dry_run) 206 | self._say("Run!", log_only=False) 207 | 208 | def _get_training_query_element_keys(self, training): 209 | # todo: move to common 210 | answer = [] 211 | query_elements = self._gather_query_elements(training) 212 | for el in query_elements: 213 | key = parse_query_part(el)[0] 214 | if key not in answer: 215 | answer.append(key) 216 | 217 | return answer 218 | 219 | def _gather_query_elements(self, training: Subview): 220 | """Sum all query elements into one big list ordered from strongest to 221 | weakest: command -> training -> flavours 222 | """ 223 | command_query = self.query 224 | training_query = [] 225 | flavour_query = [] 226 | 227 | # Append the query elements from the configuration 228 | tconf = common.get_training_attribute(training, "query") 229 | if tconf: 230 | for key in tconf.keys(): 231 | nqe = common.get_normalized_query_element(key, tconf.get(key)) 232 | if type(nqe) == list: 233 | training_query.extend(nqe) 234 | else: 235 | training_query.append(nqe) 236 | 237 | # Append the query elements from the flavours defined on the training 238 | flavours = common.get_training_attribute(training, "use_flavours") 239 | if flavours: 240 | flavours = [flavours] if type(flavours) == str else flavours 241 | for flavour_name in flavours: 242 | flavour: Subview = self.config["flavours"][flavour_name] 243 | flavour_query += common.get_flavour_elements(flavour) 244 | 245 | self._say("Command query elements: {}".format(command_query), 246 | log_only=True) 247 | self._say("Training query elements: {}".format(training_query), 248 | log_only=True) 249 | self._say("Flavour query elements: {}".format(flavour_query), 250 | log_only=True) 251 | 252 | raw_combined_query = command_query + training_query + flavour_query 253 | 254 | self._say("Combined query elements: {}". 255 | format(raw_combined_query), log_only=True) 256 | 257 | return raw_combined_query 258 | 259 | def parse_query_elements(self, query_elements, model_cls): 260 | registry = {} 261 | 262 | # Iterate through elements and group them in a registry by field name 263 | for query_element in query_elements: 264 | key, term, query_class, negate = parse_query_part(query_element) 265 | if not key: 266 | continue 267 | # treat negated keys separately 268 | _reg_key = "^{}".format(key) if negate else key 269 | if _reg_key not in registry.keys(): 270 | registry[_reg_key] = [] 271 | registry[_reg_key].append({ 272 | "key": key, 273 | "term": term, 274 | "query_class": query_class, 275 | "negate": negate, 276 | "q_string": query_element 277 | }) 278 | 279 | def parse_and_merge_items(k, lst, cls): 280 | parsed_items = [] 281 | is_negated = lst[0]["negate"] 282 | 283 | for item in lst: 284 | prefixes = {} 285 | qp = construct_query_part(cls, prefixes, item["q_string"]) 286 | parsed_items.append(qp) 287 | 288 | if len(parsed_items) == 1: 289 | answer = parsed_items.pop() 290 | else: 291 | if is_negated: 292 | answer = query.AndQuery(parsed_items) 293 | else: 294 | answer = query.OrQuery(parsed_items) 295 | 296 | return answer 297 | 298 | query_parts = [] 299 | for key in registry.keys(): 300 | reg_item_list = registry[key] 301 | parsed_and_merged = parse_and_merge_items( 302 | key, reg_item_list, model_cls) 303 | self._say("{}: {}".format(key, parsed_and_merged)) 304 | query_parts.append(parsed_and_merged) 305 | 306 | if len(query_parts) == 0: 307 | query_parts.append(query.TrueQuery()) 308 | 309 | return query.AndQuery(query_parts) 310 | 311 | def _retrieve_library_items(self, training: Subview): 312 | """Returns the results of the library query for a specific training 313 | The storing/overriding/restoring of the library.Item._types 314 | is made necessary by this issue: 315 | https://github.com/beetbox/beets/issues/3520 316 | Until the issue is solved this 'hack' is necessary. 317 | """ 318 | full_query = self._gather_query_elements(training) 319 | 320 | # Store a copy of defined types and update them with our own overrides 321 | original_types = library.Item._types.copy() 322 | override_types = common.get_item_attribute_type_overrides() 323 | library.Item._types.update(override_types) 324 | 325 | # Execute the query parsing (using our own type overrides) 326 | parsed_query = self.parse_query_elements(full_query, Item) 327 | 328 | # Restore the original types 329 | library.Item._types = original_types.copy() 330 | 331 | self._say("Parsed query: {}".format(parsed_query)) 332 | 333 | return self.lib.items(parsed_query) 334 | 335 | def display_library_items(self, items, fields, prefix=""): 336 | fmt = prefix 337 | for field in fields: 338 | if field in ["artist", "album", "title"]: 339 | fmt += "- {{{0}}} ".format(field) 340 | else: 341 | fmt += "[{0}: {{{0}}}] ".format(field) 342 | 343 | common.say("{}".format("=" * 120), log_only=False) 344 | for item in items: 345 | kwargs = {} 346 | for field in fields: 347 | fld_val = None 348 | 349 | if field == "play_count" and not hasattr(item, field): 350 | item[field] = 0 351 | 352 | if hasattr(item, field): 353 | fld_val = item[field] 354 | 355 | if type(fld_val) in [float]: 356 | fld_val = round(fld_val, 3) 357 | fld_val = "{:7.3f}".format(fld_val) 358 | 359 | if type(fld_val) in [int]: 360 | fld_val = round(fld_val, 1) 361 | fld_val = "{:7.1f}".format(fld_val) 362 | 363 | kwargs[field] = fld_val 364 | try: 365 | self._say(fmt.format(**kwargs), log_only=False) 366 | except IndexError: 367 | pass 368 | common.say("{}".format("=" * 120), log_only=False) 369 | 370 | def list_trainings(self): 371 | trainings = list(self.config["trainings"].keys()) 372 | training_names = [s for s in trainings if s != "fallback"] 373 | 374 | if len(training_names) == 0: 375 | self._say("You have not created any trainings yet.") 376 | return 377 | 378 | self._say("Available trainings:", log_only=False) 379 | for training_name in training_names: 380 | self.list_training_attributes(training_name) 381 | if not self.verbose_log: 382 | self._say("Use `beet -v run -l` to list the training attributes", 383 | log_only=False) 384 | 385 | def list_training_attributes(self, training_name: str): 386 | if not self.config["trainings"].exists() or not \ 387 | self.config["trainings"][training_name].exists(): 388 | self._say("Training[{0}] does not exist.".format(training_name), 389 | is_error=True) 390 | return 391 | 392 | # Just output the list of the names of the available trainings 393 | if not self.verbose_log: 394 | self._say("{0}".format(training_name), log_only=False) 395 | return 396 | 397 | display_name = "[ {} ]".format(training_name) 398 | self._say("\n{0}".format(display_name.center(80, "=")), log_only=False) 399 | 400 | training: Subview = self.config["trainings"][training_name] 401 | training_keys = list( 402 | set(common.MUST_HAVE_TRAINING_KEYS) | set(training.keys())) 403 | final_keys = ["duration", "query", "use_flavours", "combined_query", 404 | "ordering", "target"] 405 | final_keys.extend(tk for tk in training_keys if tk not in final_keys) 406 | 407 | for key in final_keys: 408 | val = common.get_training_attribute(training, key) 409 | 410 | # Handle non-existent (made up) keys 411 | if key == "combined_query" and common.get_training_attribute( 412 | training, "use_flavours"): 413 | val = self._gather_query_elements(training) 414 | 415 | if val is None: 416 | continue 417 | 418 | if key == "duration": 419 | val = common.get_human_readable_time(val * 60) 420 | elif key == "ordering": 421 | val = dict(val) 422 | elif key == "query": 423 | pass 424 | 425 | if isinstance(val, dict): 426 | value = [] 427 | for k in val: 428 | value.append("{key}({val})".format(key=k, val=val[k])) 429 | val = ", ".join(value) 430 | 431 | self._say("{0}: {1}".format(key, val), log_only=False) 432 | 433 | def verify_configuration_upgrade(self): 434 | """Check if user has old(pre v1.1.1) configuration keys in config 435 | """ 436 | trainings = list(self.config["trainings"].keys()) 437 | training_names = [s for s in trainings if s != "fallback"] 438 | for training_name in training_names: 439 | training: Subview = self.config["trainings"][training_name] 440 | tkeys = training.keys() 441 | for tkey in tkeys: 442 | if tkey in ["song_bpm", "song_len"]: 443 | raise RuntimeError( 444 | "Offending key in training({}): {}".format( 445 | training_name, tkey)) 446 | 447 | def show_version_information(self): 448 | self._say("{pt}({pn}) plugin for Beets: v{ver}".format( 449 | pt=common.plg_ns['__PACKAGE_TITLE__'], 450 | pn=common.plg_ns['__PACKAGE_NAME__'], 451 | ver=common.plg_ns['__version__'] 452 | ), log_only=False) 453 | 454 | @staticmethod 455 | def _say(msg, log_only=True, is_error=False): 456 | common.say(msg, log_only, is_error) 457 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/common.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | import importlib 5 | # todo: use beets logger?! 6 | # from beets import logging 7 | import logging 8 | import os 9 | import random 10 | import string 11 | from pathlib import Path 12 | 13 | from beets.dbcore import types 14 | from beets.library import Item 15 | from confuse import Subview 16 | 17 | # Get values as: plg_ns['__PLUGIN_NAME__'] 18 | plg_ns = {} 19 | about_path = os.path.join(os.path.dirname(__file__), u'about.py') 20 | with open(about_path) as about_file: 21 | exec(about_file.read(), plg_ns) 22 | 23 | MUST_HAVE_TRAINING_KEYS = ['duration', 'query', 'target'] 24 | MUST_HAVE_TARGET_KEYS = ['device_root', 'device_path'] 25 | 26 | KNOWN_NUMERIC_FLEX_ATTRIBUTES = [ 27 | "average_loudness", 28 | "chords_changes_rate", 29 | "chords_number_rate", 30 | "danceable", 31 | "key_strength", 32 | "mood_acoustic", 33 | "mood_aggressive", 34 | "mood_electronic", 35 | "mood_happy", 36 | "mood_party", 37 | "mood_relaxed", 38 | "mood_sad", 39 | "rhythm", 40 | "tonal", 41 | ] 42 | 43 | KNOWN_TEXTUAL_FLEX_ATTRIBUTES = [ 44 | "gender", 45 | "genre_rosamerica", 46 | "rhythm", 47 | "voice_instrumental", 48 | "chords_key", 49 | "chords_scale", 50 | ] 51 | 52 | __logger__ = logging.getLogger('beets.{plg}'.format(plg=plg_ns[ 53 | '__PLUGIN_NAME__'])) 54 | 55 | 56 | def say(msg: str, log_only=True, is_error=False): 57 | """ 58 | https://beets.readthedocs.io/en/stable/dev/plugins.html#logging 59 | """ 60 | _level = logging.DEBUG 61 | _level = _level if log_only else logging.INFO 62 | _level = _level if not is_error else logging.ERROR 63 | msg = msg.replace('\'', '"') 64 | __logger__.log(level=_level, msg=msg) 65 | 66 | 67 | def get_item_attribute_type_overrides(): 68 | _types = {} 69 | for attr in KNOWN_NUMERIC_FLEX_ATTRIBUTES: 70 | _types[attr] = types.Float(6) 71 | 72 | return _types 73 | 74 | 75 | def get_human_readable_time(seconds): 76 | """Formats seconds as a short human-readable HH:MM:SS string. 77 | """ 78 | seconds = int(seconds) 79 | m, s = divmod(seconds, 60) 80 | h, m = divmod(m, 60) 81 | return "%d:%02d:%02d" % (h, m, s) 82 | 83 | 84 | def get_normalized_query_element(key, val): 85 | answer = "" 86 | 87 | tpl = "{k}:{v}" 88 | if type(val) in [str, int, float, bool]: 89 | answer = tpl.format(k=key, v=val) 90 | elif type(val) == list: 91 | answer = [] 92 | for v in val: 93 | answer.append(tpl.format(k=key, v=v)) 94 | 95 | return answer 96 | 97 | 98 | def get_flavour_elements(flavour: Subview): 99 | elements = [] 100 | 101 | if not flavour.exists(): 102 | return elements 103 | 104 | for key in flavour.keys(): 105 | # todo: in future flavours can have "use_flavours" key to make this 106 | # recursive 107 | nqe = get_normalized_query_element(key, flavour[key].get()) 108 | if type(nqe) == list: 109 | elements.extend(nqe) 110 | else: 111 | elements.append(nqe) 112 | 113 | return elements 114 | 115 | 116 | def get_training_attribute(training: Subview, attrib: str): 117 | """Returns the attribute value from "goingrunning.trainings" for the 118 | specified training or uses the special fallback training configuration. 119 | """ 120 | value = None 121 | if training[attrib].exists(): 122 | value = training[attrib].get() 123 | elif training.name != "goingrunning.trainings.fallback" and training.parent[ 124 | "fallback"].exists(): 125 | fallback = training.parent["fallback"] 126 | value = get_training_attribute(fallback, attrib) 127 | 128 | return value 129 | 130 | 131 | def get_target_for_training(training: Subview): 132 | answer = None 133 | 134 | target_name = get_training_attribute(training, "target") 135 | say("Finding target: {0}".format(target_name)) 136 | 137 | cfg_targets: Subview = training.parent.parent["targets"] 138 | if not cfg_targets.exists(): 139 | say("Cannot find 'targets' node!") 140 | elif not cfg_targets[target_name].exists(): 141 | say("Target name '{0}' is not defined!".format(target_name)) 142 | else: 143 | answer = cfg_targets[target_name] 144 | 145 | return answer 146 | 147 | 148 | def get_target_attribute_for_training(training: Subview, 149 | attrib: str = "name"): 150 | answer = None 151 | 152 | target_name = get_training_attribute(training, "target") 153 | say("Getting attribute[{0}] for target: {1}".format(attrib, target_name), 154 | log_only=True) 155 | 156 | target = get_target_for_training(training) 157 | if target: 158 | if attrib == "name": 159 | answer = target_name 160 | else: 161 | answer = get_target_attribute(target, attrib) 162 | 163 | say("Found target[{0}] attribute[{1}] path: {2}". 164 | format(target_name, attrib, answer), log_only=True) 165 | 166 | return answer 167 | 168 | 169 | def get_destination_path_for_training(training: Subview): 170 | answer = None 171 | 172 | target_name = get_training_attribute(training, "target") 173 | 174 | if not target_name: 175 | say("Training does not declare a `target`!". 176 | format(target_name), log_only=False) 177 | return answer 178 | 179 | root = get_target_attribute_for_training(training, "device_root") 180 | path = get_target_attribute_for_training(training, "device_path") 181 | path = path or "" 182 | 183 | if not root: 184 | say("The target[{0}] does not declare a device root path.". 185 | format(target_name), log_only=False) 186 | return answer 187 | 188 | root = Path(root).expanduser() 189 | path = Path(str.strip(path, "/")) 190 | dst_path = os.path.realpath(root.joinpath(path)) 191 | 192 | if not os.path.isdir(dst_path): 193 | say("The target[{0}] path does not exist: {1}". 194 | format(target_name, dst_path), log_only=False) 195 | return answer 196 | 197 | say("Found target[{0}] path: {0}". 198 | format(target_name, dst_path), log_only=True) 199 | 200 | return dst_path 201 | 202 | 203 | def get_target_attribute(target: Subview, attrib: str): 204 | """Returns the attribute value from "goingrunning.targets" for the 205 | specified target. 206 | """ 207 | value = None 208 | if target[attrib].exists(): 209 | value = target[attrib].get() 210 | 211 | return value 212 | 213 | 214 | def get_duration_of_items(items): 215 | """ 216 | Calculate the total duration of the media items using the "length" attribute 217 | """ 218 | total_time = 0 219 | 220 | if isinstance(items, list): 221 | for item in items: 222 | try: 223 | length = item.get("length") 224 | if not length or length <= 0: 225 | raise ValueError("Invalid length value!") 226 | total_time += length 227 | except TypeError: 228 | pass 229 | except ValueError: 230 | pass 231 | 232 | return round(total_time) 233 | 234 | 235 | def get_min_max_sum_avg_for_items(items, field_name): 236 | _min = 99999999.9 237 | _max = 0 238 | _sum = 0 239 | _avg = 0 240 | _cnt = 0 241 | for item in items: 242 | try: 243 | field_value = round(float(item.get(field_name, None)), 3) 244 | _cnt += 1 245 | except ValueError: 246 | field_value = None 247 | except TypeError: 248 | field_value = None 249 | 250 | # Min 251 | if field_value is not None and field_value < _min: 252 | _min = field_value 253 | 254 | # Max 255 | if field_value is not None and field_value > _max: 256 | _max = field_value 257 | 258 | # Sum 259 | if field_value is not None: 260 | _sum = _sum + field_value 261 | 262 | # Min (correction) 263 | if _min > _max: 264 | _min = _max 265 | 266 | # Avg 267 | if _cnt > 0: 268 | _avg = round(_sum / _cnt, 3) 269 | 270 | return _min, _max, _sum, _avg 271 | 272 | 273 | def increment_play_count_on_item(item: Item, store=True, write=True): 274 | # clear_dirty is necessary to make sure that `ordering_score` and 275 | # `ordering_info` will not get stored to the library 276 | item.clear_dirty() 277 | item["play_count"] = item.get("play_count", 0) + 1 278 | if store: 279 | item.store() 280 | if write: 281 | item.write() 282 | 283 | def get_class_instance(module_name, class_name): 284 | try: 285 | module = importlib.import_module(module_name) 286 | except ModuleNotFoundError as err: 287 | raise RuntimeError("Module load error! {}".format(err)) 288 | 289 | try: 290 | klass = getattr(module, class_name) 291 | instance = klass() 292 | except BaseException as err: 293 | raise RuntimeError("Instance creation error! {}".format(err)) 294 | 295 | return instance 296 | 297 | 298 | def get_random_string(length=6): 299 | letters = string.ascii_letters + string.digits 300 | return ''.join(random.choice(letters) for i in range(length)) 301 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/config_default.yml: -------------------------------------------------------------------------------- 1 | targets: {} 2 | trainings: 3 | fallback: 4 | increment_play_count: no 5 | favour_unplayed: no 6 | ordering_strategy: score_based_linear 7 | pick_strategy: random_from_bins 8 | flavours: {} 9 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/itemexport.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | import hashlib 5 | import os 6 | import shutil 7 | import tempfile 8 | from datetime import datetime 9 | from pathlib import Path 10 | 11 | from alive_progress import alive_bar 12 | from beets import util 13 | from confuse import Subview 14 | from beetsplug.goingrunning import common 15 | 16 | 17 | def generate_output(training: Subview, items, dry_run=False): 18 | exporter = ItemExport(training, items, dry_run) 19 | exporter.export() 20 | 21 | 22 | class ItemExport: 23 | cfg_dry_run = False 24 | training: Subview = None 25 | items = [] 26 | 27 | def __init__(self, training, items, dry_run=False): 28 | self.training = training 29 | self.items = items 30 | self.cfg_dry_run = dry_run 31 | 32 | def export(self): 33 | self._clean_target() 34 | self._copy_items() 35 | self._generate_playist() 36 | 37 | def _generate_playist(self): 38 | training_name = self._get_cleaned_training_name() 39 | playlist_name = self._get_training_name() 40 | target_name = common.get_training_attribute(self.training, "target") 41 | 42 | if not common.get_target_attribute_for_training(self.training, 43 | "generate_playlist"): 44 | common.say("Playlist generation to target[{0}] was skipped " 45 | "(generate_playlist=no).". 46 | format(target_name), log_only=False) 47 | return 48 | 49 | dst_path = Path(common.get_destination_path_for_training(self.training)) 50 | dst_sub_dir = dst_path.joinpath(training_name) 51 | playlist_filename = "{}.m3u".format(playlist_name) 52 | dst = dst_sub_dir.joinpath(playlist_filename) 53 | 54 | lines = [ 55 | "# Playlist generated for training '{}' on {}". \ 56 | format(training_name, datetime.now()) 57 | ] 58 | 59 | for item in self.items: 60 | path = util.displayable_path(item.get("exportpath", 61 | item.get("path"))) 62 | if path: 63 | path = util.syspath(path) 64 | line = "{path}".format(path=path) 65 | lines.append(line) 66 | 67 | with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as ntf: 68 | tmp_playlist = ntf.name 69 | for line in lines: 70 | ntf.write("{}\n".format(line).encode("utf-8")) 71 | 72 | common.say("Created playlist: {0}".format(dst), log_only=True) 73 | util.copy(tmp_playlist, dst) 74 | util.remove(tmp_playlist) 75 | 76 | def _copy_items(self): 77 | training_name = self._get_cleaned_training_name() 78 | target_name = common.get_training_attribute(self.training, "target") 79 | 80 | # The copy_files is only False when it is explicitly declared so 81 | copy_files = common.get_target_attribute_for_training( 82 | self.training, "copy_files") 83 | copy_files = False if copy_files == False else True 84 | 85 | if not copy_files: 86 | common.say("Copying to target[{0}] was skipped (copy_files=no).". 87 | format(target_name)) 88 | return 89 | 90 | increment_play_count = common.get_training_attribute( 91 | self.training, "increment_play_count") 92 | dst_path = Path(common.get_destination_path_for_training(self.training)) 93 | 94 | dst_sub_dir = dst_path.joinpath(training_name) 95 | if not os.path.isdir(dst_sub_dir): 96 | os.mkdir(dst_sub_dir) 97 | 98 | common.say("Copying to target[{0}]: {1}". 99 | format(target_name, dst_sub_dir)) 100 | 101 | cnt = 0 102 | # todo: disable alive bar when running in verbose mode 103 | # from beets import logging as beetslogging 104 | # beets_log = beetslogging.getLogger("beets") 105 | # print(beets_log.getEffectiveLevel()) 106 | 107 | with alive_bar(len(self.items)) as bar: 108 | for item in self.items: 109 | src = util.displayable_path(item.get("path")) 110 | if not os.path.isfile(src): 111 | # todo: this is bad enough to interrupt! create option 112 | # for this 113 | common.say("File does not exist: {}".format(src)) 114 | continue 115 | 116 | fn, ext = os.path.splitext(src) 117 | gen_filename = "{0}_{1}{2}" \ 118 | .format(str(cnt).zfill(6), common.get_random_string(), ext) 119 | 120 | dst = dst_sub_dir.joinpath(gen_filename) 121 | # dst = "{0}/{1}".format(dst_path, gen_filename) 122 | 123 | common.say("Copying[{1}]: {0}".format(src, gen_filename)) 124 | 125 | if not self.cfg_dry_run: 126 | util.copy(src, dst) 127 | 128 | # store the file_name for the playlist 129 | item["exportpath"] = util.bytestring_path(gen_filename) 130 | 131 | if increment_play_count: 132 | common.increment_play_count_on_item(item) 133 | 134 | cnt += 1 135 | bar() 136 | 137 | def _clean_target(self): 138 | training_name = self._get_cleaned_training_name() 139 | target_name = common.get_training_attribute(self.training, "target") 140 | clean_target = common.get_target_attribute_for_training( 141 | self.training, "clean_target") 142 | 143 | if clean_target is False: 144 | return 145 | 146 | dst_path = Path(common.get_destination_path_for_training(self.training)) 147 | 148 | # Clean entire target 149 | dst_sub_dir = dst_path 150 | 151 | # Only clean specific training folder 152 | if clean_target == "training": 153 | dst_sub_dir = dst_path.joinpath(training_name) 154 | 155 | common.say("Cleaning target[{0}]: {1}". 156 | format(target_name, dst_sub_dir)) 157 | 158 | if os.path.isdir(dst_sub_dir): 159 | shutil.rmtree(dst_sub_dir) 160 | os.mkdir(dst_sub_dir) 161 | 162 | # Clean additional files 163 | additional_files = common.get_target_attribute_for_training( 164 | self.training, 165 | "delete_from_device") 166 | if additional_files and len(additional_files) > 0: 167 | root = common.get_target_attribute_for_training(self.training, 168 | "device_root") 169 | root = Path(root).expanduser() 170 | 171 | common.say("Deleting additional files: {0}". 172 | format(additional_files)) 173 | 174 | for path in additional_files: 175 | path = Path(str.strip(path, "/")) 176 | dst_path = os.path.realpath(root.joinpath(path)) 177 | 178 | if not os.path.isfile(dst_path): 179 | common.say( 180 | "The file to delete does not exist: {0}".format(path), 181 | log_only=True) 182 | continue 183 | 184 | common.say("Deleting: {}".format(dst_path)) 185 | if not self.cfg_dry_run: 186 | os.remove(dst_path) 187 | 188 | def _get_training_name(self): 189 | # This will be the name of the playlist file 190 | training_name = str(self.training.name).split(".").pop() 191 | return training_name 192 | 193 | def _get_cleaned_training_name(self): 194 | # Some MPDs do not work well with folder names longer than 8 chars 195 | training_name = self._get_training_name() 196 | hash8 = hashlib.sha1(training_name.encode("UTF-8")) \ 197 | .hexdigest().upper()[:8] 198 | return hash8 199 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/itemorder.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | import operator 5 | from abc import ABC 6 | from abc import abstractmethod 7 | from random import uniform 8 | 9 | from confuse import Subview 10 | from beetsplug.goingrunning import common 11 | 12 | permutations = { 13 | 'unordered': { 14 | 'module': 'beetsplug.goingrunning.itemorder', 15 | 'class': 'UnorderedPermutation' 16 | }, 17 | 'score_based_linear': { 18 | 'module': 'beetsplug.goingrunning.itemorder', 19 | 'class': 'ScoreBasedLinearPermutation' 20 | } 21 | } 22 | 23 | default_strategy = 'unordered' 24 | 25 | 26 | def get_ordered_items(training: Subview, items): 27 | """Returns the items ordered by the strategy specified in the 28 | `ordering_strategy` key 29 | """ 30 | strategy = common.get_training_attribute(training, "ordering_strategy") 31 | if not strategy or strategy not in permutations: 32 | strategy = default_strategy 33 | 34 | perm = permutations[strategy] 35 | instance: BasePermutation = common.get_class_instance( 36 | perm["module"], perm["class"]) 37 | instance.setup(training, items) 38 | return instance.get_ordered_items() 39 | 40 | 41 | def _get_field_info_value(field_info, strategy="zero"): 42 | answer = field_info["min"] 43 | if strategy == "average": 44 | answer = round(field_info["delta"] / 2, 6) 45 | elif strategy == "random": 46 | answer = round(uniform(field_info["min"], field_info["max"]), 6) 47 | 48 | return answer 49 | 50 | 51 | class BasePermutation(ABC): 52 | training: Subview = None 53 | items = [] 54 | 55 | def __init__(self): 56 | common.say("ORDERING permutation: {0}".format(self.__class__.__name__)) 57 | 58 | def setup(self, training: Subview, items): 59 | self.training = training 60 | self.items = items 61 | 62 | @abstractmethod 63 | def get_ordered_items(self): 64 | raise NotImplementedError("You must implement this method.") 65 | 66 | 67 | class UnorderedPermutation(BasePermutation): 68 | def __init__(self): 69 | super(UnorderedPermutation, self).__init__() 70 | 71 | def get_ordered_items(self): 72 | return self.items 73 | 74 | 75 | class ScoreBasedLinearPermutation(BasePermutation): 76 | no_value_strategy = "zero" # (zero|average|random) 77 | order_info = None 78 | 79 | def __init__(self): 80 | super(ScoreBasedLinearPermutation, self).__init__() 81 | 82 | def setup(self, training: Subview, items): 83 | super().setup(training, items) 84 | self._build_order_info() 85 | self._score_items() 86 | 87 | def get_ordered_items(self): 88 | sorted_items = sorted(self.items, 89 | key=operator.attrgetter('ordering_score')) 90 | 91 | return sorted_items 92 | 93 | def _score_items(self): 94 | 95 | common.say("Scoring {} items...".format(len(self.items))) 96 | 97 | # Score the library items 98 | for item in self.items: 99 | item["ordering_score"] = 0 100 | item["ordering_info"] = {} 101 | for field_name in self.order_info.keys(): 102 | field_info = self.order_info[field_name] 103 | 104 | try: 105 | field_value = round(float(item.get(field_name, None)), 3) 106 | except ValueError: 107 | field_value = None 108 | except TypeError: 109 | field_value = None 110 | 111 | if field_value is None: 112 | field_value = _get_field_info_value(field_info, 113 | self.no_value_strategy) 114 | 115 | distance_from_min = round(field_value - field_info["min"], 6) 116 | 117 | # Linear - field_score should always be between 0 and 100 118 | field_score = round(distance_from_min * field_info["step"], 6) 119 | field_score = field_score if field_score > 0 else 0 120 | field_score = field_score if field_score < 100 else 100 121 | 122 | weighted_field_score = round( 123 | field_info["weight"] * field_score / 100, 6) 124 | 125 | item["ordering_score"] = round( 126 | item["ordering_score"] + weighted_field_score, 6) 127 | 128 | item["ordering_info"][field_name] = { 129 | "distance_from_min": distance_from_min, 130 | "field_score": field_score, 131 | "weighted_field_score": weighted_field_score 132 | } 133 | 134 | # common.say("score:{} - info:{}".format( 135 | # item["ordering_score"], 136 | # item["ordering_info"] 137 | # )) 138 | 139 | def _build_order_info(self): 140 | cfg_ordering = common.get_training_attribute(self.training, "ordering") 141 | fields = [] 142 | if cfg_ordering: 143 | fields = list(cfg_ordering.keys()) 144 | 145 | default_field_data = { 146 | "min": 99999999.9, 147 | "max": 0.0, 148 | "delta": 0.0, 149 | "step": 0.0, 150 | "weight": 100 151 | } 152 | 153 | # Build Order Info dictionary 154 | self.order_info = {} 155 | for field in fields: 156 | field_name = field.strip() 157 | self.order_info[field_name] = default_field_data.copy() 158 | self.order_info[field_name]["weight"] = cfg_ordering[field] 159 | 160 | # Populate Order Info 161 | for field_name in self.order_info.keys(): 162 | field_info = self.order_info[field_name] 163 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 164 | self.items, field_name) 165 | field_info["min"] = _min 166 | field_info["max"] = _max 167 | 168 | # Calculate other values in Order Info 169 | for field_name in self.order_info.keys(): 170 | field_info = self.order_info[field_name] 171 | field_info["delta"] = round(field_info["max"] - field_info[ 172 | "min"], 6) 173 | if field_info["delta"] > 0: 174 | field_info["step"] = round(100 / field_info["delta"], 6) 175 | else: 176 | field_info["step"] = 0 177 | 178 | common.say("ORDER INFO: {0}".format(self.order_info)) 179 | -------------------------------------------------------------------------------- /beetsplug/goingrunning/itempick.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | import math 5 | from abc import ABC 6 | from abc import abstractmethod 7 | from random import randint 8 | 9 | from beets.library import Item 10 | from confuse import Subview 11 | from beetsplug.goingrunning import common 12 | 13 | pickers = { 14 | 'top': { 15 | 'module': 'beetsplug.goingrunning.itempick', 16 | 'class': 'TopPicker' 17 | }, 18 | 'random_from_bins': { 19 | 'module': 'beetsplug.goingrunning.itempick', 20 | 'class': 'RandomFromBinsPicker' 21 | } 22 | } 23 | 24 | default_picker = 'top' 25 | favour_unplayed = False 26 | 27 | 28 | def get_items_for_duration(training: Subview, items, duration): 29 | """Returns the items picked by the Picker strategy specified bu the 30 | `pick_strategy` key 31 | """ 32 | picker = common.get_training_attribute(training, "pick_strategy") 33 | if not picker or picker not in pickers: 34 | picker = default_picker 35 | 36 | picker_info = pickers[picker] 37 | instance: BasePicker = common.get_class_instance( 38 | picker_info["module"], picker_info["class"]) 39 | instance.setup(training, items, duration) 40 | return instance.get_picked_items() 41 | 42 | 43 | class BasePicker(ABC): 44 | training: Subview = None 45 | items = [] 46 | duration = 0 47 | selection = [] 48 | 49 | def __init__(self): 50 | common.say("PICKER strategy: {0} ('favour_unplayed': {1})". 51 | format(self.__class__.__name__, 52 | 'yes' if favour_unplayed else 'no' 53 | )) 54 | 55 | def setup(self, training: Subview, items, duration): 56 | self.training = training 57 | self.items = items 58 | self.duration = duration 59 | self.selection = [] 60 | 61 | @abstractmethod 62 | def _make_selection(self): 63 | raise NotImplementedError("You must implement this method.") 64 | 65 | def get_picked_items(self): 66 | answer = [] 67 | 68 | self._make_selection() 69 | 70 | for sel_data in self.selection: 71 | index = sel_data["index"] 72 | item = self.items[index] 73 | answer.append(item) 74 | 75 | return answer 76 | 77 | # def _show_items_in_bins(self): 78 | # max_bin = len(self.bin_boundaries) 79 | # for bi in range(0, max_bin): 80 | # low, high = self._get_bin_boundaries(bi) 81 | # print("===BIN({}): {} - {}".format(bi, low, high)) 82 | # for ii in range(low, high): 83 | # print("[{}: {}]: {}".format(bi, ii, self.items[ii])) 84 | # 85 | # def _show_selected_items(self): 86 | # for sel_data in self.selection: 87 | # index = sel_data["index"] 88 | # item = self.items[index] 89 | # print(">SEL::: {}: {}".format(sel_data, item)) 90 | 91 | 92 | class TopPicker(BasePicker): 93 | def __init__(self): 94 | super(TopPicker, self).__init__() 95 | 96 | def _make_selection(self): 97 | sel_dur = 0 98 | while sel_dur < self.duration and len(self.items) > 0: 99 | index = len(self.items) - 1 100 | item: Item = self.items[index] 101 | sel_data = { 102 | "index": index, 103 | "length": item.get("length") 104 | } 105 | sel_dur += round(item.get("length")) 106 | self.selection.append(sel_data) 107 | 108 | 109 | class RandomFromBinsPicker(BasePicker): 110 | bin_boundaries = [] 111 | max_allowed_time_difference = 120 112 | 113 | def __init__(self): 114 | super(RandomFromBinsPicker, self).__init__() 115 | 116 | def _make_selection(self): 117 | self._setup_bin_boundaries() 118 | self._make_initial_selection() 119 | self._improve_selection() 120 | 121 | def _improve_selection(self): 122 | # Try to get as close to duration as possible 123 | max_overtime = 10 124 | sel_time = sum(l["length"] for l in self.selection) 125 | curr_sel = 0 126 | curr_run = 0 127 | max_run = len(self.bin_boundaries) * 3 128 | exclusions = {} 129 | 130 | if not self.selection: 131 | common.say("IMPROVEMENTS: SKIPPED (No initial selection)") 132 | return 133 | 134 | # iterate through initial selection items and try to find a better 135 | # alternative for them 136 | while sel_time < self.duration or sel_time > self.duration + \ 137 | max_overtime: 138 | curr_run += 1 139 | if curr_run > max_run: 140 | common.say("MAX HIT!") 141 | break 142 | # common.say("{} IMPROVEMENT RUN: {}/{}". 143 | # format("=" * 60, curr_run, max_run)) 144 | 145 | curr_bin = self.selection[curr_sel]["bin"] 146 | curr_index = self.selection[curr_sel]["index"] 147 | curr_len = self.selection[curr_sel]["length"] 148 | 149 | # if positive we need shorter songs if negative then longer 150 | time_diff = abs(round(sel_time - self.duration)) 151 | min_len = curr_len - time_diff 152 | max_len = curr_len + time_diff 153 | 154 | if curr_bin not in exclusions.keys(): 155 | exclusions[curr_bin] = [curr_index] 156 | exclude = exclusions[curr_bin] 157 | index = self._get_item_within_length(curr_bin, min_len, max_len, 158 | exclude_indices=exclude) 159 | if index is not None: 160 | exclude.append(index) 161 | item: Item = self.items[index] 162 | item_len = item.get("length") 163 | new_diff = abs((sel_time - curr_len + item_len) - self.duration) 164 | 165 | if new_diff < time_diff: 166 | sel_data = { 167 | "index": index, 168 | "bin": curr_bin, 169 | "length": item_len, 170 | "play_count": item.get("play_count", 0) 171 | } 172 | del self.selection[curr_sel] 173 | self.selection.insert(curr_sel, sel_data) 174 | sel_time = round(sum(l["length"] for l in self.selection)) 175 | 176 | common.say("{} IMPROVEMENT RUN: {}/{}". 177 | format("=" * 60, curr_run, max_run)) 178 | common.say("PROPOSAL[bin: {}](index: {}): {} -> {}". 179 | format(curr_bin, index, 180 | round(curr_len), round(item_len))) 181 | common.say("IMPROVED BY: {} sec". 182 | format(round(time_diff - new_diff))) 183 | self.show_selection_status() 184 | 185 | if curr_sel < len(self.selection) - 1: 186 | curr_sel += 1 187 | else: 188 | curr_sel = 0 189 | 190 | common.say("{} IMPROVEMENTS: FINISHED".format("=" * 60)) 191 | self.show_selection_status() 192 | common.say("SELECTION: {}".format(self.selection)) 193 | 194 | def _make_initial_selection(self): 195 | # Create an initial selection 196 | sel_time = 0 197 | curr_bin = 0 198 | max_bin = len(self.bin_boundaries) - 1 199 | curr_run = 0 200 | max_run = 100 201 | while (self.duration - sel_time) > self.max_allowed_time_difference: 202 | curr_run += 1 203 | if curr_run > max_run: 204 | common.say("MAX HIT!") 205 | break 206 | low, high = self._get_bin_boundaries(curr_bin) 207 | 208 | index = self._get_random_item_between_boundaries(low, high) 209 | if index is not None: 210 | item: Item = self.items[index] 211 | item_len = item.get("length") 212 | time_diff = abs(sel_time - self.duration) 213 | new_diff = abs((sel_time + item_len) - self.duration) 214 | 215 | if new_diff < time_diff: 216 | sel_data = { 217 | "index": index, 218 | "bin": curr_bin, 219 | "length": item_len, 220 | "play_count": item.get("play_count", 0) 221 | } 222 | self.selection.append(sel_data) 223 | sel_time += item_len 224 | curr_bin = curr_bin + 1 \ 225 | if curr_bin < max_bin else max_bin 226 | 227 | common.say("{} INITIAL SELECTION: FINISHED".format("=" * 60)) 228 | self.show_selection_status() 229 | 230 | def _get_item_within_length(self, bin_number, 231 | min_len, max_len, exclude_indices=None): 232 | index = None 233 | if exclude_indices is None: 234 | exclude_indices = [] 235 | 236 | low, high = self._get_bin_boundaries(bin_number) 237 | if low is None or high is None: 238 | return index 239 | 240 | candidates = [] 241 | for i in range(low, high): 242 | if i not in exclude_indices and \ 243 | min_len < self.items[i].get("length") < max_len: 244 | candidates.append(i) 245 | 246 | bin_items = self.items[low:high] 247 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 248 | bin_items, "play_count") 249 | _min = int(_min) 250 | _max = int(_max) if _max > _min else _min + 1 251 | 252 | found = False 253 | for pc in range(_min, _max): 254 | attempts = round(len(candidates) / 2) 255 | while attempts > 0: 256 | attempts -= 1 257 | ci = randint(0, len(candidates) - 1) 258 | index = candidates[ci] 259 | if self.items[index].get("play_count", 0) == pc: 260 | found = True 261 | break 262 | if found: 263 | break 264 | 265 | return index 266 | 267 | def _get_bin_boundaries(self, bin_number): 268 | low = None 269 | high = None 270 | try: 271 | low, high = self.bin_boundaries[bin_number] 272 | except IndexError: 273 | pass 274 | 275 | return low, high 276 | 277 | def _setup_bin_boundaries(self): 278 | self.bin_boundaries = [] 279 | 280 | if len(self.items) <= 1: 281 | raise ValueError("There is only one song in the selection!") 282 | 283 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 284 | self.items, "length") 285 | 286 | if not _avg: 287 | raise ValueError("Average song length is zero!") 288 | 289 | num_bins = round(self.duration / _avg) 290 | bin_size = math.floor(len(self.items) / num_bins) 291 | 292 | common.say("Number of bins: {}".format(num_bins)) 293 | common.say("Bin size: {}".format(bin_size)) 294 | 295 | if not bin_size or bin_size * num_bins > len(self.items): 296 | low = 0 297 | high = len(self.items) - 1 298 | self.bin_boundaries.append([low, high]) 299 | else: 300 | for bi in range(0, num_bins): 301 | is_last_bin = bi == (num_bins - 1) 302 | low = bi * bin_size 303 | high = low + bin_size - 1 304 | if is_last_bin: 305 | high = len(self.items) - 1 306 | self.bin_boundaries.append([low, high]) 307 | 308 | common.say("Bin boundaries: {}".format(self.bin_boundaries)) 309 | 310 | def _get_random_item_between_boundaries(self, low, high): 311 | if not favour_unplayed: 312 | return randint(low, high) 313 | 314 | bin_items = self.items[low:high] 315 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 316 | bin_items, "play_count") 317 | _min = int(_min) 318 | _max = int(_max) if _max > _min else _min + 1 319 | 320 | index = None 321 | found = False 322 | for pc in range(_min, _max): 323 | attempts = round(len(bin_items) / 2) 324 | while attempts > 0: 325 | attempts -= 1 326 | index = randint(low, high) 327 | if self.items[index].get("play_count", 0) == pc: 328 | found = True 329 | break 330 | if found: 331 | break 332 | 333 | return index 334 | 335 | def show_selection_status(self): 336 | sel_time = sum(l["length"] for l in self.selection) 337 | time_diff = sel_time - self.duration 338 | common.say("TOTAL(sec):{} SELECTED(sec):{} DIFFERENCE(sec):{}".format( 339 | self.duration, round(sel_time), round(time_diff))) 340 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v1.2.3 4 | 5 | ### New features: 6 | 7 | ### Fixes 8 | 9 | 10 | 11 | ## v1.2.2 12 | 13 | ### New features: 14 | - multiple trainings/playlists on MPD device using 'clean_target: training' 15 | - songs on device are now stored on separate folders for each training 16 | 17 | ### Fixes 18 | - ordering on fallback training is now honoured 19 | - some minor ordering/song picking fixes 20 | 21 | 22 | 23 | ## v1.2.1 24 | 25 | ### New features: 26 | - creating playlists on target 27 | - possibility to disable song copy (playlist only) 28 | - negated lists can also be used 29 | - fields in queries can now be used as lists 30 | - fields in different flavours now expand the selection (instead of substitution) 31 | - maximize unheard song proposal by incrementing play_count on export 32 | 33 | ### Fixes 34 | - multiple logging issues 35 | 36 | 37 | 38 | ## v1.2.0 39 | 40 | ### New features: 41 | - introduced `play_count` handling and `favour_unplayed` based song selection 42 | 43 | ### Fixes 44 | - multiple lines in logging 45 | - trainings without target 46 | 47 | 48 | 49 | ## v1.1.2 50 | 51 | ### New features: 52 | - introduced flavour based song selection 53 | - improved library item fetching and filtering - support for numeric flex attributes (such as mood_happy) 54 | - added special "fallback" training 55 | - added file check for stale library items 56 | - advanced ordering based on multi-item scoring system 57 | 58 | ### Fixes 59 | - temporary fix for incompatibility issue with other plugins declaring the same types (Issue #15) 60 | - now removing .m3u playlist files from device on cleanup 61 | - removed confusing "bubble up" concept from config/code 62 | 63 | 64 | 65 | ## v1.1.1 66 | 67 | **Special note**: Deleted right after its release due to an incompatibility issue with some core plugins. 68 | 69 | 70 | 71 | ## v1.1.0 72 | 73 | ### New features: 74 | - Queries are now compatible with command line queries (and can be overwritten) 75 | - Ordering is now possible on numeric fields 76 | - Cleaning of target device from extra files 77 | - Implemented --dry-run option to show what would be done without execution 78 | 79 | ### Fixes 80 | -------------------------------------------------------------------------------- /docs/ROADMAP.md: -------------------------------------------------------------------------------- 1 | # TODOS (ROADMAP) 2 | 3 | This is an ever-growing list of ideas that will be scheduled for implementation at some point. 4 | 5 | 6 | ## Short term implementations 7 | These should be easily implemented. 8 | 9 | - allow passing `flavour` names on command line 10 | - training info: show full info for a specific training: 11 | - total time 12 | - number of bins 13 | - ... 14 | - targets - target definition should include some extra info - just some ideas: 15 | ```yaml 16 | goingrunning: 17 | targets: 18 | SONY-1: 19 | create_training_folder: yes 20 | ``` 21 | 22 | 23 | ## Long term implementations 24 | These need some proper planning. 25 | 26 | - **possibility to handle multiple sections** inside a training (for interval trainings / strides at different speeds) 27 | - sections can also be repeated 28 | 29 | example of an interval training with 5 minutes fast and 2.5 minutes recovery repeated 5 times: 30 | ```yaml 31 | goingrunning: 32 | trainings: 33 | STRIDES-1K: 34 | use_sections: [fast, recovery] 35 | repeat_sections: 5 36 | sections: 37 | fast: 38 | use_flavours: [energy, above170] 39 | duration: 300 40 | recovery: 41 | use_flavours: [chillout, sunshine] 42 | duration: 150 43 | ``` 44 | 45 | - enable song merging and exporting all songs merged into one single file (optional) 46 | - enable audio TTS files to give instructions during training: "Run for 10K at 4:45. RUN!" exporting it as mp3 files and adding it into the song list. 47 | 48 | 49 | ## Will not implement 50 | These ideas are kept because they might be valuable for some future development but they will not part of the present plugin 51 | 52 | - stats - show statistics about the library - such as number of songs without bpm information -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 2/17/20, 10:25 PM 5 | # License: See LICENSE.txt 6 | # 7 | 8 | import pathlib 9 | from distutils.util import convert_path 10 | 11 | from setuptools import setup 12 | 13 | # The directory containing this file 14 | HERE = pathlib.Path(__file__).parent 15 | 16 | # The text of the README file 17 | README = (HERE / "README.md").read_text() 18 | 19 | plg_ns = {} 20 | about_path = convert_path('beetsplug/goingrunning/about.py') 21 | with open(about_path) as about_file: 22 | exec(about_file.read(), plg_ns) 23 | 24 | # Setup 25 | setup( 26 | name=plg_ns['__PACKAGE_NAME__'], 27 | version=plg_ns['__version__'], 28 | description=plg_ns['__PACKAGE_DESCRIPTION__'], 29 | author=plg_ns['__author__'], 30 | author_email=plg_ns['__email__'], 31 | url=plg_ns['__PACKAGE_URL__'], 32 | license='MIT', 33 | long_description=README, 34 | long_description_content_type='text/markdown', 35 | platforms='ALL', 36 | 37 | include_package_data=True, 38 | test_suite='test', 39 | packages=['beetsplug.goingrunning'], 40 | 41 | python_requires='>=3.8', 42 | 43 | install_requires=[ 44 | 'beets>=1.4.9', 45 | 'alive-progress', 46 | ], 47 | 48 | tests_require=[ 49 | 'pytest', 'nose', 'coverage', 50 | 'mock', 'six', 'pyyaml', 51 | 'requests' 52 | ], 53 | 54 | # Extras needed during testing 55 | extras_require={ 56 | 'tests': ['requests'], 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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjakab/BeetsPluginGoingRunning/e16dfcf28d2ca77347049ec6394d7f68f96cfe1f/test/__init__.py -------------------------------------------------------------------------------- /test/config/default.yml: -------------------------------------------------------------------------------- 1 | # This default configuration file is used to test a hypothetical user configuration scenario 2 | 3 | format_item: "[bpm:$bpm][length:$length][genre:$genre]: $artist - $album - $title ::: $path" 4 | 5 | goingrunning: 6 | targets: 7 | MPD_1: 8 | device_root: /tmp/ 9 | device_path: Music/ 10 | clean_target: yes 11 | delete_from_device: 12 | - xyz.txt 13 | MPD_2: 14 | device_root: /mnt/UsbDrive/ 15 | device_path: Auto/Music/ 16 | clean_target: no 17 | MPD_3: 18 | device_root: /media/this/probably/does/not/exist/ 19 | device_path: Music/ 20 | trainings: 21 | training-1: 22 | alias: "Match any songs" 23 | query: 24 | bpm: 0..999 25 | ordering: 26 | bpm: 100 27 | duration: 10 28 | target: MPD_1 29 | training-2: 30 | alias: "Select songs by flavour only" 31 | use_flavours: [runlikehell, 60s] 32 | ordering: 33 | bpm: 100 34 | duration: 10 35 | target: MPD_1 36 | training-3: 37 | alias: "Select songs by both query and flavour" 38 | query: 39 | bpm: 145..160 40 | use_flavours: [sunshine] 41 | duration: 10 42 | target: MPD_1 43 | q-test-1: 44 | alias: "Without nothing" 45 | q-test-2: 46 | alias: "Query only (different fields)" 47 | query: 48 | bpm: 100..150 49 | length: 120..300 50 | genre: hard rock 51 | q-test-3: 52 | alias: "Query with one additional flavour (different fields)" 53 | query: 54 | bpm: 100..150 55 | length: 120..300 56 | use_flavours: [sunshine] 57 | q-test-4: 58 | alias: "Query with multiple additional flavours (repeated fields)" 59 | query: 60 | bpm: 100..150 61 | year: 2015.. 62 | use_flavours: [sunshine, 60s] 63 | q-test-5: 64 | alias: "Query supports fields a lists" 65 | query: 66 | genre: 67 | - rock 68 | - blues 69 | - ska 70 | use_flavours: [funkymonkey] 71 | q-test-6: 72 | alias: "Query supports negated lists" 73 | query: 74 | genre: [rock, blues] 75 | ^genre: [jazz, death metal] 76 | bad-target-1: 77 | alias: "This training does not define a target" 78 | bad-target-2: 79 | target: inexistent_target 80 | bad-target-3: 81 | target: MPD_3 82 | flavours: 83 | runlikehell: 84 | bpm: 170.. 85 | mood_aggressive: 0.7.. 86 | 60s: 87 | year: 1960..1969 88 | sunshine: 89 | genre: reggae 90 | funkymonkey: 91 | genre: [funk, Rockabilly, Disco] 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /test/config/empty.yml: -------------------------------------------------------------------------------- 1 | # Empty configuration file for testing plugin defaults without any user configurations 2 | 3 | -------------------------------------------------------------------------------- /test/config/obsolete.yml: -------------------------------------------------------------------------------- 1 | # This contains keys which indicate that it was created for a pre-v1.1.1 version 2 | # The plugin will issue a warning and exit when finding those keys 3 | 4 | goingrunning: 5 | trainings: 6 | training-1: 7 | song_bpm: [100, 150] 8 | song_len: [60, 120] 9 | -------------------------------------------------------------------------------- /test/fixtures/song.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjakab/BeetsPluginGoingRunning/e16dfcf28d2ca77347049ec6394d7f68f96cfe1f/test/fixtures/song.mp3 -------------------------------------------------------------------------------- /test/functional/000_basic_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 2/19/20, 12:35 PM 5 | # License: See LICENSE.txt 6 | # 7 | 8 | from test.helper import ( 9 | FunctionalTestHelper, 10 | PLUGIN_NAME, PLUGIN_ALIAS, PLUGIN_SHORT_DESCRIPTION, 11 | get_single_line_from_output 12 | ) 13 | 14 | 15 | class BasicTest(FunctionalTestHelper): 16 | """Test presence and invocation of the plugin. 17 | Only ensures that command does not fail. 18 | """ 19 | 20 | def test_application(self): 21 | self.setup_beets({"config_file": b"empty.yml"}) 22 | stdout = self.run_with_output() 23 | self.assertIn(PLUGIN_NAME, stdout) 24 | self.assertIn(PLUGIN_SHORT_DESCRIPTION, stdout) 25 | 26 | def test_application_version(self): 27 | self.setup_beets({"config_file": b"empty.yml"}) 28 | stdout = self.run_with_output("version") 29 | self.assertIn("plugins: {0}".format(PLUGIN_NAME), stdout) 30 | 31 | def test_plugin_no_arguments(self): 32 | self.setup_beets({"config_file": b"empty.yml"}) 33 | 34 | stdout = self.run_with_output(PLUGIN_NAME) 35 | self.assertIn( 36 | "Usage: beet goingrunning [training] [options] [QUERY...]", stdout) 37 | 38 | def test_plugin_shortname_no_arguments(self): 39 | self.setup_beets({"config_file": b"empty.yml"}) 40 | stdout = self.run_with_output(PLUGIN_ALIAS) 41 | self.assertIn("Usage: beet goingrunning [training] [options] [QUERY...]", stdout) 42 | 43 | def test_with_core_plugin_acousticbrainz(self): 44 | """Flexible field type declaration conflict 45 | Introduced after release 1.1.1 when discovered core bug failing to 46 | compare flexible field types 47 | Ref.: https://beets.readthedocs.io/en/stable/dev/plugins.html 48 | #flexible-field-types 49 | This bug is present in beets version 1.4.9 so until the `item_types` 50 | declaration in the `GoingRunningPlugin` 51 | class is commented out this test will pass. 52 | Issue: https://github.com/adamjakab/BeetsPluginGoingRunning/issues/15 53 | Issue(Beets): https://github.com/beetbox/beets/issues/3520 54 | """ 55 | extra_plugin = "acousticbrainz" 56 | self.setup_beets({ 57 | "config_file": b"empty.yml", 58 | "extra_plugins": [extra_plugin] 59 | }) 60 | 61 | stdout = self.run_with_output("version") 62 | prefix = "plugins:" 63 | line = get_single_line_from_output(stdout, prefix) 64 | expected = "{0} {1}".format(prefix, 65 | ", ".join([extra_plugin, PLUGIN_NAME])) 66 | self.assertEqual(expected, line) 67 | -------------------------------------------------------------------------------- /test/functional/001_configuration_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 2/19/20, 12:35 PM 5 | # License: See LICENSE.txt 6 | # 7 | 8 | from confuse import Subview 9 | 10 | from test.helper import FunctionalTestHelper, PLUGIN_NAME 11 | 12 | 13 | class ConfigurationTest(FunctionalTestHelper): 14 | """Configuration related tests 15 | """ 16 | 17 | def test_plugin_no_config(self): 18 | self.setup_beets({"config_file": b"empty.yml"}) 19 | self.assertTrue(self.config.exists()) 20 | self.assertTrue(self.config[PLUGIN_NAME].exists()) 21 | self.assertIsInstance(self.config[PLUGIN_NAME], Subview) 22 | self.assertTrue(self.config[PLUGIN_NAME]["targets"].exists()) 23 | self.assertTrue(self.config[PLUGIN_NAME]["trainings"].exists()) 24 | self.assertTrue(self.config[PLUGIN_NAME]["flavours"].exists()) 25 | 26 | def test_obsolete_config(self): 27 | self.setup_beets({"config_file": b"obsolete.yml"}) 28 | logged = self.run_with_log_capture(PLUGIN_NAME) 29 | self.assertIn("INCOMPATIBLE PLUGIN CONFIGURATION", logged) 30 | self.assertIn("Offending key in training(training-1): song_bpm", logged) 31 | 32 | def test_default_config_sanity(self): 33 | self.setup_beets({"config_file": b"default.yml"}) 34 | self.assertTrue(self.config[PLUGIN_NAME].exists()) 35 | cfg = self.config[PLUGIN_NAME] 36 | 37 | # Check keys 38 | cfg_keys = cfg.keys() 39 | cfg_keys.sort() 40 | chk_keys = ['targets', 'trainings', 'flavours'] 41 | chk_keys.sort() 42 | self.assertEqual(chk_keys, cfg_keys) 43 | 44 | def test_default_config_targets(self): 45 | self.setup_beets({"config_file": b"default.yml"}) 46 | """ Check Targets""" 47 | cfg: Subview = self.config[PLUGIN_NAME] 48 | targets = cfg["targets"] 49 | self.assertTrue(targets.exists()) 50 | 51 | self.assertIsInstance(targets, Subview) 52 | self.assertEqual(["MPD_1", "MPD_2", "MPD_3"], list(targets.get().keys())) 53 | 54 | # MPD 1 55 | target = targets["MPD_1"] 56 | self.assertIsInstance(target, Subview) 57 | self.assertTrue(target.exists()) 58 | self.assertEqual("/tmp/", target["device_root"].get()) 59 | self.assertEqual("Music/", target["device_path"].get()) 60 | self.assertTrue(target["clean_target"].get()) 61 | self.assertEqual(["xyz.txt"], target["delete_from_device"].get()) 62 | 63 | # MPD 2 64 | target = targets["MPD_2"] 65 | self.assertIsInstance(target, Subview) 66 | self.assertTrue(target.exists()) 67 | self.assertEqual("/mnt/UsbDrive/", target["device_root"].get()) 68 | self.assertEqual("Auto/Music/", target["device_path"].get()) 69 | self.assertFalse(target["clean_target"].get()) 70 | 71 | # MPD 3 72 | target = targets["MPD_3"] 73 | self.assertIsInstance(target, Subview) 74 | self.assertTrue(target.exists()) 75 | self.assertEqual("/media/this/probably/does/not/exist/", target["device_root"].get()) 76 | self.assertEqual("Music/", target["device_path"].get()) 77 | 78 | def test_default_config_trainings(self): 79 | self.setup_beets({"config_file": b"default.yml"}) 80 | """ Check Targets""" 81 | cfg: Subview = self.config[PLUGIN_NAME] 82 | trainings = cfg["trainings"] 83 | self.assertTrue(trainings.exists()) 84 | 85 | def test_default_config_flavours(self): 86 | self.setup_beets({"config_file": b"default.yml"}) 87 | """ Check Targets""" 88 | cfg: Subview = self.config[PLUGIN_NAME] 89 | flavours = cfg["flavours"] 90 | self.assertTrue(flavours.exists()) 91 | -------------------------------------------------------------------------------- /test/functional/002_command_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/17/20, 10:44 PM 5 | # License: See LICENSE.txt 6 | # 7 | 8 | from test.helper import FunctionalTestHelper, PLUGIN_NAME, \ 9 | PACKAGE_TITLE, PACKAGE_NAME, PLUGIN_VERSION, \ 10 | get_value_separated_from_output, convert_time_to_seconds 11 | 12 | 13 | class CommandTest(FunctionalTestHelper): 14 | """Command related tests 15 | """ 16 | 17 | def test_plugin_version(self): 18 | self.setup_beets({"config_file": b"default.yml"}) 19 | versioninfo = "{pt}({pn}) plugin for Beets: v{ver}".format( 20 | pt=PACKAGE_TITLE, 21 | pn=PACKAGE_NAME, 22 | ver=PLUGIN_VERSION 23 | ) 24 | 25 | logged = self.run_with_log_capture(PLUGIN_NAME, "--version") 26 | self.assertIn(versioninfo, logged) 27 | 28 | logged = self.run_with_log_capture(PLUGIN_NAME, "-v") 29 | self.assertIn(versioninfo, logged) 30 | 31 | def test_training_listing_empty(self): 32 | self.setup_beets({"config_file": b"empty.yml"}) 33 | logged = self.run_with_log_capture(PLUGIN_NAME, "--list") 34 | self.assertIn("You have not created any trainings yet.", logged) 35 | 36 | logged = self.run_with_log_capture(PLUGIN_NAME, "-l") 37 | self.assertIn("You have not created any trainings yet.", logged) 38 | 39 | def test_training_listing_default(self): 40 | self.setup_beets({"config_file": b"default.yml"}) 41 | logged = self.run_with_log_capture(PLUGIN_NAME, "--list") 42 | self.assertIn("[ training-1 ]", logged) 43 | self.assertIn("[ training-2 ]", logged) 44 | self.assertIn("[ training-3 ]", logged) 45 | 46 | def test_training_handling_inexistent(self): 47 | self.setup_beets({"config_file": b"default.yml"}) 48 | training_name = "sitting_on_the_sofa" 49 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 50 | self.assertIn( 51 | "There is no training with this name[{}]!".format( 52 | training_name), logged) 53 | 54 | def test_training_song_count(self): 55 | self.setup_beets({"config_file": b"default.yml"}) 56 | training_name = "training-1" 57 | self.ensure_training_target_path(training_name) 58 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name, 59 | "--count") 60 | self.assertIn("Number of songs available: {}".format(0), logged) 61 | 62 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name, "-c") 63 | self.assertIn("Number of songs available: {}".format(0), logged) 64 | 65 | def test_training_no_songs(self): 66 | self.setup_beets({"config_file": b"default.yml"}) 67 | training_name = "training-1" 68 | self.ensure_training_target_path(training_name) 69 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 70 | self.assertIn("Handling training: {0}".format(training_name), logged) 71 | self.assertIn( 72 | "No songs in your library match this training!", 73 | logged) 74 | 75 | def test_training_target_not_set(self): 76 | self.setup_beets({"config_file": b"default.yml"}) 77 | self.add_single_item_to_library() 78 | training_name = "bad-target-1" 79 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 80 | self.assertIn( 81 | "Training does not declare a `target`!", logged) 82 | 83 | def test_training_undefined_target(self): 84 | self.setup_beets({"config_file": b"default.yml"}) 85 | self.add_single_item_to_library() 86 | training_name = "bad-target-2" 87 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 88 | target_name = "inexistent_target" 89 | self.assertIn( 90 | "Target name \"{0}\" is not defined!".format(target_name), logged) 91 | 92 | def test_training_bad_target(self): 93 | self.setup_beets({"config_file": b"default.yml"}) 94 | self.add_single_item_to_library() 95 | training_name = "bad-target-3" 96 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 97 | target_name = "MPD_3" 98 | target_path = "/media/this/probably/does/not/exist/" 99 | self.assertIn( 100 | "The target[{0}] path does not exist: {1}".format(target_name, 101 | target_path), 102 | logged) 103 | 104 | def test_handling_training_1(self): 105 | """Simple query based song selection where everything matches 106 | """ 107 | self.setup_beets({"config_file": b"default.yml"}) 108 | training_name = "training-1" 109 | 110 | self.add_multiple_items_to_library( 111 | count=10, 112 | bpm=[120, 180], 113 | length=[120, 240] 114 | ) 115 | 116 | self.ensure_training_target_path(training_name) 117 | 118 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 119 | 120 | """ Output for "training-1": 121 | 122 | Handling training: training-1 123 | Available songs: 10 124 | Selected songs: 2 125 | Planned training duration: 0:10:00 126 | Total song duration: 0:05:55 127 | Selected: [bpm:137][year:0][length:175.0][artist:the ärtist][ 128 | title:tïtle 7] 129 | Selected: [bpm:139][year:0][length:180.0][artist:the ärtist][ 130 | title:tïtle 6] 131 | Cleaning target[MPD_1]: 132 | /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpa2soyuro 133 | /Music 134 | Deleting additional files: ['xyz.txt'] 135 | Copying to target[MPD_1]: 136 | /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpa2soyuro 137 | /Music 138 | Run! 139 | """ 140 | 141 | prefix = "Handling training:" 142 | self.assertIn(prefix, logged) 143 | value = get_value_separated_from_output(logged, prefix) 144 | self.assertEqual(training_name, value) 145 | 146 | prefix = "Available songs:" 147 | self.assertIn(prefix, logged) 148 | value = int(get_value_separated_from_output(logged, prefix)) 149 | self.assertEqual(10, value) 150 | 151 | prefix = "Selected songs:" 152 | self.assertIn(prefix, logged) 153 | value = int(get_value_separated_from_output(logged, prefix)) 154 | self.assertGreater(value, 0) 155 | self.assertLessEqual(value, 10) 156 | 157 | prefix = "Planned training duration:" 158 | self.assertIn(prefix, logged) 159 | value = get_value_separated_from_output(logged, prefix) 160 | seconds = convert_time_to_seconds(value) 161 | self.assertEqual("0:10:00", value) 162 | self.assertEqual(600, seconds) 163 | 164 | # Do not test for efficiency here 165 | prefix = "Total song duration:" 166 | self.assertIn(prefix, logged) 167 | value = get_value_separated_from_output(logged, prefix) 168 | seconds = convert_time_to_seconds(value) 169 | self.assertGreater(seconds, 0) 170 | 171 | def test_handling_training_2(self): 172 | """Simple flavour based song selection with float value matching 173 | """ 174 | self.setup_beets({"config_file": b"default.yml"}) 175 | training_name = "training-2" 176 | 177 | # Add matching items 178 | self.add_multiple_items_to_library(count=10, 179 | bpm=[170, 200], 180 | mood_aggressive=[0.7, 1], 181 | year=[1960, 1969], 182 | length=[120, 240] 183 | ) 184 | # Add not matching items 185 | self.add_multiple_items_to_library(count=10, 186 | bpm=[120, 150], 187 | mood_aggressive=[0.2, 0.4], 188 | year=[1930, 1950], 189 | length=[120, 240] 190 | ) 191 | 192 | self.ensure_training_target_path(training_name) 193 | 194 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 195 | 196 | """ Output for "training-2": 197 | 198 | Handling training: training-2 199 | Available songs: 10 200 | Selected songs: 3 201 | Planned training duration: 0:10:00 202 | Total song duration: 0:09:03 203 | Selected: [bpm:175][year:1967][length:174.0][artist:the ärtist][ 204 | title:tïtle 6] 205 | Selected: [bpm:181][year:1962][length:206.0][artist:the ärtist][ 206 | title:tïtle 4] 207 | Selected: [bpm:197][year:1968][length:163.0][artist:the ärtist][ 208 | title:tïtle 1] 209 | Cleaning target[MPD_1]: 210 | /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgoh3gyo5 211 | /Music 212 | Deleting additional files: ['xyz.txt'] 213 | Copying to target[MPD_1]: 214 | /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgoh3gyo5 215 | /Music 216 | Run! 217 | 218 | """ 219 | 220 | prefix = "Handling training:" 221 | self.assertIn(prefix, logged) 222 | value = get_value_separated_from_output(logged, prefix) 223 | self.assertEqual(training_name, value) 224 | 225 | prefix = "Available songs:" 226 | self.assertIn(prefix, logged) 227 | value = int(get_value_separated_from_output(logged, prefix)) 228 | self.assertEqual(10, value) 229 | 230 | prefix = "Selected songs:" 231 | self.assertIn(prefix, logged) 232 | value = int(get_value_separated_from_output(logged, prefix)) 233 | self.assertGreater(value, 0) 234 | self.assertLessEqual(value, 10) 235 | 236 | prefix = "Planned training duration:" 237 | self.assertIn(prefix, logged) 238 | value = get_value_separated_from_output(logged, prefix) 239 | seconds = convert_time_to_seconds(value) 240 | self.assertEqual("0:10:00", value) 241 | self.assertEqual(600, seconds) 242 | 243 | # Do not test for efficiency here 244 | prefix = "Total song duration:" 245 | self.assertIn(prefix, logged) 246 | value = get_value_separated_from_output(logged, prefix) 247 | seconds = convert_time_to_seconds(value) 248 | self.assertGreater(seconds, 0) 249 | 250 | def test_handling_training_3(self): 251 | """Simple query + flavour based song selection 252 | """ 253 | self.setup_beets({"config_file": b"default.yml"}) 254 | training_name = "training-3" 255 | 256 | # Add matching items for query + flavour 257 | self.add_multiple_items_to_library(count=10, 258 | bpm=[145, 160], 259 | genre="Reggae", 260 | length=[120, 240] 261 | ) 262 | # Add partially matching items 263 | self.add_multiple_items_to_library(count=10, 264 | bpm=[145, 160], 265 | genre="Rock", 266 | length=[120, 240] 267 | ) 268 | 269 | self.add_multiple_items_to_library(count=10, 270 | bpm=[100, 140], 271 | genre="Reggae", 272 | length=[120, 240] 273 | ) 274 | 275 | self.ensure_training_target_path(training_name) 276 | 277 | logged = self.run_with_log_capture(PLUGIN_NAME, training_name) 278 | 279 | """ Output for "training-2": 280 | 281 | Handling training: training-3 282 | Available songs: 10 283 | Selected songs: 3 284 | Planned training duration: 0:10:00 285 | Total song duration: 0:07:26 286 | Selected: [bpm:158][year:0][length:141.0][artist:the ärtist][ 287 | title:tïtle 3] 288 | Selected: [bpm:154][year:0][length:158.0][artist:the ärtist][ 289 | title:tïtle 5] 290 | Selected: [bpm:152][year:0][length:147.0][artist:the ärtist][ 291 | title:tïtle 10] 292 | Cleaning target[MPD_1]: 293 | /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgnxlpeih 294 | /Music 295 | Deleting additional files: ['xyz.txt'] 296 | Copying to target[MPD_1]: 297 | /private/var/folders/yv/9ntm56m10ql9wf_1zkbw74hr0000gp/T/tmpgnxlpeih 298 | /Music 299 | Run! 300 | 301 | """ 302 | 303 | prefix = "Handling training:" 304 | self.assertIn(prefix, logged) 305 | value = get_value_separated_from_output(logged, prefix) 306 | self.assertEqual(training_name, value) 307 | 308 | prefix = "Available songs:" 309 | self.assertIn(prefix, logged) 310 | value = int(get_value_separated_from_output(logged, prefix)) 311 | self.assertEqual(10, value) 312 | 313 | prefix = "Selected songs:" 314 | self.assertIn(prefix, logged) 315 | value = int(get_value_separated_from_output(logged, prefix)) 316 | self.assertGreater(value, 0) 317 | self.assertLessEqual(value, 10) 318 | 319 | prefix = "Planned training duration:" 320 | self.assertIn(prefix, logged) 321 | value = get_value_separated_from_output(logged, prefix) 322 | seconds = convert_time_to_seconds(value) 323 | self.assertEqual("0:10:00", value) 324 | self.assertEqual(600, seconds) 325 | 326 | # Do not test for efficiency here 327 | prefix = "Total song duration:" 328 | self.assertIn(prefix, logged) 329 | value = get_value_separated_from_output(logged, prefix) 330 | seconds = convert_time_to_seconds(value) 331 | self.assertGreater(seconds, 0) 332 | -------------------------------------------------------------------------------- /test/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjakab/BeetsPluginGoingRunning/e16dfcf28d2ca77347049ec6394d7f68f96cfe1f/test/functional/__init__.py -------------------------------------------------------------------------------- /test/helper.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | # References: https://docs.python.org/3/library/unittest.html 5 | # 6 | 7 | import os 8 | import shutil 9 | import subprocess 10 | import sys 11 | import tempfile 12 | from contextlib import contextmanager 13 | from random import randint, uniform 14 | from unittest import TestCase 15 | 16 | import beets 17 | import six 18 | import yaml 19 | from beets import logging, library 20 | from beets import plugins 21 | from beets import util 22 | from beets.dbcore import types 23 | from beets.library import Item 24 | from beets.util import ( 25 | syspath, 26 | bytestring_path, 27 | displayable_path, 28 | ) 29 | from confuse import Subview, Dumper, LazyConfig, ConfigSource 30 | from beetsplug import goingrunning 31 | from beetsplug.goingrunning import common 32 | from six import StringIO 33 | 34 | # Values from about.py 35 | PACKAGE_TITLE = common.plg_ns['__PACKAGE_TITLE__'] 36 | PACKAGE_NAME = common.plg_ns['__PACKAGE_NAME__'] 37 | PLUGIN_NAME = common.plg_ns['__PLUGIN_NAME__'] 38 | PLUGIN_ALIAS = common.plg_ns['__PLUGIN_ALIAS__'] 39 | PLUGIN_SHORT_DESCRIPTION = common.plg_ns['__PLUGIN_SHORT_DESCRIPTION__'] 40 | PLUGIN_VERSION = common.plg_ns['__version__'] 41 | 42 | _default_logger_name_ = 'beets.{plg}'.format(plg=PLUGIN_NAME) 43 | logging.getLogger(_default_logger_name_).propagate = False 44 | 45 | 46 | class LogCapture(logging.Handler): 47 | 48 | def __init__(self): 49 | super(LogCapture, self).__init__() 50 | self.messages = [] 51 | 52 | def emit(self, record): 53 | self.messages.append(six.text_type(record.msg)) 54 | 55 | 56 | @contextmanager 57 | def capture_log(logger=_default_logger_name_): 58 | """Capture Logger output 59 | >>> with capture_log() as logs: 60 | ... log.info("Message") 61 | 62 | >>> full_log = ""\n"".join(logs) 63 | """ 64 | capture = LogCapture() 65 | log = logging.getLogger(logger) 66 | log.addHandler(capture) 67 | try: 68 | yield capture.messages 69 | finally: 70 | log.removeHandler(capture) 71 | 72 | 73 | @contextmanager 74 | def capture_stdout(): 75 | """Save stdout in a StringIO. 76 | >>> with capture_stdout() as output: 77 | ... print('cseresznye') 78 | ... 79 | >>> output.getvalue() 80 | """ 81 | org = sys.stdout 82 | sys.stdout = capture = StringIO() 83 | if six.PY2: # StringIO encoding attr isn't writable in python >= 3 84 | sys.stdout.encoding = 'utf-8' 85 | try: 86 | yield sys.stdout 87 | finally: 88 | sys.stdout = org 89 | print(capture.getvalue()) 90 | 91 | 92 | @contextmanager 93 | def control_stdin(userinput=None): 94 | """Sends ``input`` to stdin. 95 | >>> with control_stdin('yes'): 96 | ... input() 97 | 'yes' 98 | """ 99 | org = sys.stdin 100 | sys.stdin = StringIO(userinput) 101 | if six.PY2: # StringIO encoding attr isn't writable in python >= 3 102 | sys.stdin.encoding = 'utf-8' 103 | try: 104 | yield sys.stdin 105 | finally: 106 | sys.stdin = org 107 | 108 | 109 | def get_plugin_configuration(cfg): 110 | """Creates and returns a configuration from a dict to play around with""" 111 | config = LazyConfig("unittest") 112 | cfg = {PLUGIN_NAME: cfg} 113 | config.add(ConfigSource(cfg)) 114 | return config[PLUGIN_NAME] 115 | 116 | 117 | def get_single_line_from_output(text: str, prefix: str): 118 | selected_line = "" 119 | lines = text.split("\n") 120 | for line in lines: 121 | if prefix in line: 122 | selected_line = line 123 | break 124 | 125 | return selected_line 126 | 127 | 128 | def convert_time_to_seconds(time: str): 129 | return sum(x * int(t) for x, t in zip([3600, 60, 1], time.split(":"))) 130 | 131 | 132 | def get_value_separated_from_output(fulltext: str, prefix: str): 133 | """Separate a value from the logged output in the format of: 134 | prefix: value 135 | """ 136 | prefix = "{}: {}".format(PLUGIN_NAME, prefix) 137 | value = None 138 | line = get_single_line_from_output(fulltext, prefix) 139 | # print("SL:{}".format(line)) 140 | 141 | if prefix in line: 142 | value = line.replace(prefix, "") 143 | value = value.strip() 144 | 145 | return value 146 | 147 | 148 | def _convert_args(args): 149 | """Convert args to bytestrings for Python 2 and convert them to strings 150 | on Python 3. 151 | """ 152 | for i, elem in enumerate(args): 153 | if six.PY2: 154 | if isinstance(elem, six.text_type): 155 | args[i] = elem.encode(util.arg_encoding()) 156 | else: 157 | if isinstance(elem, bytes): 158 | args[i] = elem.decode(util.arg_encoding()) 159 | 160 | return args 161 | 162 | 163 | def has_program(cmd, args=['--version']): 164 | """Returns `True` if `cmd` can be executed. 165 | """ 166 | full_cmd = _convert_args([cmd] + args) 167 | try: 168 | with open(os.devnull, 'wb') as devnull: 169 | subprocess.check_call(full_cmd, stderr=devnull, 170 | stdout=devnull, stdin=devnull) 171 | except OSError: 172 | return False 173 | except subprocess.CalledProcessError: 174 | return False 175 | else: 176 | return True 177 | 178 | 179 | class Assertions(object): 180 | 181 | def assertIsFile(self: TestCase, path): 182 | self.assertTrue(os.path.isfile(syspath(path)), 183 | msg=u'Path is not a file: {0}'.format( 184 | displayable_path(path))) 185 | 186 | 187 | class BaseTestHelper(TestCase, Assertions): 188 | _test_config_dir_ = os.path.join(bytestring_path( 189 | os.path.dirname(__file__)), b'config') 190 | 191 | _test_fixture_dir = os.path.join(bytestring_path( 192 | os.path.dirname(__file__)), b'fixtures') 193 | 194 | _tempdirs = [] 195 | _tmpdir = None 196 | beetsdir = None 197 | __item_count = 0 198 | 199 | default_item_values = { 200 | 'title': u't\u00eftle {0}', 201 | 'artist': u'the \u00e4rtist', 202 | 'album': u'the \u00e4lbum', 203 | 'track': 0, 204 | 'format': 'MP3', 205 | } 206 | 207 | @classmethod 208 | def setUpClass(cls): 209 | pass 210 | 211 | @classmethod 212 | def tearDownClass(cls): 213 | pass 214 | 215 | def setUp(self): 216 | self._tmpdir = self.create_temp_dir() 217 | self.beetsdir = bytestring_path(self._tmpdir) 218 | os.environ['BEETSDIR'] = self.beetsdir.decode() 219 | pass 220 | 221 | def tearDown(self): 222 | # Clean temporary folders 223 | for tempdir in self._tempdirs: 224 | if os.path.exists(tempdir): 225 | shutil.rmtree(syspath(tempdir), ignore_errors=True) 226 | self._tempdirs = [] 227 | self._tmpdir = None 228 | self.beetsdir = None 229 | 230 | def create_item(self, **values): 231 | """Return an `Item` instance with sensible default values. 232 | 233 | The item receives its attributes from `**values` paratmeter. The 234 | `title`, `artist`, `album`, `track`, `format` and `path` 235 | attributes have defaults if they are not given as parameters. 236 | The `title` attribute is formatted with a running item count to 237 | prevent duplicates. 238 | """ 239 | item_count = self._get_item_count() 240 | _values = self.default_item_values 241 | _values['title'] = _values['title'].format(item_count) 242 | _values['track'] = item_count 243 | _values.update(values) 244 | item = Item(**_values) 245 | 246 | return item 247 | 248 | def _get_item_count(self): 249 | self.__item_count += 1 250 | return self.__item_count 251 | 252 | def create_temp_dir(self): 253 | temp_dir = tempfile.mkdtemp() 254 | self._tempdirs.append(temp_dir) 255 | return temp_dir 256 | 257 | def _copy_files_to_beetsdir(self, file_list: list): 258 | if file_list: 259 | for file in file_list: 260 | if isinstance(file, dict) and \ 261 | 'file_name' in file and 'file_path' in file: 262 | src = file['file_path'] 263 | file_name = file['file_name'] 264 | else: 265 | src = file 266 | file_name = os.path.basename(src) 267 | 268 | if isinstance(src, bytes): 269 | src = src.decode() 270 | 271 | if isinstance(file_name, bytes): 272 | file_name = file_name.decode() 273 | 274 | dst = os.path.join(self.beetsdir.decode(), file_name) 275 | shutil.copyfile(src, dst) 276 | 277 | 278 | class UnitTestHelper(BaseTestHelper): 279 | config = None 280 | 281 | @classmethod 282 | def setUpClass(cls): 283 | super().setUpClass() 284 | beets.ui._configure({"verbose": True}) 285 | 286 | def setUp(self): 287 | super().setUp() 288 | self.__item_count = 0 289 | 290 | # copy configuration file to beets dir 291 | config_file = os.path.join(self._test_config_dir_.decode(), 292 | u'default.yml') 293 | file_list = [{'file_name': 'config.yaml', 'file_path': config_file}] 294 | self._copy_files_to_beetsdir(file_list) 295 | 296 | self.config = LazyConfig('beets', 'unit_tests') 297 | 298 | def tearDown(self): 299 | """Tear down after each test 300 | """ 301 | self.config.clear() 302 | super().tearDown() 303 | 304 | def create_multiple_items(self, count=10, **values): 305 | items = [] 306 | for i in range(count): 307 | new_values = values.copy() 308 | for key in values: 309 | if type(values[key]) == list and len(values[key]) == 2: 310 | if type(values[key][0]) == float or type( 311 | values[key][1]) == float: 312 | random_val = uniform(values[key][0], values[key][1]) 313 | elif type(values[key][0]) == int and type( 314 | values[key][1]) == int: 315 | random_val = randint(values[key][0], values[key][1]) 316 | else: 317 | raise ValueError( 318 | "Elements for key({}) are neither float nor int!") 319 | 320 | new_values[key] = random_val 321 | items.append(self.create_item(**new_values)) 322 | 323 | return items 324 | 325 | 326 | class FunctionalTestHelper(BaseTestHelper): 327 | __item_count = 0 328 | 329 | @classmethod 330 | def setUpClass(cls): 331 | super().setUpClass() 332 | cls._CFG = cls._get_default_CFG() 333 | 334 | @classmethod 335 | def tearDownClass(cls): 336 | super().tearDownClass() 337 | 338 | def setUp(self): 339 | """Setup before each test 340 | """ 341 | super().setUp() 342 | 343 | def tearDown(self): 344 | """Tear down after each test 345 | """ 346 | self._teardown_beets() 347 | self._CFG = self._get_default_CFG() 348 | super().tearDown() 349 | 350 | def setup_beets(self, cfg=None): 351 | if cfg is not None and type(cfg) is dict: 352 | self._CFG.update(cfg) 353 | 354 | plugins._classes = {self._CFG["plugin"]} 355 | if self._CFG["extra_plugins"]: 356 | plugins.load_plugins(self._CFG["extra_plugins"]) 357 | 358 | # copy configuration file to beets dir 359 | config_file = os.path.join(self._test_config_dir_, 360 | self._CFG["config_file"]).decode() 361 | file_list = [{'file_name': 'config.yaml', 'file_path': config_file}] 362 | self._copy_files_to_beetsdir(file_list) 363 | 364 | self.config = beets.config 365 | self.config.clear() 366 | self.config.read() 367 | 368 | self.config['plugins'] = [] 369 | self.config['verbose'] = True 370 | self.config['ui']['color'] = False 371 | self.config['threaded'] = False 372 | self.config['import']['copy'] = False 373 | 374 | self.config['directory'] = self.beetsdir.decode() 375 | self.lib = beets.library.Library(':memory:', self.beetsdir.decode()) 376 | 377 | # This will initialize the plugins 378 | plugins.find_plugins() 379 | 380 | def _teardown_beets(self): 381 | self.unload_plugins() 382 | 383 | # reset types updated here: beets/ui/__init__.py:1148 384 | library.Item._types = {'data_source': types.STRING} 385 | 386 | if hasattr(self, 'lib'): 387 | if hasattr(self.lib, '_connections'): 388 | del self.lib._connections 389 | 390 | if 'BEETSDIR' in os.environ: 391 | del os.environ['BEETSDIR'] 392 | 393 | self.config.clear() 394 | 395 | def ensure_training_target_path(self, training_name): 396 | """Make sure that the path set withing the target for the training 397 | exists by creating it under the temporary folder and changing the 398 | device_root key in the configuration 399 | """ 400 | target_name = self.config[PLUGIN_NAME]["trainings"][training_name][ 401 | "target"].get() 402 | target = self.config[PLUGIN_NAME]["targets"][target_name] 403 | device_root = self.create_temp_dir() 404 | device_path = target["device_path"].get() 405 | target["device_root"].set(device_root) 406 | full_path = os.path.join(device_root, device_path) 407 | os.makedirs(full_path) 408 | 409 | def run_command(self, *args, **kwargs): 410 | """Run a beets command with an arbitrary amount of arguments. The 411 | Library` defaults to `self.lib`, but can be overridden with 412 | the keyword argument `lib`. 413 | """ 414 | sys.argv = ['beet'] # avoid leakage from test suite args 415 | lib = None 416 | if hasattr(self, 'lib'): 417 | lib = self.lib 418 | lib = kwargs.get('lib', lib) 419 | beets.ui._raw_main(_convert_args(list(args)), lib) 420 | 421 | def run_with_output(self, *args): 422 | with capture_stdout() as out: 423 | self.run_command(*args) 424 | return util.text_string(out.getvalue()) 425 | 426 | def run_with_log_capture(self, *args): 427 | with capture_log() as out: 428 | self.run_command(*args) 429 | return util.text_string("\n".join(out)) 430 | 431 | def _get_item_count(self): 432 | """Internal counter for create_item 433 | """ 434 | self.__item_count += 1 435 | return self.__item_count 436 | 437 | def create_item(self, **values): 438 | """... The item is attached to the database from `self.lib`. 439 | """ 440 | values['db'] = self.lib 441 | item = super().create_item(**values) 442 | # print("Creating Item: {}".format(values_)) 443 | 444 | if 'path' not in values: 445 | item['path'] = 'test/fixtures/song.' + item['format'].lower() 446 | 447 | # mtime needs to be set last since other assignments reset it. 448 | item.mtime = 12345 449 | 450 | return item 451 | 452 | def add_single_item_to_library(self, **values): 453 | item = self.create_item(**values) 454 | item.add(self.lib) 455 | item.store() 456 | 457 | # item.move(MoveOperation.COPY) 458 | return item 459 | 460 | def add_multiple_items_to_library(self, count=10, **values): 461 | for i in range(count): 462 | new_values = values.copy() 463 | for key in values: 464 | if type(values[key]) == list and len(values[key]) == 2: 465 | if type(values[key][0]) == float or type( 466 | values[key][1]) == float: 467 | random_val = uniform(values[key][0], values[key][1]) 468 | elif type(values[key][0]) == int and type( 469 | values[key][1]) == int: 470 | random_val = randint(values[key][0], values[key][1]) 471 | else: 472 | raise ValueError( 473 | "Elements for key({}) are neither float nor int!") 474 | 475 | new_values[key] = random_val 476 | self.add_single_item_to_library(**new_values) 477 | 478 | @staticmethod 479 | def _dump_config(cfg: Subview): 480 | flat = cfg.flatten() 481 | print(yaml.dump(flat, Dumper=Dumper, default_flow_style=None, 482 | indent=2, width=1000)) 483 | 484 | @staticmethod 485 | def unload_plugins(): 486 | for plugin in plugins._classes: 487 | plugin.listeners = None 488 | plugins._classes = set() 489 | plugins._instances = {} 490 | 491 | @staticmethod 492 | def _get_default_CFG(): 493 | return { 494 | 'plugin': goingrunning.GoingRunningPlugin, 495 | 'config_file': b'default.yml', 496 | 'extra_plugins': [], 497 | } 498 | -------------------------------------------------------------------------------- /test/tmp/helper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # This file is part of beets. 3 | # Copyright 2016, Thomas Scholtes. 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | 16 | """This module includes various helpers that provide fixtures, capture 17 | information or mock the environment. 18 | 19 | - The `control_stdin` and `capture_stdout` context managers allow one to 20 | interact with the user interface. 21 | 22 | - `has_program` checks the presence of a command on the system. 23 | 24 | - The `generate_album_info` and `generate_track_info` functions return 25 | fixtures to be used when mocking the autotagger. 26 | 27 | - The `ImportSessionFixture` allows one to run importer code while 28 | controlling the interactions through code. 29 | 30 | - The `TestHelper` class encapsulates various fixtures that can be set up. 31 | """ 32 | 33 | from __future__ import division, absolute_import, print_function 34 | 35 | import os 36 | import os.path 37 | import shutil 38 | import subprocess 39 | import sys 40 | from contextlib import contextmanager 41 | from enum import Enum 42 | from tempfile import mkdtemp, mkstemp 43 | 44 | import beets 45 | import beets.plugins 46 | import six 47 | from beets import config 48 | from beets import importer 49 | from beets import logging 50 | from beets import util 51 | from beets.autotag.hooks import AlbumInfo, TrackInfo 52 | from beets.library import Library, Item, Album 53 | from beets.util import MoveOperation 54 | from mediafile import MediaFile, Image 55 | from six import StringIO 56 | 57 | # TODO Move AutotagMock here 58 | from test import _common 59 | 60 | 61 | class LogCapture(logging.Handler): 62 | 63 | def __init__(self): 64 | logging.Handler.__init__(self) 65 | self.messages = [] 66 | 67 | def emit(self, record): 68 | self.messages.append(six.text_type(record.msg)) 69 | 70 | 71 | @contextmanager 72 | def capture_log(logger='beets'): 73 | capture = LogCapture() 74 | log = logging.getLogger(logger) 75 | log.addHandler(capture) 76 | try: 77 | yield capture.messages 78 | finally: 79 | log.removeHandler(capture) 80 | 81 | 82 | @contextmanager 83 | def control_stdin(input=None): 84 | """Sends ``input`` to stdin. 85 | 86 | >>> with control_stdin('yes'): 87 | ... input() 88 | 'yes' 89 | """ 90 | org = sys.stdin 91 | sys.stdin = StringIO(input) 92 | if six.PY2: # StringIO encoding attr isn't writable in python >= 3 93 | sys.stdin.encoding = 'utf-8' 94 | try: 95 | yield sys.stdin 96 | finally: 97 | sys.stdin = org 98 | 99 | 100 | @contextmanager 101 | def capture_stdout(): 102 | """Save stdout in a StringIO. 103 | 104 | >>> with capture_stdout() as output: 105 | ... print('spam') 106 | ... 107 | >>> output.getvalue() 108 | 'spam' 109 | """ 110 | org = sys.stdout 111 | sys.stdout = capture = StringIO() 112 | if six.PY2: # StringIO encoding attr isn't writable in python >= 3 113 | sys.stdout.encoding = 'utf-8' 114 | try: 115 | yield sys.stdout 116 | finally: 117 | sys.stdout = org 118 | print(capture.getvalue()) 119 | 120 | 121 | def _convert_args(args): 122 | """Convert args to bytestrings for Python 2 and convert them to strings 123 | on Python 3. 124 | """ 125 | for i, elem in enumerate(args): 126 | if six.PY2: 127 | if isinstance(elem, six.text_type): 128 | args[i] = elem.encode(util.arg_encoding()) 129 | else: 130 | if isinstance(elem, bytes): 131 | args[i] = elem.decode(util.arg_encoding()) 132 | 133 | return args 134 | 135 | 136 | def has_program(cmd, args=['--version']): 137 | """Returns `True` if `cmd` can be executed. 138 | """ 139 | full_cmd = _convert_args([cmd] + args) 140 | try: 141 | with open(os.devnull, 'wb') as devnull: 142 | subprocess.check_call(full_cmd, stderr=devnull, 143 | stdout=devnull, stdin=devnull) 144 | except OSError: 145 | return False 146 | except subprocess.CalledProcessError: 147 | return False 148 | else: 149 | return True 150 | 151 | 152 | class TestHelper(object): 153 | """Helper mixin for high-level cli and plugin tests. 154 | 155 | This mixin provides methods to isolate beets' global state provide 156 | fixtures. 157 | """ 158 | 159 | # TODO automate teardown through hook registration 160 | 161 | def setup_beets(self, disk=False): 162 | """Setup pristine global configuration and library for testing. 163 | 164 | Sets ``beets.config`` so we can safely use any functionality 165 | that uses the global configuration. All paths used are 166 | contained in a temporary directory 167 | 168 | Sets the following properties on itself. 169 | 170 | - ``temp_dir`` Path to a temporary directory containing all 171 | files specific to beets 172 | 173 | - ``libdir`` Path to a subfolder of ``temp_dir``, containing the 174 | library's media files. Same as ``config['directory']``. 175 | 176 | - ``config`` The global configuration used by beets. 177 | 178 | - ``lib`` Library instance created with the settings from 179 | ``config``. 180 | 181 | Make sure you call ``teardown_beets()`` afterwards. 182 | """ 183 | self.create_temp_dir() 184 | os.environ['BEETSDIR'] = util.py3_path(self.temp_dir) 185 | 186 | self.config = beets.config 187 | self.config.clear() 188 | self.config.read() 189 | 190 | self.config['plugins'] = [] 191 | self.config['verbose'] = 1 192 | self.config['ui']['color'] = False 193 | self.config['threaded'] = False 194 | 195 | self.libdir = os.path.join(self.temp_dir, b'libdir') 196 | os.mkdir(self.libdir) 197 | self.config['directory'] = util.py3_path(self.libdir) 198 | 199 | if disk: 200 | dbpath = util.bytestring_path( 201 | self.config['library'].as_filename() 202 | ) 203 | else: 204 | dbpath = ':memory:' 205 | self.lib = Library(dbpath, self.libdir) 206 | 207 | def teardown_beets(self): 208 | self.lib._close() 209 | if 'BEETSDIR' in os.environ: 210 | del os.environ['BEETSDIR'] 211 | self.remove_temp_dir() 212 | self.config.clear() 213 | beets.config.read(user=False, defaults=True) 214 | 215 | def load_plugins(self, *plugins): 216 | """Load and initialize plugins by names. 217 | 218 | Similar setting a list of plugins in the configuration. Make 219 | sure you call ``unload_plugins()`` afterwards. 220 | """ 221 | # FIXME this should eventually be handled by a plugin manager 222 | beets.config['plugins'] = plugins 223 | beets.plugins.load_plugins(plugins) 224 | beets.plugins.find_plugins() 225 | 226 | # Take a backup of the original _types and _queries to restore 227 | # when unloading. 228 | Item._original_types = dict(Item._types) 229 | Album._original_types = dict(Album._types) 230 | Item._types.update(beets.plugins.types(Item)) 231 | Album._types.update(beets.plugins.types(Album)) 232 | 233 | Item._original_queries = dict(Item._queries) 234 | Album._original_queries = dict(Album._queries) 235 | Item._queries.update(beets.plugins.named_queries(Item)) 236 | Album._queries.update(beets.plugins.named_queries(Album)) 237 | 238 | def unload_plugins(self): 239 | """Unload all plugins and remove the from the configuration. 240 | """ 241 | # FIXME this should eventually be handled by a plugin manager 242 | beets.config['plugins'] = [] 243 | beets.plugins._classes = set() 244 | beets.plugins._instances = {} 245 | Item._types = Item._original_types 246 | Album._types = Album._original_types 247 | Item._queries = Item._original_queries 248 | Album._queries = Album._original_queries 249 | 250 | def create_importer(self, item_count=1, album_count=1): 251 | """Create files to import and return corresponding session. 252 | 253 | Copies the specified number of files to a subdirectory of 254 | `self.temp_dir` and creates a `ImportSessionFixture` for this path. 255 | """ 256 | import_dir = os.path.join(self.temp_dir, b'import') 257 | if not os.path.isdir(import_dir): 258 | os.mkdir(import_dir) 259 | 260 | album_no = 0 261 | while album_count: 262 | album = util.bytestring_path(u'album {0}'.format(album_no)) 263 | album_dir = os.path.join(import_dir, album) 264 | if os.path.exists(album_dir): 265 | album_no += 1 266 | continue 267 | os.mkdir(album_dir) 268 | album_count -= 1 269 | 270 | track_no = 0 271 | album_item_count = item_count 272 | while album_item_count: 273 | title = u'track {0}'.format(track_no) 274 | src = os.path.join(_common.RSRC, b'full.mp3') 275 | title_file = util.bytestring_path('{0}.mp3'.format(title)) 276 | dest = os.path.join(album_dir, title_file) 277 | if os.path.exists(dest): 278 | track_no += 1 279 | continue 280 | album_item_count -= 1 281 | shutil.copy(src, dest) 282 | mediafile = MediaFile(dest) 283 | mediafile.update({ 284 | 'artist': 'artist', 285 | 'albumartist': 'album artist', 286 | 'title': title, 287 | 'album': album, 288 | 'mb_albumid': None, 289 | 'mb_trackid': None, 290 | }) 291 | mediafile.save() 292 | 293 | config['import']['quiet'] = True 294 | config['import']['autotag'] = False 295 | config['import']['resume'] = False 296 | 297 | return ImportSessionFixture(self.lib, loghandler=None, query=None, 298 | paths=[import_dir]) 299 | 300 | # Library fixtures methods 301 | 302 | def create_item(self, **values): 303 | """Return an `Item` instance with sensible default values. 304 | 305 | The item receives its attributes from `**values` paratmeter. The 306 | `title`, `artist`, `album`, `track`, `format` and `path` 307 | attributes have defaults if they are not given as parameters. 308 | The `title` attribute is formated with a running item count to 309 | prevent duplicates. The default for the `path` attribute 310 | respects the `format` value. 311 | 312 | The item is attached to the database from `self.lib`. 313 | """ 314 | item_count = self._get_item_count() 315 | values_ = { 316 | 'title': u't\u00eftle {0}', 317 | 'artist': u'the \u00e4rtist', 318 | 'album': u'the \u00e4lbum', 319 | 'track': item_count, 320 | 'format': 'MP3', 321 | } 322 | values_.update(values) 323 | values_['title'] = values_['title'].format(item_count) 324 | values_['db'] = self.lib 325 | item = Item(**values_) 326 | if 'path' not in values: 327 | item['path'] = 'audio.' + item['format'].lower() 328 | # mtime needs to be set last since other assignments reset it. 329 | item.mtime = 12345 330 | return item 331 | 332 | def add_item(self, **values): 333 | """Add an item to the library and return it. 334 | 335 | Creates the item by passing the parameters to `create_item()`. 336 | 337 | If `path` is not set in `values` it is set to `item.destination()`. 338 | """ 339 | # When specifying a path, store it normalized (as beets does 340 | # ordinarily). 341 | if 'path' in values: 342 | values['path'] = util.normpath(values['path']) 343 | 344 | item = self.create_item(**values) 345 | item.add(self.lib) 346 | 347 | # Ensure every item has a path. 348 | if 'path' not in values: 349 | item['path'] = item.destination() 350 | item.store() 351 | 352 | return item 353 | 354 | def add_item_fixture(self, **values): 355 | """Add an item with an actual audio file to the library. 356 | """ 357 | item = self.create_item(**values) 358 | extension = item['format'].lower() 359 | item['path'] = os.path.join(_common.RSRC, 360 | util.bytestring_path('min.' + extension)) 361 | item.add(self.lib) 362 | item.move(operation=MoveOperation.COPY) 363 | item.store() 364 | return item 365 | 366 | def add_album(self, **values): 367 | item = self.add_item(**values) 368 | return self.lib.add_album([item]) 369 | 370 | def add_item_fixtures(self, ext='mp3', count=1): 371 | """Add a number of items with files to the database. 372 | """ 373 | # TODO base this on `add_item()` 374 | items = [] 375 | path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) 376 | for i in range(count): 377 | item = Item.from_path(path) 378 | item.album = u'\u00e4lbum {0}'.format(i) # Check unicode paths 379 | item.title = u't\u00eftle {0}'.format(i) 380 | # mtime needs to be set last since other assignments reset it. 381 | item.mtime = 12345 382 | item.add(self.lib) 383 | item.move(operation=MoveOperation.COPY) 384 | item.store() 385 | items.append(item) 386 | return items 387 | 388 | def add_album_fixture(self, track_count=1, ext='mp3'): 389 | """Add an album with files to the database. 390 | """ 391 | items = [] 392 | path = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) 393 | for i in range(track_count): 394 | item = Item.from_path(path) 395 | item.album = u'\u00e4lbum' # Check unicode paths 396 | item.title = u't\u00eftle {0}'.format(i) 397 | # mtime needs to be set last since other assignments reset it. 398 | item.mtime = 12345 399 | item.add(self.lib) 400 | item.move(operation=MoveOperation.COPY) 401 | item.store() 402 | items.append(item) 403 | return self.lib.add_album(items) 404 | 405 | def create_mediafile_fixture(self, ext='mp3', images=[]): 406 | """Copies a fixture mediafile with the extension to a temporary 407 | location and returns the path. 408 | 409 | It keeps track of the created locations and will delete the with 410 | `remove_mediafile_fixtures()` 411 | 412 | `images` is a subset of 'png', 'jpg', and 'tiff'. For each 413 | specified extension a cover art image is added to the media 414 | file. 415 | """ 416 | src = os.path.join(_common.RSRC, util.bytestring_path('full.' + ext)) 417 | handle, path = mkstemp() 418 | os.close(handle) 419 | shutil.copyfile(src, path) 420 | 421 | if images: 422 | mediafile = MediaFile(path) 423 | imgs = [] 424 | for img_ext in images: 425 | file = util.bytestring_path('image-2x3.{0}'.format(img_ext)) 426 | img_path = os.path.join(_common.RSRC, file) 427 | with open(img_path, 'rb') as f: 428 | imgs.append(Image(f.read())) 429 | mediafile.images = imgs 430 | mediafile.save() 431 | 432 | if not hasattr(self, '_mediafile_fixtures'): 433 | self._mediafile_fixtures = [] 434 | self._mediafile_fixtures.append(path) 435 | 436 | return path 437 | 438 | def remove_mediafile_fixtures(self): 439 | if hasattr(self, '_mediafile_fixtures'): 440 | for path in self._mediafile_fixtures: 441 | os.remove(path) 442 | 443 | def _get_item_count(self): 444 | if not hasattr(self, '__item_count'): 445 | count = 0 446 | self.__item_count = count + 1 447 | return count 448 | 449 | # Running beets commands 450 | 451 | def run_command(self, *args, **kwargs): 452 | """Run a beets command with an arbitrary amount of arguments. The 453 | Library` defaults to `self.lib`, but can be overridden with 454 | the keyword argument `lib`. 455 | """ 456 | sys.argv = ['beet'] # avoid leakage from test suite args 457 | lib = None 458 | if hasattr(self, 'lib'): 459 | lib = self.lib 460 | lib = kwargs.get('lib', lib) 461 | beets.ui._raw_main(_convert_args(list(args)), lib) 462 | 463 | def run_with_output(self, *args): 464 | with capture_stdout() as out: 465 | self.run_command(*args) 466 | return util.text_string(out.getvalue()) 467 | 468 | # Safe file operations 469 | 470 | def create_temp_dir(self): 471 | """Create a temporary directory and assign it into 472 | `self.temp_dir`. Call `remove_temp_dir` later to delete it. 473 | """ 474 | temp_dir = mkdtemp() 475 | self.temp_dir = util.bytestring_path(temp_dir) 476 | 477 | def remove_temp_dir(self): 478 | """Delete the temporary directory created by `create_temp_dir`. 479 | """ 480 | shutil.rmtree(self.temp_dir) 481 | 482 | def touch(self, path, dir=None, content=''): 483 | """Create a file at `path` with given content. 484 | 485 | If `dir` is given, it is prepended to `path`. After that, if the 486 | path is relative, it is resolved with respect to 487 | `self.temp_dir`. 488 | """ 489 | if dir: 490 | path = os.path.join(dir, path) 491 | 492 | if not os.path.isabs(path): 493 | path = os.path.join(self.temp_dir, path) 494 | 495 | parent = os.path.dirname(path) 496 | if not os.path.isdir(parent): 497 | os.makedirs(util.syspath(parent)) 498 | 499 | with open(util.syspath(path), 'a+') as f: 500 | f.write(content) 501 | return path 502 | 503 | 504 | class ImportSessionFixture(importer.ImportSession): 505 | """ImportSession that can be controlled programaticaly. 506 | 507 | >>> lib = Library(':memory:') 508 | >>> importer = ImportSessionFixture(lib, paths=['/path/to/import']) 509 | >>> importer.add_choice(importer.action.SKIP) 510 | >>> importer.add_choice(importer.action.ASIS) 511 | >>> importer.default_choice = importer.action.APPLY 512 | >>> importer.run() 513 | 514 | This imports ``/path/to/import`` into `lib`. It skips the first 515 | album and imports thesecond one with metadata from the tags. For the 516 | remaining albums, the metadata from the autotagger will be applied. 517 | """ 518 | 519 | def __init__(self, *args, **kwargs): 520 | super(ImportSessionFixture, self).__init__(*args, **kwargs) 521 | self._choices = [] 522 | self._resolutions = [] 523 | 524 | default_choice = importer.action.APPLY 525 | 526 | def add_choice(self, choice): 527 | self._choices.append(choice) 528 | 529 | def clear_choices(self): 530 | self._choices = [] 531 | 532 | def choose_match(self, task): 533 | try: 534 | choice = self._choices.pop(0) 535 | except IndexError: 536 | choice = self.default_choice 537 | 538 | if choice == importer.action.APPLY: 539 | return task.candidates[0] 540 | elif isinstance(choice, int): 541 | return task.candidates[choice - 1] 542 | else: 543 | return choice 544 | 545 | choose_item = choose_match 546 | 547 | Resolution = Enum('Resolution', 'REMOVE SKIP KEEPBOTH MERGE') 548 | 549 | default_resolution = 'REMOVE' 550 | 551 | def add_resolution(self, resolution): 552 | assert isinstance(resolution, self.Resolution) 553 | self._resolutions.append(resolution) 554 | 555 | def resolve_duplicate(self, task, found_duplicates): 556 | try: 557 | res = self._resolutions.pop(0) 558 | except IndexError: 559 | res = self.default_resolution 560 | 561 | if res == self.Resolution.SKIP: 562 | task.set_choice(importer.action.SKIP) 563 | elif res == self.Resolution.REMOVE: 564 | task.should_remove_duplicates = True 565 | elif res == self.Resolution.MERGE: 566 | task.should_merge_duplicates = True 567 | 568 | 569 | def generate_album_info(album_id, track_values): 570 | """Return `AlbumInfo` populated with mock data. 571 | 572 | Sets the album info's `album_id` field is set to the corresponding 573 | argument. For each pair (`id`, `values`) in `track_values` the `TrackInfo` 574 | from `generate_track_info` is added to the album info's `tracks` field. 575 | Most other fields of the album and track info are set to "album 576 | info" and "track info", respectively. 577 | """ 578 | tracks = [generate_track_info(id, values) for id, values in track_values] 579 | album = AlbumInfo( 580 | album_id=u'album info', 581 | album=u'album info', 582 | artist=u'album info', 583 | artist_id=u'album info', 584 | tracks=tracks, 585 | ) 586 | for field in ALBUM_INFO_FIELDS: 587 | setattr(album, field, u'album info') 588 | 589 | return album 590 | 591 | 592 | ALBUM_INFO_FIELDS = ['album', 'album_id', 'artist', 'artist_id', 593 | 'asin', 'albumtype', 'va', 'label', 594 | 'artist_sort', 'releasegroup_id', 'catalognum', 595 | 'language', 'country', 'albumstatus', 'media', 596 | 'albumdisambig', 'releasegroupdisambig', 'artist_credit', 597 | 'data_source', 'data_url'] 598 | 599 | 600 | def generate_track_info(track_id='track info', values={}): 601 | """Return `TrackInfo` populated with mock data. 602 | 603 | The `track_id` field is set to the corresponding argument. All other 604 | string fields are set to "track info". 605 | """ 606 | track = TrackInfo( 607 | title=u'track info', 608 | track_id=track_id, 609 | ) 610 | for field in TRACK_INFO_FIELDS: 611 | setattr(track, field, u'track info') 612 | for field, value in values.items(): 613 | setattr(track, field, value) 614 | return track 615 | 616 | 617 | TRACK_INFO_FIELDS = ['artist', 'artist_id', 'artist_sort', 618 | 'disctitle', 'artist_credit', 'data_source', 619 | 'data_url'] 620 | -------------------------------------------------------------------------------- /test/unit/000_init_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | 5 | from beets.dbcore import types 6 | from beetsplug.goingrunning import GoingRunningPlugin 7 | from beetsplug.goingrunning.command import GoingRunningCommand 8 | 9 | from test.helper import UnitTestHelper, PLUGIN_NAME 10 | 11 | 12 | class PluginTest(UnitTestHelper): 13 | """Test methods in the beetsplug.goingrunning module 14 | """ 15 | 16 | def test_plugin(self): 17 | plg = GoingRunningPlugin() 18 | self.assertEqual(PLUGIN_NAME, plg.name) 19 | 20 | def test_plugin_commands(self): 21 | plg = GoingRunningPlugin() 22 | GRC = plg.commands()[0] 23 | self.assertIsInstance(GRC, GoingRunningCommand) 24 | 25 | def test_plugin_types_definitions(self): 26 | plg = GoingRunningPlugin() 27 | definitions = {'play_count': types.INTEGER} 28 | self.assertDictEqual(definitions, plg.item_types) 29 | -------------------------------------------------------------------------------- /test/unit/001_about_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | 5 | from beetsplug.goingrunning import about 6 | 7 | from test.helper import UnitTestHelper, PACKAGE_TITLE, PACKAGE_NAME, \ 8 | PLUGIN_NAME, PLUGIN_ALIAS, PLUGIN_SHORT_DESCRIPTION, PLUGIN_VERSION 9 | 10 | 11 | class AboutTest(UnitTestHelper): 12 | """Test values defined in the beetsplug.goingrunning.about module 13 | """ 14 | 15 | def test_author(self): 16 | attr = "__author__" 17 | self.assertTrue(hasattr(about, attr)) 18 | self.assertIsNotNone(getattr(about, attr)) 19 | 20 | def test_email(self): 21 | attr = "__email__" 22 | self.assertTrue(hasattr(about, attr)) 23 | self.assertIsNotNone(getattr(about, attr)) 24 | 25 | def test_copyright(self): 26 | attr = "__copyright__" 27 | self.assertTrue(hasattr(about, attr)) 28 | self.assertIsNotNone(getattr(about, attr)) 29 | 30 | def test_license(self): 31 | attr = "__license__" 32 | self.assertTrue(hasattr(about, attr)) 33 | self.assertIsNotNone(getattr(about, attr)) 34 | 35 | def test_version(self): 36 | attr = "__version__" 37 | self.assertTrue(hasattr(about, attr)) 38 | self.assertIsNotNone(getattr(about, attr)) 39 | self.assertEqual(PLUGIN_VERSION, getattr(about, attr)) 40 | 41 | def test_status(self): 42 | attr = "__status__" 43 | self.assertTrue(hasattr(about, attr)) 44 | self.assertIsNotNone(getattr(about, attr)) 45 | 46 | def test_package_title(self): 47 | attr = "__PACKAGE_TITLE__" 48 | self.assertTrue(hasattr(about, attr)) 49 | self.assertIsNotNone(getattr(about, attr)) 50 | self.assertEqual(PACKAGE_TITLE, getattr(about, attr)) 51 | 52 | def test_package_name(self): 53 | attr = "__PACKAGE_NAME__" 54 | self.assertTrue(hasattr(about, attr)) 55 | self.assertIsNotNone(getattr(about, attr)) 56 | self.assertEqual(PACKAGE_NAME, getattr(about, attr)) 57 | 58 | def test_package_description(self): 59 | attr = "__PACKAGE_DESCRIPTION__" 60 | self.assertTrue(hasattr(about, attr)) 61 | self.assertIsNotNone(getattr(about, attr)) 62 | 63 | def test_package_url(self): 64 | attr = "__PACKAGE_URL__" 65 | self.assertTrue(hasattr(about, attr)) 66 | self.assertIsNotNone(getattr(about, attr)) 67 | 68 | def test_plugin_name(self): 69 | attr = "__PLUGIN_NAME__" 70 | self.assertTrue(hasattr(about, attr)) 71 | self.assertIsNotNone(getattr(about, attr)) 72 | self.assertEqual(PLUGIN_NAME, getattr(about, attr)) 73 | 74 | def test_plugin_alias(self): 75 | attr = "__PLUGIN_ALIAS__" 76 | self.assertTrue(hasattr(about, attr)) 77 | self.assertIsNotNone(getattr(about, attr)) 78 | self.assertEqual(PLUGIN_ALIAS, getattr(about, attr)) 79 | 80 | def test_plugin_short_description(self): 81 | attr = "__PLUGIN_SHORT_DESCRIPTION__" 82 | self.assertTrue(hasattr(about, attr)) 83 | self.assertIsNotNone(getattr(about, attr)) 84 | self.assertEqual(PLUGIN_SHORT_DESCRIPTION, getattr(about, attr)) 85 | -------------------------------------------------------------------------------- /test/unit/002_common_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # 3 | # Author: Adam Jakab 4 | # Created: 3/17/20, 3:28 PM 5 | # License: See LICENSE.txt 6 | # 7 | import os 8 | from logging import Logger 9 | 10 | from beets import util 11 | from beets.dbcore import types 12 | from beetsplug.goingrunning import common, GoingRunningPlugin 13 | 14 | from test.helper import UnitTestHelper, get_plugin_configuration, \ 15 | capture_log 16 | 17 | 18 | class CommonTest(UnitTestHelper): 19 | """Test methods in the beetsplug.goingrunning.common module 20 | """ 21 | 22 | def test_module_values(self): 23 | self.assertTrue(hasattr(common, "MUST_HAVE_TRAINING_KEYS")) 24 | self.assertTrue(hasattr(common, "MUST_HAVE_TARGET_KEYS")) 25 | self.assertTrue(hasattr(common, "KNOWN_NUMERIC_FLEX_ATTRIBUTES")) 26 | self.assertTrue(hasattr(common, "KNOWN_TEXTUAL_FLEX_ATTRIBUTES")) 27 | self.assertTrue(hasattr(common, "__logger__")) 28 | self.assertIsInstance(common.__logger__, Logger) 29 | 30 | def test_say(self): 31 | test_message = "one two three" 32 | 33 | with capture_log() as logs: 34 | common.say(test_message) 35 | self.assertIn(test_message, '\n'.join(logs)) 36 | 37 | def test_get_item_attribute_type_overrides(self): 38 | res = common.get_item_attribute_type_overrides() 39 | self.assertListEqual(common.KNOWN_NUMERIC_FLEX_ATTRIBUTES, 40 | list(res.keys())) 41 | 42 | exp_types = [types.Float for n in 43 | range(0, len(common.KNOWN_NUMERIC_FLEX_ATTRIBUTES))] 44 | res_types = [type(v) for v in res.values()] 45 | self.assertListEqual(exp_types, res_types) 46 | 47 | def test_get_human_readable_time(self): 48 | self.assertEqual("0:00:00", common.get_human_readable_time(0), 49 | "Bad time format!") 50 | self.assertEqual("0:00:33", common.get_human_readable_time(33), 51 | "Bad time format!") 52 | self.assertEqual("0:33:33", common.get_human_readable_time(2013), 53 | "Bad time format!") 54 | self.assertEqual("3:33:33", common.get_human_readable_time(12813), 55 | "Bad time format!") 56 | 57 | def test_get_normalized_query_element(self): 58 | # Test simple value pair(string) 59 | key = "genre" 60 | val = "Rock" 61 | expected = "genre:Rock" 62 | qe = common.get_normalized_query_element(key, val) 63 | self.assertEqual(expected, qe) 64 | 65 | # Test simple value pair(int) 66 | key = "length" 67 | val = 360 68 | expected = "length:360" 69 | qe = common.get_normalized_query_element(key, val) 70 | self.assertEqual(expected, qe) 71 | 72 | # Test list of values: ['bpm:100..120', 'bpm:160..180'] 73 | key = "bpm" 74 | val = ["100..120", "160..180"] 75 | expected = ["bpm:100..120", "bpm:160..180"] 76 | qe = common.get_normalized_query_element(key, val) 77 | self.assertListEqual(expected, qe) 78 | 79 | def test_get_flavour_elements(self): 80 | cfg = { 81 | "flavours": { 82 | "speedy": { 83 | "bpm": "180..", 84 | "genre": "Hard Rock", 85 | }, 86 | "complex": { 87 | "bpm": "180..", 88 | "genre": ["Rock", "Jazz", "Pop"], 89 | } 90 | } 91 | } 92 | config = get_plugin_configuration(cfg) 93 | 94 | # non-existent flavour 95 | el = common.get_flavour_elements(config["flavours"]["not_there"]) 96 | self.assertListEqual([], el) 97 | 98 | # simple single values 99 | expected = ["bpm:180..", "genre:Hard Rock"] 100 | el = common.get_flavour_elements(config["flavours"]["speedy"]) 101 | self.assertListEqual(expected, el) 102 | 103 | # list in field 104 | expected = ["bpm:180..", "genre:Rock", "genre:Jazz", "genre:Pop"] 105 | el = common.get_flavour_elements(config["flavours"]["complex"]) 106 | self.assertListEqual(expected, el) 107 | 108 | def test_get_training_attribute(self): 109 | cfg = { 110 | "trainings": { 111 | "fallback": { 112 | "query": { 113 | "bpm": "120..", 114 | }, 115 | "target": "MPD1", 116 | }, 117 | "10K": { 118 | "query": { 119 | "bpm": "180..", 120 | "length": "60..240", 121 | }, 122 | "use_flavours": ["f1", "f2"], 123 | } 124 | } 125 | } 126 | config = get_plugin_configuration(cfg) 127 | training = config["trainings"]["10K"] 128 | 129 | # Direct 130 | self.assertEqual(cfg["trainings"]["10K"]["query"], 131 | common.get_training_attribute(training, "query")) 132 | self.assertEqual(cfg["trainings"]["10K"]["use_flavours"], 133 | common.get_training_attribute(training, 134 | "use_flavours")) 135 | 136 | # Fallback 137 | self.assertEqual(cfg["trainings"]["fallback"]["target"], 138 | common.get_training_attribute(training, "target")) 139 | 140 | # Inexistent 141 | self.assertIsNone(common.get_training_attribute(training, "hoppa")) 142 | 143 | def test_get_target_for_training(self): 144 | cfg = { 145 | "targets": { 146 | "MPD1": { 147 | "device_root": "/mnt/mpd1" 148 | } 149 | }, 150 | "trainings": { 151 | "T1": { 152 | "target": "missing", 153 | }, 154 | "T2": { 155 | "target": "MPD1", 156 | } 157 | } 158 | } 159 | config = get_plugin_configuration(cfg) 160 | 161 | # No "targets" node 162 | no_targets_cfg = cfg.copy() 163 | del no_targets_cfg["targets"] 164 | no_targets_config = get_plugin_configuration(no_targets_cfg) 165 | training = no_targets_config["trainings"]["T1"] 166 | self.assertIsNone(common.get_target_for_training(training)) 167 | 168 | # Undefined target 169 | training = config["trainings"]["T1"] 170 | self.assertIsNone(common.get_target_for_training(training)) 171 | 172 | # Target found 173 | training = config["trainings"]["T2"] 174 | expected = config["targets"]["MPD1"].flatten() 175 | target = common.get_target_for_training(training).flatten() 176 | self.assertDictEqual(expected, target) 177 | 178 | def test_get_target_attribute_for_training(self): 179 | cfg = { 180 | "targets": { 181 | "MPD1": { 182 | "device_root": "/mnt/mpd1" 183 | } 184 | }, 185 | "trainings": { 186 | "T1": { 187 | "target": "missing", 188 | }, 189 | "T2": { 190 | "target": "MPD1", 191 | } 192 | } 193 | } 194 | config = get_plugin_configuration(cfg) 195 | 196 | # Undefined target 197 | training = config["trainings"]["T1"] 198 | self.assertIsNone(common.get_target_attribute_for_training(training)) 199 | 200 | # Get name (default param) 201 | training = config["trainings"]["T2"] 202 | expected = "MPD1" 203 | self.assertEqual(expected, 204 | common.get_target_attribute_for_training(training)) 205 | 206 | # Get name (using param) 207 | training = config["trainings"]["T2"] 208 | expected = "MPD1" 209 | self.assertEqual(expected, 210 | common.get_target_attribute_for_training(training, 211 | "name")) 212 | 213 | # Get name (using param) 214 | training = config["trainings"]["T2"] 215 | expected = "/mnt/mpd1" 216 | self.assertEqual(expected, 217 | common.get_target_attribute_for_training(training, 218 | "device_root")) 219 | 220 | def test_get_destination_path_for_training(self): 221 | tmpdir = self.create_temp_dir() 222 | tmpdir_slashed = "{}/".format(tmpdir) 223 | temp_sub_dir = os.path.join(tmpdir, "music") 224 | os.mkdir(temp_sub_dir) 225 | 226 | cfg = { 227 | "targets": { 228 | "MPD-no-device-root": { 229 | "alias": "I have no device_root", 230 | "device_path": "music" 231 | }, 232 | "MPD-non-existent": { 233 | "device_root": "/this/does/not/exist/i/hope", 234 | "device_path": "music" 235 | }, 236 | "MPD1": { 237 | "device_root": tmpdir, 238 | "device_path": "music" 239 | }, 240 | "MPD2": { 241 | "device_root": tmpdir_slashed, 242 | "device_path": "music" 243 | }, 244 | "MPD3": { 245 | "device_root": tmpdir, 246 | "device_path": "/music" 247 | }, 248 | "MPD4": { 249 | "device_root": tmpdir_slashed, 250 | "device_path": "/music" 251 | }, 252 | "MPD5": { 253 | "device_root": tmpdir_slashed, 254 | "device_path": "/music/" 255 | }, 256 | }, 257 | "trainings": { 258 | "T0-no-target": { 259 | "alias": "I have no target", 260 | }, 261 | "T0-no-device-root": { 262 | "target": "MPD-no-device-root", 263 | }, 264 | "T0-non-existent": { 265 | "target": "MPD-non-existent", 266 | }, 267 | "T1": { 268 | "target": "MPD1", 269 | }, 270 | "T2": { 271 | "target": "MPD2", 272 | }, 273 | "T3": { 274 | "target": "MPD3", 275 | }, 276 | "T4": { 277 | "target": "MPD4", 278 | }, 279 | "T5": { 280 | "target": "MPD5", 281 | } 282 | } 283 | } 284 | config = get_plugin_configuration(cfg) 285 | 286 | # No target 287 | training = config["trainings"]["T0-no-target"] 288 | path = common.get_destination_path_for_training(training) 289 | self.assertIsNone(path) 290 | 291 | # No device_root in target 292 | training = config["trainings"]["T0-no-device-root"] 293 | path = common.get_destination_path_for_training(training) 294 | self.assertIsNone(path) 295 | 296 | # No non existent device_root in target 297 | training = config["trainings"]["T0-non-existent"] 298 | path = common.get_destination_path_for_training(training) 299 | self.assertIsNone(path) 300 | 301 | # No separators between root and path 302 | training = config["trainings"]["T1"] 303 | expected = os.path.realpath(util.normpath( 304 | os.path.join(tmpdir, "music")).decode()) 305 | path = common.get_destination_path_for_training(training) 306 | self.assertEqual(expected, path) 307 | 308 | # final slash on device_root 309 | training = config["trainings"]["T2"] 310 | expected = os.path.realpath(util.normpath( 311 | os.path.join(tmpdir, "music")).decode()) 312 | path = common.get_destination_path_for_training(training) 313 | self.assertEqual(expected, path) 314 | 315 | # leading slash on device path 316 | training = config["trainings"]["T3"] 317 | expected = os.path.realpath(util.normpath( 318 | os.path.join(tmpdir, "music")).decode()) 319 | path = common.get_destination_path_for_training(training) 320 | self.assertEqual(expected, path) 321 | 322 | # final slash on device_root and leading slash on device path 323 | training = config["trainings"]["T4"] 324 | expected = os.path.realpath(util.normpath( 325 | os.path.join(tmpdir, "music")).decode()) 326 | path = common.get_destination_path_for_training(training) 327 | self.assertEqual(expected, path) 328 | 329 | # slashes allover 330 | training = config["trainings"]["T5"] 331 | expected = os.path.realpath(util.normpath( 332 | os.path.join(tmpdir, "music")).decode()) 333 | path = common.get_destination_path_for_training(training) 334 | self.assertEqual(expected, path) 335 | 336 | def test_get_target_attribute(self): 337 | cfg = { 338 | "targets": { 339 | "MPD1": { 340 | "device_root": "/media/mpd1/", 341 | "device_path": "auto/", 342 | } 343 | } 344 | } 345 | config = get_plugin_configuration(cfg) 346 | target = config["targets"]["MPD1"] 347 | 348 | self.assertEqual("/media/mpd1/", 349 | common.get_target_attribute(target, "device_root")) 350 | self.assertEqual("auto/", 351 | common.get_target_attribute(target, "device_path")) 352 | self.assertEqual(None, common.get_target_attribute(target, "not_there")) 353 | 354 | def test_get_duration_of_items(self): 355 | items = [self.create_item(length=120), self.create_item(length=79)] 356 | self.assertEqual(199, common.get_duration_of_items(items)) 357 | 358 | # ValueError 359 | baditem = self.create_item(length=-1) 360 | self.assertEqual(0, common.get_duration_of_items([baditem])) 361 | 362 | # ValueError 363 | baditem = self.create_item(length=None) 364 | self.assertEqual(0, common.get_duration_of_items([baditem])) 365 | 366 | # TypeError 367 | baditem = self.create_item(length=object()) 368 | self.assertEqual(0, common.get_duration_of_items([baditem])) 369 | 370 | def test_get_min_max_sum_avg_for_items(self): 371 | item1 = self.create_item(mood_happy=100) 372 | item2 = self.create_item(mood_happy=150) 373 | item3 = self.create_item(mood_happy=200) 374 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 375 | [item1, item2, item3], "mood_happy") 376 | self.assertEqual(100, _min) 377 | self.assertEqual(200, _max) 378 | self.assertEqual(450, _sum) 379 | self.assertEqual(150, _avg) 380 | 381 | item1 = self.create_item(mood_happy=99.7512345) 382 | item2 = self.create_item(mood_happy=150.482234) 383 | item3 = self.create_item(mood_happy=200.254733) 384 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 385 | [item1, item2, item3], "mood_happy") 386 | self.assertEqual(99.751, _min) 387 | self.assertEqual(200.255, _max) 388 | self.assertEqual(450.488, _sum) 389 | self.assertEqual(150.163, _avg) 390 | 391 | # ValueError 392 | item1 = self.create_item(mood_happy=100) 393 | item2 = self.create_item(mood_happy="") 394 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 395 | [item1, item2], "mood_happy") 396 | self.assertEqual(100, _min) 397 | self.assertEqual(100, _max) 398 | self.assertEqual(100, _sum) 399 | self.assertEqual(100, _avg) 400 | 401 | # TypeError 402 | item1 = self.create_item(mood_happy=100) 403 | item2 = self.create_item(mood_happy={}) 404 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 405 | [item1, item2], "mood_happy") 406 | self.assertEqual(100, _min) 407 | self.assertEqual(100, _max) 408 | self.assertEqual(100, _sum) 409 | self.assertEqual(100, _avg) 410 | 411 | # empty list 412 | _min, _max, _sum, _avg = common.get_min_max_sum_avg_for_items( 413 | [], "mood_happy") 414 | self.assertEqual(0, _min) 415 | self.assertEqual(0, _max) 416 | self.assertEqual(0, _sum) 417 | self.assertEqual(0, _avg) 418 | 419 | def test_increment_play_count_on_item(self): 420 | item1 = self.create_item(play_count=3) 421 | common.increment_play_count_on_item(item1, store=False, write=False) 422 | expected = 4 423 | self.assertEqual(expected, item1.get("play_count")) 424 | 425 | def test_get_class_instance(self): 426 | module_name = 'beetsplug.goingrunning' 427 | class_name = 'GoingRunningPlugin' 428 | instance = common.get_class_instance(module_name, class_name) 429 | self.assertIsInstance(instance, GoingRunningPlugin) 430 | 431 | with self.assertRaises(RuntimeError): 432 | module_name = 'beetsplug.goingtosleep' 433 | common.get_class_instance(module_name, class_name) 434 | 435 | with self.assertRaises(RuntimeError): 436 | module_name = 'beetsplug.goingrunning' 437 | class_name = 'GoingToSleepPlugin' 438 | common.get_class_instance(module_name, class_name) 439 | -------------------------------------------------------------------------------- /test/unit/003_command_test.py: -------------------------------------------------------------------------------- 1 | # Copyright: Copyright (c) 2020., Adam Jakab 2 | # Author: Adam Jakab 3 | # License: See LICENSE.txt 4 | from beets.library import Item 5 | from confuse import Subview 6 | from beetsplug.goingrunning import GoingRunningCommand 7 | from beetsplug.goingrunning import command 8 | 9 | from test.helper import UnitTestHelper, get_plugin_configuration, \ 10 | PLUGIN_NAME, PLUGIN_ALIAS, PLUGIN_SHORT_DESCRIPTION 11 | 12 | 13 | class CommandTest(UnitTestHelper): 14 | """Test methods in the beetsplug.goingrunning.command module 15 | """ 16 | 17 | def test_module_values(self): 18 | self.assertEqual(u'goingrunning', PLUGIN_NAME) 19 | self.assertEqual(u'run', PLUGIN_ALIAS) 20 | self.assertEqual( 21 | u'run with the music that matches your training sessions', 22 | PLUGIN_SHORT_DESCRIPTION) 23 | 24 | def test_class_init_config(self): 25 | cfg = {"something": "good"} 26 | config = get_plugin_configuration(cfg) 27 | inst = command.GoingRunningCommand(config) 28 | self.assertEqual(config, inst.config) 29 | 30 | def test_gather_parse_query_elements__test_1(self): 31 | plg_cfg: Subview = self.config["goingrunning"] 32 | training: Subview = plg_cfg["trainings"]["q-test-1"] 33 | cmd = GoingRunningCommand(plg_cfg) 34 | elements = cmd._gather_query_elements(training) 35 | self.assertListEqual([], elements) 36 | 37 | query = cmd.parse_query_elements(elements, Item) 38 | expected = "AndQuery([TrueQuery()])" 39 | self.assertEqual(expected, str(query)) 40 | 41 | def test_gather_parse_query_elements__test_2(self): 42 | plg_cfg: Subview = self.config["goingrunning"] 43 | training: Subview = plg_cfg["trainings"]["q-test-2"] 44 | cmd = GoingRunningCommand(plg_cfg) 45 | elements = cmd._gather_query_elements(training) 46 | expected = ['bpm:100..150', 'length:120..300', 'genre:hard rock'] 47 | self.assertListEqual(expected, elements) 48 | 49 | query = cmd.parse_query_elements(elements, Item) 50 | expected = ( 51 | "AndQuery([" 52 | "NumericQuery('bpm', '100..150', True), " 53 | "DurationQuery('length', '120..300', True), " 54 | "SubstringQuery('genre', 'hard rock', True)" 55 | "])" 56 | ) 57 | self.assertEqual(expected, str(query)) 58 | 59 | def test_gather_parse_query_elements__test_3(self): 60 | plg_cfg: Subview = self.config["goingrunning"] 61 | training: Subview = plg_cfg["trainings"]["q-test-3"] 62 | cmd = GoingRunningCommand(plg_cfg) 63 | elements = cmd._gather_query_elements(training) 64 | expected = ['bpm:100..150', 'length:120..300', 'genre:reggae'] 65 | self.assertListEqual(expected, elements) 66 | 67 | query = cmd.parse_query_elements(elements, Item) 68 | expected = ( 69 | "AndQuery([" 70 | "NumericQuery('bpm', '100..150', True), " 71 | "DurationQuery('length', '120..300', True), " 72 | "SubstringQuery('genre', 'reggae', True)" 73 | "])" 74 | ) 75 | self.assertEqual(expected, str(query)) 76 | 77 | def test_gather_parse_query_elements__test_4(self): 78 | plg_cfg: Subview = self.config["goingrunning"] 79 | training: Subview = plg_cfg["trainings"]["q-test-4"] 80 | cmd = GoingRunningCommand(plg_cfg) 81 | elements = cmd._gather_query_elements(training) 82 | expected = ['bpm:100..150', 'year:2015..', 83 | 'genre:reggae', 'year:1960..1969'] 84 | self.assertListEqual(expected, elements) 85 | 86 | query = cmd.parse_query_elements(elements, Item) 87 | expected = ( 88 | "AndQuery([" 89 | "NumericQuery('bpm', '100..150', True), " 90 | "OrQuery([" 91 | "NumericQuery('year', '2015..', True), " 92 | "NumericQuery('year', '1960..1969', True)" 93 | "]), " 94 | "SubstringQuery('genre', 'reggae', True)" 95 | "])" 96 | ) 97 | self.assertEqual(expected, str(query)) 98 | 99 | def test_gather_parse_query_elements__test_4_bis(self): 100 | """Command line query should always be the first in the list 101 | """ 102 | plg_cfg: Subview = self.config["goingrunning"] 103 | training: Subview = plg_cfg["trainings"]["q-test-4"] 104 | cmd = GoingRunningCommand(plg_cfg) 105 | cmd.query = ['albumartist:various artists'] 106 | elements = cmd._gather_query_elements(training) 107 | expected = cmd.query + \ 108 | ['bpm:100..150', 'year:2015..', 109 | 'genre:reggae', 'year:1960..1969'] 110 | self.assertListEqual(expected, elements) 111 | 112 | query = cmd.parse_query_elements(elements, Item) 113 | expected = ( 114 | "AndQuery([" 115 | "SubstringQuery('albumartist', 'various artists', True), " 116 | "NumericQuery('bpm', '100..150', True), " 117 | "OrQuery([" 118 | "NumericQuery('year', '2015..', True), " 119 | "NumericQuery('year', '1960..1969', True)" 120 | "]), " 121 | "SubstringQuery('genre', 'reggae', True)" 122 | "])" 123 | ) 124 | self.assertEqual(expected, str(query)) 125 | 126 | def test_gather_parse_query_elements__test_5(self): 127 | plg_cfg: Subview = self.config["goingrunning"] 128 | training: Subview = plg_cfg["trainings"]["q-test-5"] 129 | cmd = GoingRunningCommand(plg_cfg) 130 | elements = cmd._gather_query_elements(training) 131 | expected = ['genre:rock', 'genre:blues', 'genre:ska', 132 | 'genre:funk', 'genre:Rockabilly', 'genre:Disco'] 133 | self.assertListEqual(expected, elements) 134 | 135 | query = cmd.parse_query_elements(elements, Item) 136 | expected = ( 137 | "AndQuery([" 138 | "OrQuery([" 139 | "SubstringQuery('genre', 'rock', True), " 140 | "SubstringQuery('genre', 'blues', True), " 141 | "SubstringQuery('genre', 'ska', True), " 142 | "SubstringQuery('genre', 'funk', True), " 143 | "SubstringQuery('genre', 'Rockabilly', True), " 144 | "SubstringQuery('genre', 'Disco', True)" 145 | "])" 146 | "])" 147 | ) 148 | self.assertEqual(expected, str(query)) 149 | 150 | def test_gather_parse_query_elements__test_6(self): 151 | plg_cfg: Subview = self.config["goingrunning"] 152 | training: Subview = plg_cfg["trainings"]["q-test-6"] 153 | cmd = GoingRunningCommand(plg_cfg) 154 | elements = cmd._gather_query_elements(training) 155 | expected = ['genre:rock', 'genre:blues', 156 | '^genre:jazz', '^genre:death metal'] 157 | self.assertListEqual(expected, elements) 158 | 159 | query = cmd.parse_query_elements(elements, Item) 160 | print(query) 161 | expected = ( 162 | "AndQuery([" 163 | "OrQuery([" 164 | "SubstringQuery('genre', 'rock', True), " 165 | "SubstringQuery('genre', 'blues', True)" 166 | "]), " 167 | "AndQuery([" 168 | "NotQuery(SubstringQuery('genre', 'jazz', True)), " 169 | "NotQuery(SubstringQuery('genre', 'death metal', True))" 170 | "])" 171 | "])" 172 | ) 173 | self.assertEqual(expected, str(query)) 174 | -------------------------------------------------------------------------------- /test/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjakab/BeetsPluginGoingRunning/e16dfcf28d2ca77347049ec6394d7f68f96cfe1f/test/unit/__init__.py --------------------------------------------------------------------------------