From 6087cb343c3fbb1ece8ed474a6cdf4820ff569b9 Mon Sep 17 00:00:00 2001 From: Thomas Scholtes Date: Wed, 10 Sep 2014 17:45:37 +0200 Subject: [PATCH] initial commit --- .gitignore | 8 ++ .travis.yml | 15 +++ LICENSE | 19 ++++ MANIFEST.in | 1 + README.md | 167 +++++++++++++++++++++++++++++++++ beetsplug/__init__.py | 2 + beetsplug/alternatives.py | 191 +++++++++++++++++++++++++++++++++++++ setup.cfg | 8 ++ setup.py | 32 +++++++ test/cli_test.py | 133 ++++++++++++++++++++++++++ test/fixtures/min.mp3 | Bin 0 -> 12820 bytes test/helper.py | 192 ++++++++++++++++++++++++++++++++++++++ tox.ini | 24 +++++ 13 files changed, 792 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 beetsplug/__init__.py create mode 100644 beetsplug/alternatives.py create mode 100644 setup.cfg create mode 100755 setup.py create mode 100644 test/cli_test.py create mode 100644 test/fixtures/min.mp3 create mode 100644 test/helper.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8fb904 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +dist/ +*.egg-info/ +build/ +.noseids +.coverage +coverage/ +*.pyc +.tox/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4dd9d6e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +notifications: + email: false + +language: python + +env: + - TOX_ENV=beets-master COVERAGE=1 + # - TOX_ENV=beets-release + +install: + - "pip install coveralls tox" + +script: "tox -e $TOX_ENV" + +after_success: "[ ! -z $COVERAGE ] && coveralls || true" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b825cd2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 Thomas Scholtes. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..72ca36c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include LICENSE README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b268d4f --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +beets-alternatives +================== + +You want to manage multiple versions of your audio files with beets? +Your favourite iPlayer has limited space and does not support OGG? You +want to keep lossless versions on a large external drive? You want to +symlink your audio to other locations? + +Getting Started +--------------- + +The basic idea of this plugin is that every file in your library can +have multiple alternate versions in separate locations. + +There are three basic use cases introduced below. + +### External Files + +Suppose your favourite portable player only supports MP3 and has +limited disk space. It is mounted at `/player` and instead of selecting +its content manually and using the `convert` plugin to transcode it you +want to sync it automatically. We call this external location +'myplayer' and start configuring beets. + +```yaml +alt: + external: + myplayer: + directory: /player + paths: + default: $album/$title + format: mp3 + query: "onplayer:true" +``` + +The first to options are self-explanatory. They determine the location +of the external files and correspond to the global +[`directory`][config-directory] and [`paths`][config-paths] options. +The `format` option specifies the format we transcode the files to. +We use the [convert plugin][], so the format name must correspond to +one of the formats [configured for convert][]. Finally, the `query` +option tells the plugin which files you want to put in the external +location. The value is a [query string][] as used for the beets command +line. In our case we use a flexible attribute to make the selection +transparent. + +First we add some files to our selection by setting the flexible +attribute from the `query` option. + +``` +$ beet modify onplayer=true artist:Bach +``` + +We then tell beets to create the external files. + +``` +$ beet alt update myplayer +``` + +A quick look into the `/player` directory reveals that indeed all +tracks of Bach have been transcoded to MP3 and copied to the player. + +If you update your takes locally, the `alt update` command will +propagate the changes to your external collection. Since we don’t need +to convert the files but just update the tags, this will be much faster +the second time. + +``` +$ beet modify composer="Johann Sebastian Bach" artist:Bach +$ beet alt update myplayer +``` + +After going for a run you realise that Bach is probably not the right +thing to work out to. So you decide to put Beethoven on your player. + +``` +$ beet modify onplayer! artist:Bach +$ beet modify onplayer=true artist:Beethoven +$ beet alt update myplayer +``` + +This removes all Bach tracks from the player and adds Beethoven’s. + + +### Archive Files + +TODO + +### Symlink Views + +TODO + +### Ideas + +* `removable` option checks if directory exists and aborts otherwise + + +CLI Reference +------------- + +``` +beet alt update NAME +``` + +Updates the external collection configured under `alt.external.NAME`. + +* Add missing files. Convert them to the configured format or copy + them. +* Remove files that dont’t match the query but are still in the + external collection +* Move files to the path determined from the `paths` configuration. +* Update tags if the modification time of the external file is older + then that of the source file from the library. + + +Configuration +------------- + +The `alt.external` configuration is a dictionary. The keys are the +names of the external locations and used for reference from the command +line. The values are again dictionaries with the following keys. + +* `directory` The root directory to store the external files under. + Relative paths are resolved with respect to the global `directory` + configuration. + +* `paths` Path templates for audio files under `directory`. Configured + like [global paths option][config-paths]. + +* `query` A [query string][] that determine which tracks belong to the + collection. + +* `format` (optional) A string that determines the format to convert + audio files in the external collection to. The string must correspond + to a key in the [`convert.formats`][convert plugin] configuration. + The settings of the configuration are used to run the conversion. + + +License +------- + +Copyright (c) 2014 Thomas Scholtes. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +[config-directory]: http://beets.readthedocs.org/en/latest/reference/config.html#directory +[config-paths]: http://beets.readthedocs.org/en/latest/reference/config.html#path-format-configuration +[configured for convert]: http://beets.readthedocs.org/en/latest/plugins/convert.html#configuring-the-transcoding-command +[convert plugin]: http://beets.readthedocs.org/en/latest/plugins/convert.html +[query string]: http://beets.readthedocs.org/en/latest/reference/query.html diff --git a/beetsplug/__init__.py b/beetsplug/__init__.py new file mode 100644 index 0000000..3ad9513 --- /dev/null +++ b/beetsplug/__init__.py @@ -0,0 +1,2 @@ +from pkgutil import extend_path +__path__ = extend_path(__path__, __name__) diff --git a/beetsplug/alternatives.py b/beetsplug/alternatives.py new file mode 100644 index 0000000..17bd660 --- /dev/null +++ b/beetsplug/alternatives.py @@ -0,0 +1,191 @@ +# Copyright (c) 2014 Thomas Scholtes + +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + + +import os.path +import logging +import threading +from argparse import ArgumentParser +from concurrent import futures + +from beets import util +from beets.plugins import BeetsPlugin +from beets.ui import Subcommand, get_path_formats +from beets.library import get_query_sort, Item +from beets.util import syspath + +from beetsplug import convert + +log = logging.getLogger('beets.alternatives') + + +class AlternativesPlugin(BeetsPlugin): + + def __init__(self): + super(AlternativesPlugin, self).__init__() + + def commands(self): + return [AlternativesCommand(self)] + + def update(self, lib, options): + self.alternative(options.name, lib).update() + + def alternative(self, name, lib): + conf = self.config['external'][name] + if conf.exists(): + if conf['format'].exists(): + return ExternalConvert(name, conf['format'].get(unicode), + lib, conf) + else: + return External(name, lib, conf) + + +class AlternativesCommand(Subcommand): + + name = 'alt' + help = 'manage alternative files' + + def __init__(self, plugin): + parser = ArgumentParser() + subparsers = parser.add_subparsers() + update = subparsers.add_parser('update') + update.set_defaults(func=plugin.update) + update.add_argument('name') + super(AlternativesCommand, self).__init__(self.name, parser) + + def func(self, lib, opts, _): + opts.func(lib, opts) + + def parse_args(self, args): + return self.parser.parse_args(args), [] + + +class External(object): + + ADD = 1 + REMOVE = 2 + WRITE = 3 + MOVE = 4 + + def __init__(self, name, lib, config): + self.lib = lib + self.path_key = 'alt.{0}'.format(name) + self.path_formats = get_path_formats(config['paths']) + self.query, _ = get_query_sort(config['query'].get(unicode), Item) + + dir = config['directory'].as_filename() + if not os.path.isabs(dir): + dir = os.path.join(lib.directory, dir) + self.directory = dir + + def items_action(self, items): + for item in items: + path = item.get(self.path_key) + if self.query.match(item): + if path: + dest = self.destination(item) + if path != dest: + yield (item, self.MOVE) + elif (os.path.getmtime(syspath(dest)) + < os.path.getmtime(syspath(item.path))): + yield (item, self.WRITE) + else: + yield (item, self.ADD) + elif path: + yield (item, self.REMOVE) + + def update(self): + converter = self.converter() + for (item, action) in self.items_action(self.lib.items()): + dest = self.destination(item) + path = item.get(self.path_key) + if action == self.MOVE: + print('>{0} -> {1}'.format(path, dest)) + util.mkdirall(dest) + util.move(path, dest) + item[self.path_key] = dest + item.store() + elif action == self.WRITE: + print('*{0}'.format(path)) + item.write(path=path) + elif action == self.ADD: + print('+{0}'.format(dest)) + converter.submit(item) + elif action == self.REMOVE: + print('-{0}'.format(self.destination(item))) + util.remove(path) + util.prune_dirs(path) + del item[self.path_key] + item.store() + + for item, dest in converter.as_completed(): + item[self.path_key] = dest + item.store() + converter.shutdown() + + def destination(self, item): + return item.destination(basedir=self.directory, + path_formats=self.path_formats) + + def converter(self): + def _convert(item): + dest = self.destination(item) + util.mkdirall(dest) + util.copy(item.path, dest) + return item, dest + return Worker(_convert) + + +class ExternalConvert(External): + + def __init__(self, name, format, lib, config): + super(ExternalConvert, self).__init__(name, lib, config) + self.format = format + self.convert_cmd, self.ext = convert.get_format(self.format) + + def converter(self): + command, ext = convert.get_format(self.format) + fs_lock = threading.Lock() + + def _convert(item): + dest = self.destination(item) + with fs_lock: + util.mkdirall(dest) + + if not self.format or self.format.lower() == item.format.lower(): + util.copy(item.path, dest) + else: + convert.encode(command, item.path, dest) + return item, dest + return Worker(_convert) + + def destination(self, item): + dest = super(ExternalConvert, self).destination(item) + return os.path.splitext(dest)[0] + '.' + self.ext + + +class Worker(futures.ThreadPoolExecutor): + + def __init__(self, fn, max_workers=None): + super(Worker, self).__init__(max_workers=1) + self._tasks = set() + self._fn = fn + + def submit(self, *args, **kwargs): + fut = super(Worker, self).submit(self._fn, *args, **kwargs) + self._tasks.add(fut) + return fut + + def as_completed(self): + for f in futures.as_completed(self._tasks): + self._tasks.remove(f) + yield f.result() diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..12f3031 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[nosetests] +verbosity=1 +with-coverage=1 +cover-erase=1 +cover-package=beetsplug.alternatives +cover-html=1 +cover-html-dir=coverage +logging-clear-handlers=1 diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..72c1fe8 --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +from setuptools import setup + +setup( + name='beets-alternatives', + version='0.8.0-beta.1', + description='beets plugin to manage multiple files', + long_description=open('README.md').read(), + author='Thomas Scholtes', + author_email='thomas-scholtes@gmx.de', + url='http://www.github.com/geigerzaehler/beets-alternatives', + license='MIT', + platforms='ALL', + + test_suite='test', + + packages=['beetsplug'], + + install_requires=[ + 'beets>=1.3.8', + 'futures', + ], + + classifiers=[ + 'Topic :: Multimedia :: Sound/Audio', + 'Topic :: Multimedia :: Sound/Audio :: Players :: MP3', + 'License :: OSI Approved :: MIT License', + 'Environment :: Console', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + ], +) diff --git a/test/cli_test.py b/test/cli_test.py new file mode 100644 index 0000000..7b16144 --- /dev/null +++ b/test/cli_test.py @@ -0,0 +1,133 @@ +import os +import os.path +from unittest import TestCase + +from helper import TestHelper + +from beets.mediafile import MediaFile + + +class DocTest(TestHelper, TestCase): + + def setUp(self): + super(DocTest, self).setUp() + self.add_album(artist='Bach') + self.add_album(artist='Beethoven') + + def test_external(self): + external_dir = self.mkdtemp() + self.config['convert']['formats'] = { + 'ogg': 'cp $source $dest; printf ISOGG >> $dest' + } + self.config['alternatives']['external'] = { + 'myplayer': { + 'directory': external_dir, + 'paths': {'default': '$artist/$title'}, + 'format': 'ogg', + 'query': 'onplayer:true' + } + } + + external_bach = os.path.join(external_dir, 'Bach', 'track 1.ogg') + external_beet = os.path.join(external_dir, 'Beethoven', 'track 1.ogg') + + self.runcli('modify', '--yes', 'onplayer=true', 'artist:Bach') + self.runcli('alt', 'update', 'myplayer') + + self.assertIsConvertedOgg(external_bach) + self.assertFalse(os.path.isfile(external_beet)) + + self.runcli('modify', '--yes', 'composer=JSB', 'artist:Bach') + self.runcli('alt', 'update', 'myplayer') + mediafile = MediaFile(external_bach) + self.assertEqual(mediafile.composer, 'JSB') + + self.runcli('modify', '--yes', 'onplayer!', 'artist:Bach') + self.runcli('modify', '--yes', 'onplayer=true', 'artist:Beethoven') + self.runcli('alt', 'update', 'myplayer') + + self.assertFalse(os.path.isfile(external_bach)) + self.assertIsConvertedOgg(external_beet) + + def assertIsConvertedOgg(self, path): + with open(path, 'r') as f: + f.seek(-5, os.SEEK_END) + self.assertEqual(f.read(), 'ISOGG') + + +class ExternalCopyCliTest(TestHelper, TestCase): + + def setUp(self): + super(ExternalCopyCliTest, self).setUp() + external_dir = self.mkdtemp() + self.config['alternatives']['external'] = { + 'myexternal': { + 'directory': external_dir, + 'paths': {'default': '$artist/$title'}, + 'query': 'myexternal:true' + } + } + self.external_config = \ + self.config['alternatives']['external']['myexternal'] + + def test_update_older(self): + item = self.add_external_track('myexternal') + item['composer'] = 'JSB' + item.store() + item.write() + + self.runcli('alt', 'update', 'myexternal') + item.load() + mediafile = MediaFile(item['alt.myexternal']) + self.assertEqual(mediafile.composer, 'JSB') + + def test_no_udpdate_newer(self): + item = self.add_external_track('myexternal') + item['composer'] = 'JSB' + item.store() + # We omit write to keep old mtime + + self.runcli('alt', 'update', 'myexternal') + item.load() + mediafile = MediaFile(item['alt.myexternal']) + self.assertNotEqual(mediafile.composer, 'JSB') + + def test_move_after_path_format_update(self): + item = self.add_external_track('myexternal') + old_path = item['alt.myexternal'] + self.assertTrue(os.path.isfile(old_path)) + + self.external_config['paths'] = {'default': '$album/$title'} + self.runcli('alt', 'update', 'myexternal') + + item.load() + new_path = item['alt.myexternal'] + self.assertFalse(os.path.isfile(old_path)) + self.assertTrue(os.path.isfile(new_path)) + + def test_move_after_tags_changed(self): + item = self.add_external_track('myexternal') + old_path = item['alt.myexternal'] + self.assertTrue(os.path.isfile(old_path)) + + item['title'] = 'a new title' + item.store() + self.runcli('alt', 'update', 'myexternal') + + item.load() + new_path = item['alt.myexternal'] + self.assertFalse(os.path.isfile(old_path)) + self.assertTrue(os.path.isfile(new_path)) + + def test_remove(self): + item = self.add_external_track('myexternal') + old_path = item['alt.myexternal'] + self.assertTrue(os.path.isfile(old_path)) + + del item['myexternal'] + item.store() + self.runcli('alt', 'update', 'myexternal') + + item.load() + self.assertNotIn('alt.myexternal', item) + self.assertFalse(os.path.isfile(old_path)) diff --git a/test/fixtures/min.mp3 b/test/fixtures/min.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7a8006b36b8aca1b2d40a45ebe7f059cf919eac8 GIT binary patch literal 12820 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.3.8 + +[testenv:beets-release] +commands = nosetests {posargs} + +[testenv:beets-master] +deps = + {[testenv]deps} + git+git://github.com/sampsyo/beets.git@master + +[testenv:flake8] +deps = + flake8 +commands = flake8 beetsplug test setup.py