mirror of
https://github.com/beetbox/beets.git
synced 2026-01-06 07:53:40 +01:00
Merge remote-tracking branch 'origin/master' into parallel-replaygain
This commit is contained in:
commit
e3205aacbd
56 changed files with 1023 additions and 409 deletions
6
.github/ISSUE_TEMPLATE/bug-report.md
vendored
6
.github/ISSUE_TEMPLATE/bug-report.md
vendored
|
|
@ -35,6 +35,12 @@ Here's a link to the music files that trigger the bug (if relevant):
|
|||
* beets version:
|
||||
* Turning off plugins made problem go away (yes/no):
|
||||
|
||||
<!--
|
||||
You can turn off plugins temporarily by passing --plugins= on the command line:
|
||||
|
||||
$ beet --plugins= version
|
||||
-->
|
||||
|
||||
My configuration (output of `beet config`) is:
|
||||
|
||||
```yaml
|
||||
|
|
|
|||
4
.github/workflows/integration_test.yaml
vendored
4
.github/workflows/integration_test.yaml
vendored
|
|
@ -27,6 +27,10 @@ jobs:
|
|||
run: |
|
||||
tox -e int
|
||||
|
||||
- name: Check external links in docs
|
||||
run: |
|
||||
tox -e links
|
||||
|
||||
- name: Notify on failure
|
||||
if: ${{ failure() }}
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Non-Programming
|
|||
|
||||
- Promote beets! Help get the word out by telling your friends, writing
|
||||
a blog post, or discussing it on a forum you frequent.
|
||||
- Improve the `documentation <http://beets.readthedocs.org/>`__. It’s
|
||||
- Improve the `documentation`_. It’s
|
||||
incredibly easy to contribute here: just find a page you want to
|
||||
modify and hit the “Edit on GitHub” button in the upper-right. You
|
||||
can automatically send us a pull request for your changes.
|
||||
|
|
@ -62,7 +62,7 @@ Getting the Source
|
|||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The easiest way to get started with the latest beets source is to use
|
||||
`pip <https://pip.pypa.io/>`__ to install an “editable” package. This
|
||||
`pip`_ to install an “editable” package. This
|
||||
can be done with one command:
|
||||
|
||||
.. code-block:: bash
|
||||
|
|
@ -147,8 +147,7 @@ request and your code will ship in no time.
|
|||
5. Add a changelog entry to ``docs/changelog.rst`` near the top of the
|
||||
document.
|
||||
6. Run the tests and style checker. The easiest way to run the tests is
|
||||
to use `tox <https://tox.readthedocs.org/en/latest/>`__. For more
|
||||
information on running tests, see :ref:`testing`.
|
||||
to use `tox`_. For more information on running tests, see :ref:`testing`.
|
||||
7. Push to your fork and open a pull request! We’ll be in touch shortly.
|
||||
8. If you add commits to a pull request, please add a comment or
|
||||
re-request a review after you push them since GitHub doesn’t
|
||||
|
|
@ -253,7 +252,7 @@ guidelines to follow:
|
|||
Editor Settings
|
||||
---------------
|
||||
|
||||
Personally, I work on beets with `vim <http://www.vim.org/>`__. Here are
|
||||
Personally, I work on beets with `vim`_. Here are
|
||||
some ``.vimrc`` lines that might help with PEP 8-compliant Python
|
||||
coding::
|
||||
|
||||
|
|
@ -318,7 +317,7 @@ To install the test dependencies, run ``python -m pip install .[test]``.
|
|||
Or, just run a test suite with ``tox`` which will install them
|
||||
automatically.
|
||||
|
||||
.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py#L99`
|
||||
.. _setup.py: https://github.com/beetbox/beets/blob/master/setup.py
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
|
|
@ -352,9 +351,9 @@ others. See `unittest.mock`_ for more info.
|
|||
.. _Python unittest: https://docs.python.org/2/library/unittest.html
|
||||
.. _Codecov: https://codecov.io/github/beetbox/beets
|
||||
.. _pytest-random: https://github.com/klrmn/pytest-random
|
||||
.. _tox: http://tox.readthedocs.org
|
||||
.. _detox: https://pypi.python.org/pypi/detox/
|
||||
.. _pytest: http://pytest.org
|
||||
.. _tox: https://tox.readthedocs.io/en/latest/
|
||||
.. _detox: https://pypi.org/project/detox/
|
||||
.. _pytest: https://docs.pytest.org/en/stable/
|
||||
.. _Linux: https://github.com/beetbox/beets/actions
|
||||
.. _Windows: https://ci.appveyor.com/project/beetbox/beets/
|
||||
.. _`https://github.com/beetbox/beets/blob/master/setup.py#L99`: https://github.com/beetbox/beets/blob/master/setup.py#L99
|
||||
|
|
@ -364,3 +363,6 @@ others. See `unittest.mock`_ for more info.
|
|||
.. _integration test: https://github.com/beetbox/beets/actions?query=workflow%3A%22integration+tests%22
|
||||
.. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html
|
||||
.. _Python unittest: https://docs.python.org/2/library/unittest.html
|
||||
.. _documentation: https://beets.readthedocs.io/en/stable/
|
||||
.. _pip: https://pip.pypa.io/en/stable/
|
||||
.. _vim: https://www.vim.org/
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
|
|||
TRACK_INCLUDES = ['artists', 'aliases']
|
||||
if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']:
|
||||
TRACK_INCLUDES += ['work-level-rels', 'artist-rels']
|
||||
if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']:
|
||||
RELEASE_INCLUDES += ['genres']
|
||||
|
||||
|
||||
def track_url(trackid):
|
||||
|
|
@ -415,6 +417,10 @@ def album_info(release):
|
|||
first_medium = release['medium-list'][0]
|
||||
info.media = first_medium.get('format')
|
||||
|
||||
genres = release.get('genre-list')
|
||||
if config['musicbrainz']['genres'] and genres:
|
||||
info.genre = ';'.join(g['name'] for g in genres)
|
||||
|
||||
info.decode()
|
||||
return info
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import:
|
|||
move: no
|
||||
link: no
|
||||
hardlink: no
|
||||
reflink: no
|
||||
delete: no
|
||||
resume: ask
|
||||
incremental: no
|
||||
|
|
@ -105,6 +106,7 @@ musicbrainz:
|
|||
ratelimit_interval: 1.0
|
||||
searchlimit: 5
|
||||
extra_tags: []
|
||||
genres: no
|
||||
|
||||
match:
|
||||
strong_rec_thresh: 0.04
|
||||
|
|
|
|||
|
|
@ -207,6 +207,12 @@ class String(Type):
|
|||
sql = u'TEXT'
|
||||
query = query.SubstringQuery
|
||||
|
||||
def normalize(self, value):
|
||||
if value is None:
|
||||
return self.null
|
||||
else:
|
||||
return self.model_type(value)
|
||||
|
||||
|
||||
class Boolean(Type):
|
||||
"""A boolean type.
|
||||
|
|
|
|||
|
|
@ -222,19 +222,31 @@ class ImportSession(object):
|
|||
iconfig['resume'] = False
|
||||
iconfig['incremental'] = False
|
||||
|
||||
# Copy, move, link, and hardlink are mutually exclusive.
|
||||
if iconfig['reflink']:
|
||||
iconfig['reflink'] = iconfig['reflink'] \
|
||||
.as_choice(['auto', True, False])
|
||||
|
||||
# Copy, move, reflink, link, and hardlink are mutually exclusive.
|
||||
if iconfig['move']:
|
||||
iconfig['copy'] = False
|
||||
iconfig['link'] = False
|
||||
iconfig['hardlink'] = False
|
||||
iconfig['reflink'] = False
|
||||
elif iconfig['link']:
|
||||
iconfig['copy'] = False
|
||||
iconfig['move'] = False
|
||||
iconfig['hardlink'] = False
|
||||
iconfig['reflink'] = False
|
||||
elif iconfig['hardlink']:
|
||||
iconfig['copy'] = False
|
||||
iconfig['move'] = False
|
||||
iconfig['link'] = False
|
||||
iconfig['reflink'] = False
|
||||
elif iconfig['reflink']:
|
||||
iconfig['copy'] = False
|
||||
iconfig['move'] = False
|
||||
iconfig['link'] = False
|
||||
iconfig['hardlink'] = False
|
||||
|
||||
# Only delete when copying.
|
||||
if not iconfig['copy']:
|
||||
|
|
@ -707,7 +719,7 @@ class ImportTask(BaseImportTask):
|
|||
item.update(changes)
|
||||
|
||||
def manipulate_files(self, operation=None, write=False, session=None):
|
||||
""" Copy, move, link or hardlink (depending on `operation`) the files
|
||||
""" Copy, move, link, hardlink or reflink (depending on `operation`) the files
|
||||
as well as write metadata.
|
||||
|
||||
`operation` should be an instance of `util.MoveOperation`.
|
||||
|
|
@ -1536,6 +1548,8 @@ def manipulate_files(session, task):
|
|||
operation = MoveOperation.LINK
|
||||
elif session.config['hardlink']:
|
||||
operation = MoveOperation.HARDLINK
|
||||
elif session.config['reflink']:
|
||||
operation = MoveOperation.REFLINK
|
||||
else:
|
||||
operation = None
|
||||
|
||||
|
|
|
|||
|
|
@ -747,6 +747,16 @@ class Item(LibModel):
|
|||
util.hardlink(self.path, dest)
|
||||
plugins.send("item_hardlinked", item=self, source=self.path,
|
||||
destination=dest)
|
||||
elif operation == MoveOperation.REFLINK:
|
||||
util.reflink(self.path, dest, fallback=False)
|
||||
plugins.send("item_reflinked", item=self, source=self.path,
|
||||
destination=dest)
|
||||
elif operation == MoveOperation.REFLINK_AUTO:
|
||||
util.reflink(self.path, dest, fallback=True)
|
||||
plugins.send("item_reflinked", item=self, source=self.path,
|
||||
destination=dest)
|
||||
else:
|
||||
assert False, 'unknown MoveOperation'
|
||||
|
||||
# Either copying or moving succeeded, so update the stored path.
|
||||
self.path = dest
|
||||
|
|
@ -1087,6 +1097,12 @@ class Album(LibModel):
|
|||
util.link(old_art, new_art)
|
||||
elif operation == MoveOperation.HARDLINK:
|
||||
util.hardlink(old_art, new_art)
|
||||
elif operation == MoveOperation.REFLINK:
|
||||
util.reflink(old_art, new_art, fallback=False)
|
||||
elif operation == MoveOperation.REFLINK_AUTO:
|
||||
util.reflink(old_art, new_art, fallback=True)
|
||||
else:
|
||||
assert False, 'unknown MoveOperation'
|
||||
self.artpath = new_art
|
||||
|
||||
def move(self, operation=MoveOperation.MOVE, basedir=None, store=True):
|
||||
|
|
|
|||
|
|
@ -389,17 +389,19 @@ def input_yn(prompt, require=False):
|
|||
return sel == u'y'
|
||||
|
||||
|
||||
def input_select_objects(prompt, objs, rep):
|
||||
def input_select_objects(prompt, objs, rep, prompt_all=None):
|
||||
"""Prompt to user to choose all, none, or some of the given objects.
|
||||
Return the list of selected objects.
|
||||
|
||||
`prompt` is the prompt string to use for each question (it should be
|
||||
phrased as an imperative verb). `rep` is a function to call on each
|
||||
object to print it out when confirming objects individually.
|
||||
phrased as an imperative verb). If `prompt_all` is given, it is used
|
||||
instead of `prompt` for the first (yes(/no/select) question.
|
||||
`rep` is a function to call on each object to print it out when confirming
|
||||
objects individually.
|
||||
"""
|
||||
choice = input_options(
|
||||
(u'y', u'n', u's'), False,
|
||||
u'%s? (Yes/no/select)' % prompt)
|
||||
u'%s? (Yes/no/select)' % (prompt_all or prompt))
|
||||
print() # Blank line.
|
||||
|
||||
if choice == u'y': # Yes.
|
||||
|
|
@ -1100,8 +1102,8 @@ optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
|
|||
|
||||
# The main entry point and bootstrapping.
|
||||
|
||||
def _load_plugins(config):
|
||||
"""Load the plugins specified in the configuration.
|
||||
def _load_plugins(options, config):
|
||||
"""Load the plugins specified on the command line or in the configuration.
|
||||
"""
|
||||
paths = config['pluginpath'].as_str_seq(split=False)
|
||||
paths = [util.normpath(p) for p in paths]
|
||||
|
|
@ -1112,13 +1114,20 @@ def _load_plugins(config):
|
|||
|
||||
# Extend the `beetsplug` package to include the plugin paths.
|
||||
import beetsplug
|
||||
beetsplug.__path__ = paths + beetsplug.__path__
|
||||
beetsplug.__path__ = paths + list(beetsplug.__path__)
|
||||
|
||||
# For backwards compatibility, also support plugin paths that
|
||||
# *contain* a `beetsplug` package.
|
||||
sys.path += paths
|
||||
|
||||
plugins.load_plugins(config['plugins'].as_str_seq())
|
||||
# If we were given any plugins on the command line, use those.
|
||||
if options.plugins is not None:
|
||||
plugin_list = (options.plugins.split(',')
|
||||
if len(options.plugins) > 0 else [])
|
||||
else:
|
||||
plugin_list = config['plugins'].as_str_seq()
|
||||
|
||||
plugins.load_plugins(plugin_list)
|
||||
plugins.send("pluginload")
|
||||
return plugins
|
||||
|
||||
|
|
@ -1133,7 +1142,7 @@ def _setup(options, lib=None):
|
|||
|
||||
config = _configure(options)
|
||||
|
||||
plugins = _load_plugins(config)
|
||||
plugins = _load_plugins(options, config)
|
||||
|
||||
# Get the default subcommands.
|
||||
from beets.ui.commands import default_commands
|
||||
|
|
@ -1231,6 +1240,8 @@ def _raw_main(args, lib=None):
|
|||
help=u'log more details (use twice for even more)')
|
||||
parser.add_option('-c', '--config', dest='config',
|
||||
help=u'path to configuration file')
|
||||
parser.add_option('-p', '--plugins', dest='plugins',
|
||||
help=u'a comma-separated list of plugins to load')
|
||||
parser.add_option('-h', '--help', dest='help', action='store_true',
|
||||
help=u'show this help message and exit')
|
||||
parser.add_option('--version', dest='version', action='store_true',
|
||||
|
|
|
|||
|
|
@ -468,6 +468,10 @@ def summarize_items(items, singleton):
|
|||
total_duration = sum([item.length for item in items])
|
||||
total_filesize = sum([item.filesize for item in items])
|
||||
summary_parts.append(u'{0}kbps'.format(int(average_bitrate / 1000)))
|
||||
if items[0].format == "FLAC":
|
||||
sample_bits = u'{}kHz/{} bit'.format(
|
||||
round(int(items[0].samplerate) / 1000, 1), items[0].bitdepth)
|
||||
summary_parts.append(sample_bits)
|
||||
summary_parts.append(ui.human_seconds_short(total_duration))
|
||||
summary_parts.append(ui.human_bytes(total_filesize))
|
||||
|
||||
|
|
@ -803,7 +807,7 @@ class TerminalImportSession(importer.ImportSession):
|
|||
))
|
||||
|
||||
sel = ui.input_options(
|
||||
(u'Skip new', u'Keep both', u'Remove old', u'Merge all')
|
||||
(u'Skip new', u'Keep all', u'Remove old', u'Merge all')
|
||||
)
|
||||
|
||||
if sel == u's':
|
||||
|
|
@ -1228,31 +1232,53 @@ def remove_items(lib, query, album, delete, force):
|
|||
"""
|
||||
# Get the matching items.
|
||||
items, albums = _do_query(lib, query, album)
|
||||
objs = albums if album else items
|
||||
|
||||
# Confirm file removal if not forcing removal.
|
||||
if not force:
|
||||
# Prepare confirmation with user.
|
||||
print_()
|
||||
album_str = u" in {} album{}".format(
|
||||
len(albums), u's' if len(albums) > 1 else u''
|
||||
) if album else ""
|
||||
|
||||
if delete:
|
||||
fmt = u'$path - $title'
|
||||
prompt = u'Really DELETE %i file%s (y/n)?' % \
|
||||
(len(items), 's' if len(items) > 1 else '')
|
||||
prompt = u'Really DELETE'
|
||||
prompt_all = u'Really DELETE {} file{}{}'.format(
|
||||
len(items), u's' if len(items) > 1 else u'', album_str
|
||||
)
|
||||
else:
|
||||
fmt = u''
|
||||
prompt = u'Really remove %i item%s from the library (y/n)?' % \
|
||||
(len(items), 's' if len(items) > 1 else '')
|
||||
prompt = u'Really remove from the library?'
|
||||
prompt_all = u'Really remove {} item{}{} from the library?'.format(
|
||||
len(items), u's' if len(items) > 1 else u'', album_str
|
||||
)
|
||||
|
||||
# Helpers for printing affected items
|
||||
def fmt_track(t):
|
||||
ui.print_(format(t, fmt))
|
||||
|
||||
def fmt_album(a):
|
||||
ui.print_()
|
||||
for i in a.items():
|
||||
fmt_track(i)
|
||||
|
||||
fmt_obj = fmt_album if album else fmt_track
|
||||
|
||||
# Show all the items.
|
||||
for item in items:
|
||||
ui.print_(format(item, fmt))
|
||||
for o in objs:
|
||||
fmt_obj(o)
|
||||
|
||||
# Confirm with user.
|
||||
if not ui.input_yn(prompt, True):
|
||||
return
|
||||
objs = ui.input_select_objects(prompt, objs, fmt_obj,
|
||||
prompt_all=prompt_all)
|
||||
|
||||
if not objs:
|
||||
return
|
||||
|
||||
# Remove (and possibly delete) items.
|
||||
with lib.transaction():
|
||||
for obj in (albums if album else items):
|
||||
for obj in objs:
|
||||
obj.remove(delete)
|
||||
|
||||
|
||||
|
|
@ -1665,7 +1691,10 @@ def config_func(lib, opts, args):
|
|||
# Dump configuration.
|
||||
else:
|
||||
config_out = config.dump(full=opts.defaults, redact=opts.redact)
|
||||
print_(util.text_string(config_out))
|
||||
if config_out.strip() != '{}':
|
||||
print_(util.text_string(config_out))
|
||||
else:
|
||||
print("Empty configuration")
|
||||
|
||||
|
||||
def config_edit():
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@ class MoveOperation(Enum):
|
|||
COPY = 1
|
||||
LINK = 2
|
||||
HARDLINK = 3
|
||||
REFLINK = 4
|
||||
REFLINK_AUTO = 5
|
||||
|
||||
|
||||
def normpath(path):
|
||||
|
|
@ -197,6 +199,10 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
|
|||
skip = False
|
||||
for pat in ignore:
|
||||
if fnmatch.fnmatch(base, pat):
|
||||
if logger:
|
||||
logger.debug(u'ignoring {0} due to ignore rule {1}'.format(
|
||||
base, pat
|
||||
))
|
||||
skip = True
|
||||
break
|
||||
if skip:
|
||||
|
|
@ -545,6 +551,35 @@ def hardlink(path, dest, replace=False):
|
|||
traceback.format_exc())
|
||||
|
||||
|
||||
def reflink(path, dest, replace=False, fallback=False):
|
||||
"""Create a reflink from `dest` to `path`.
|
||||
|
||||
Raise an `OSError` if `dest` already exists, unless `replace` is
|
||||
True. If `path` == `dest`, then do nothing.
|
||||
|
||||
If reflinking fails and `fallback` is enabled, try copying the file
|
||||
instead. Otherwise, raise an error without trying a plain copy.
|
||||
|
||||
May raise an `ImportError` if the `reflink` module is not available.
|
||||
"""
|
||||
import reflink as pyreflink
|
||||
|
||||
if samefile(path, dest):
|
||||
return
|
||||
|
||||
if os.path.exists(syspath(dest)) and not replace:
|
||||
raise FilesystemError(u'file exists', 'rename', (path, dest))
|
||||
|
||||
try:
|
||||
pyreflink.reflink(path, dest)
|
||||
except (NotImplementedError, pyreflink.ReflinkImpossibleError):
|
||||
if fallback:
|
||||
copy(path, dest, replace)
|
||||
else:
|
||||
raise FilesystemError(u'OS/filesystem does not support reflinks.',
|
||||
'link', (path, dest), traceback.format_exc())
|
||||
|
||||
|
||||
def unique_path(path):
|
||||
"""Returns a version of ``path`` that does not exist on the
|
||||
filesystem. Specifically, if ``path` itself already exists, then
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ def pil_resize(maxwidth, path_in, path_out=None, quality=0):
|
|||
im = Image.open(util.syspath(path_in))
|
||||
size = maxwidth, maxwidth
|
||||
im.thumbnail(size, Image.ANTIALIAS)
|
||||
|
||||
if quality == 0:
|
||||
# Use PIL's default quality.
|
||||
quality = -1
|
||||
|
||||
im.save(util.py3_path(path_out), quality=quality)
|
||||
return path_out
|
||||
except IOError:
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ def submit_items(log, userkey, items, chunksize=64):
|
|||
del data[:]
|
||||
|
||||
for item in items:
|
||||
fp = fingerprint_item(log, item)
|
||||
fp = fingerprint_item(log, item, write=ui.should_write())
|
||||
|
||||
# Construct a submission dictionary for this item.
|
||||
item_data = {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ class ExportPlugin(BeetsPlugin):
|
|||
'sort_keys': True
|
||||
}
|
||||
},
|
||||
'jsonlines': {
|
||||
# JSON Lines formatting options.
|
||||
'formatting': {
|
||||
'ensure_ascii': False,
|
||||
'separators': (',', ': '),
|
||||
'sort_keys': True
|
||||
}
|
||||
},
|
||||
'csv': {
|
||||
# CSV module formatting options.
|
||||
'formatting': {
|
||||
|
|
@ -95,7 +103,7 @@ class ExportPlugin(BeetsPlugin):
|
|||
)
|
||||
cmd.parser.add_option(
|
||||
u'-f', u'--format', default='json',
|
||||
help=u"the output format: json (default), csv, or xml"
|
||||
help=u"the output format: json (default), jsonlines, csv, or xml"
|
||||
)
|
||||
return [cmd]
|
||||
|
||||
|
|
@ -103,6 +111,7 @@ class ExportPlugin(BeetsPlugin):
|
|||
file_path = opts.output
|
||||
file_mode = 'a' if opts.append else 'w'
|
||||
file_format = opts.format or self.config['default_format'].get(str)
|
||||
file_format_is_line_based = (file_format == 'jsonlines')
|
||||
format_options = self.config[file_format]['formatting'].get(dict)
|
||||
|
||||
export_format = ExportFormat.factory(
|
||||
|
|
@ -130,9 +139,14 @@ class ExportPlugin(BeetsPlugin):
|
|||
continue
|
||||
|
||||
data = key_filter(data)
|
||||
items += [data]
|
||||
|
||||
export_format.export(items, **format_options)
|
||||
if file_format_is_line_based:
|
||||
export_format.export(data, **format_options)
|
||||
else:
|
||||
items += [data]
|
||||
|
||||
if not file_format_is_line_based:
|
||||
export_format.export(items, **format_options)
|
||||
|
||||
|
||||
class ExportFormat(object):
|
||||
|
|
@ -147,7 +161,7 @@ class ExportFormat(object):
|
|||
|
||||
@classmethod
|
||||
def factory(cls, file_type, **kwargs):
|
||||
if file_type == "json":
|
||||
if file_type in ["json", "jsonlines"]:
|
||||
return JsonFormat(**kwargs)
|
||||
elif file_type == "csv":
|
||||
return CSVFormat(**kwargs)
|
||||
|
|
@ -167,6 +181,7 @@ class JsonFormat(ExportFormat):
|
|||
|
||||
def export(self, data, **kwargs):
|
||||
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
|
||||
self.out_stream.write('\n')
|
||||
|
||||
|
||||
class CSVFormat(ExportFormat):
|
||||
|
|
|
|||
|
|
@ -308,16 +308,44 @@ class CoverArtArchive(RemoteArtSource):
|
|||
VALID_THUMBNAIL_SIZES = [250, 500, 1200]
|
||||
|
||||
if util.SNI_SUPPORTED:
|
||||
URL = 'https://coverartarchive.org/release/{mbid}/front'
|
||||
GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front'
|
||||
URL = 'https://coverartarchive.org/release/{mbid}'
|
||||
GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}'
|
||||
else:
|
||||
URL = 'http://coverartarchive.org/release/{mbid}/front'
|
||||
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
|
||||
URL = 'http://coverartarchive.org/release/{mbid}'
|
||||
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}'
|
||||
|
||||
def get(self, album, plugin, paths):
|
||||
"""Return the Cover Art Archive and Cover Art Archive release group URLs
|
||||
using album MusicBrainz release ID and release group ID.
|
||||
"""
|
||||
|
||||
def get_image_urls(url, size_suffix=None):
|
||||
try:
|
||||
response = self.request(url)
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'{0}: error receiving response'
|
||||
.format(self.NAME))
|
||||
return
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
self._log.debug(u'{0}: error loading response: {1}'
|
||||
.format(self.NAME, response.text))
|
||||
return
|
||||
|
||||
for item in data.get('images', []):
|
||||
try:
|
||||
if 'Front' not in item['types']:
|
||||
continue
|
||||
|
||||
if size_suffix:
|
||||
yield item['thumbnails'][size_suffix]
|
||||
else:
|
||||
yield item['image']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
release_url = self.URL.format(mbid=album.mb_albumid)
|
||||
release_group_url = self.GROUP_URL.format(mbid=album.mb_releasegroupid)
|
||||
|
||||
|
|
@ -330,19 +358,12 @@ class CoverArtArchive(RemoteArtSource):
|
|||
size_suffix = "-" + str(plugin.maxwidth)
|
||||
|
||||
if 'release' in self.match_by and album.mb_albumid:
|
||||
if size_suffix:
|
||||
release_thumbnail_url = release_url + size_suffix
|
||||
yield self._candidate(url=release_thumbnail_url,
|
||||
match=Candidate.MATCH_EXACT)
|
||||
yield self._candidate(url=release_url,
|
||||
match=Candidate.MATCH_EXACT)
|
||||
for url in get_image_urls(release_url, size_suffix):
|
||||
yield self._candidate(url=url, match=Candidate.MATCH_EXACT)
|
||||
|
||||
if 'releasegroup' in self.match_by and album.mb_releasegroupid:
|
||||
if size_suffix:
|
||||
release_group_thumbnail_url = release_group_url + size_suffix
|
||||
yield self._candidate(url=release_group_thumbnail_url,
|
||||
match=Candidate.MATCH_FALLBACK)
|
||||
yield self._candidate(url=release_group_url,
|
||||
match=Candidate.MATCH_FALLBACK)
|
||||
for url in get_image_urls(release_group_url):
|
||||
yield self._candidate(url=url, match=Candidate.MATCH_FALLBACK)
|
||||
|
||||
|
||||
class Amazon(RemoteArtSource):
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ class FishPlugin(BeetsPlugin):
|
|||
fish_file.write(totstring)
|
||||
|
||||
|
||||
def _escape(name):
|
||||
# Escape ? in fish
|
||||
if name == "?":
|
||||
name = "\\" + name
|
||||
|
||||
|
||||
def get_cmds_list(cmds_names):
|
||||
# Make a list of all Beets core & plugin commands
|
||||
substr = ''
|
||||
|
|
@ -201,6 +207,8 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
|
|||
# Formatting for Fish to complete our fields/values
|
||||
word = ""
|
||||
for cmdname, cmdhelp in cmd_name_and_help:
|
||||
cmdname = _escape(cmdname)
|
||||
|
||||
word += "\n" + "# ------ {} -------".format(
|
||||
"fieldsetups for " + cmdname) + "\n"
|
||||
word += (
|
||||
|
|
@ -232,6 +240,8 @@ def get_all_commands(beetcmds):
|
|||
names = [alias for alias in cmd.aliases]
|
||||
names.append(cmd.name)
|
||||
for name in names:
|
||||
name = _escape(name)
|
||||
|
||||
word += "\n"
|
||||
word += ("\n" * 2) + "# ====== {} =====".format(
|
||||
"completions for " + name) + "\n"
|
||||
|
|
|
|||
|
|
@ -76,7 +76,14 @@ class KeyFinderPlugin(BeetsPlugin):
|
|||
item.path)
|
||||
continue
|
||||
|
||||
key_raw = output.rsplit(None, 1)[-1]
|
||||
try:
|
||||
key_raw = output.rsplit(None, 1)[-1]
|
||||
except IndexError:
|
||||
# Sometimes keyfinder-cli returns 0 but with no key, usually
|
||||
# when the file is silent or corrupt, so we log and skip.
|
||||
self._log.error(u'no key returned for path: {0}', item.path)
|
||||
continue
|
||||
|
||||
try:
|
||||
key = util.text_string(key_raw)
|
||||
except UnicodeDecodeError:
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ except ImportError:
|
|||
|
||||
from beets import plugins
|
||||
from beets import ui
|
||||
from beets import util
|
||||
import beets
|
||||
|
||||
DIV_RE = re.compile(r'<(/?)div>?', re.I)
|
||||
|
|
@ -145,39 +144,6 @@ def extract_text_between(html, start_marker, end_marker):
|
|||
return html
|
||||
|
||||
|
||||
def extract_text_in(html, starttag):
|
||||
"""Extract the text from a <DIV> tag in the HTML starting with
|
||||
``starttag``. Returns None if parsing fails.
|
||||
"""
|
||||
# Strip off the leading text before opening tag.
|
||||
try:
|
||||
_, html = html.split(starttag, 1)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
# Walk through balanced DIV tags.
|
||||
level = 0
|
||||
parts = []
|
||||
pos = 0
|
||||
for match in DIV_RE.finditer(html):
|
||||
if match.group(1): # Closing tag.
|
||||
level -= 1
|
||||
if level == 0:
|
||||
pos = match.end()
|
||||
else: # Opening tag.
|
||||
if level == 0:
|
||||
parts.append(html[pos:match.start()])
|
||||
level += 1
|
||||
|
||||
if level == -1:
|
||||
parts.append(html[pos:match.start()])
|
||||
break
|
||||
else:
|
||||
print(u'no closing tag found!')
|
||||
return
|
||||
return u''.join(parts)
|
||||
|
||||
|
||||
def search_pairs(item):
|
||||
"""Yield a pairs of artists and titles to search for.
|
||||
|
||||
|
|
@ -296,9 +262,9 @@ class Backend(object):
|
|||
raise NotImplementedError()
|
||||
|
||||
|
||||
class SymbolsReplaced(Backend):
|
||||
class MusiXmatch(Backend):
|
||||
REPLACEMENTS = {
|
||||
r'\s+': '_',
|
||||
r'\s+': '-',
|
||||
'<': 'Less_Than',
|
||||
'>': 'Greater_Than',
|
||||
'#': 'Number_',
|
||||
|
|
@ -306,20 +272,14 @@ class SymbolsReplaced(Backend):
|
|||
r'[\]\}]': ')',
|
||||
}
|
||||
|
||||
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
|
||||
|
||||
@classmethod
|
||||
def _encode(cls, s):
|
||||
for old, new in cls.REPLACEMENTS.items():
|
||||
s = re.sub(old, new, s)
|
||||
|
||||
return super(SymbolsReplaced, cls)._encode(s)
|
||||
|
||||
|
||||
class MusiXmatch(SymbolsReplaced):
|
||||
REPLACEMENTS = dict(SymbolsReplaced.REPLACEMENTS, **{
|
||||
r'\s+': '-'
|
||||
})
|
||||
|
||||
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
|
||||
return super(MusiXmatch, cls)._encode(s)
|
||||
|
||||
def fetch(self, artist, title):
|
||||
url = self.build_url(artist, title)
|
||||
|
|
@ -441,30 +401,6 @@ class Genius(Backend):
|
|||
return lyrics_div.get_text()
|
||||
|
||||
|
||||
class LyricsWiki(SymbolsReplaced):
|
||||
"""Fetch lyrics from LyricsWiki."""
|
||||
|
||||
if util.SNI_SUPPORTED:
|
||||
URL_PATTERN = 'https://lyrics.wikia.com/%s:%s'
|
||||
else:
|
||||
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
||||
|
||||
def fetch(self, artist, title):
|
||||
url = self.build_url(artist, title)
|
||||
html = self.fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
|
||||
# Get the HTML fragment inside the appropriate HTML element and then
|
||||
# extract the text from it.
|
||||
html_frag = extract_text_in(html, u"<div class='lyricbox'>")
|
||||
if html_frag:
|
||||
lyrics = _scrape_strip_cruft(html_frag, True)
|
||||
|
||||
if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
|
||||
return lyrics
|
||||
|
||||
|
||||
def remove_credits(text):
|
||||
"""Remove first/last line of text if it contains the word 'lyrics'
|
||||
eg 'Lyrics by songsdatabase.com'
|
||||
|
|
@ -488,6 +424,7 @@ def _scrape_strip_cruft(html, plain_text_out=False):
|
|||
html = re.sub(r' +', ' ', html) # Whitespaces collapse.
|
||||
html = BREAK_RE.sub('\n', html) # <br> eats up surrounding '\n'.
|
||||
html = re.sub(r'(?s)<(script).*?</\1>', '', html) # Strip script tags.
|
||||
html = re.sub(u'\u2005', " ", html) # replace unicode with regular space
|
||||
|
||||
if plain_text_out: # Strip remaining HTML tags
|
||||
html = COMMENT_RE.sub('', html)
|
||||
|
|
@ -656,10 +593,9 @@ class Google(Backend):
|
|||
|
||||
|
||||
class LyricsPlugin(plugins.BeetsPlugin):
|
||||
SOURCES = ['google', 'lyricwiki', 'musixmatch', 'genius']
|
||||
SOURCES = ['google', 'musixmatch', 'genius']
|
||||
SOURCE_BACKENDS = {
|
||||
'google': Google,
|
||||
'lyricwiki': LyricsWiki,
|
||||
'musixmatch': MusiXmatch,
|
||||
'genius': Genius,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,16 +100,24 @@ class SubsonicUpdate(BeetsPlugin):
|
|||
't': token,
|
||||
's': salt,
|
||||
'v': '1.15.0', # Subsonic 6.1 and newer.
|
||||
'c': 'beets'
|
||||
'c': 'beets',
|
||||
'f': 'json'
|
||||
}
|
||||
|
||||
response = requests.post(url, params=payload)
|
||||
try:
|
||||
response = requests.get(url, params=payload)
|
||||
json = response.json()
|
||||
|
||||
if response.status_code == 403:
|
||||
self._log.error(u'Server authentication failed')
|
||||
elif response.status_code == 200:
|
||||
self._log.debug(u'Updating Subsonic')
|
||||
else:
|
||||
self._log.error(
|
||||
u'Generic error, please try again later [Status Code: {}]'
|
||||
.format(response.status_code))
|
||||
if response.status_code == 200 and \
|
||||
json['subsonic-response']['status'] == "ok":
|
||||
count = json['subsonic-response']['scanStatus']['count']
|
||||
self._log.info(
|
||||
u'Updating Subsonic; scanning {0} tracks'.format(count))
|
||||
elif response.status_code == 200 and \
|
||||
json['subsonic-response']['status'] == "failed":
|
||||
error_message = json['subsonic-response']['error']['message']
|
||||
self._log.error(u'Error: {0}'.format(error_message))
|
||||
else:
|
||||
self._log.error(u'Error: {0}', json)
|
||||
except Exception as error:
|
||||
self._log.error(u'Error: {0}'.format(error))
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from beets import ui
|
|||
from beets import util
|
||||
import beets.library
|
||||
import flask
|
||||
from flask import g
|
||||
from flask import g, jsonify
|
||||
from werkzeug.routing import BaseConverter, PathConverter
|
||||
import os
|
||||
from unidecode import unidecode
|
||||
|
|
@ -91,7 +91,20 @@ def is_expand():
|
|||
return flask.request.args.get('expand') is not None
|
||||
|
||||
|
||||
def resource(name):
|
||||
def is_delete():
|
||||
"""Returns whether the current delete request should remove the selected
|
||||
files.
|
||||
"""
|
||||
|
||||
return flask.request.args.get('delete') is not None
|
||||
|
||||
|
||||
def get_method():
|
||||
"""Returns the HTTP method of the current request."""
|
||||
return flask.request.method
|
||||
|
||||
|
||||
def resource(name, patchable=False):
|
||||
"""Decorates a function to handle RESTful HTTP requests for a resource.
|
||||
"""
|
||||
def make_responder(retriever):
|
||||
|
|
@ -99,34 +112,84 @@ def resource(name):
|
|||
entities = [retriever(id) for id in ids]
|
||||
entities = [entity for entity in entities if entity]
|
||||
|
||||
if len(entities) == 1:
|
||||
return flask.jsonify(_rep(entities[0], expand=is_expand()))
|
||||
elif entities:
|
||||
return app.response_class(
|
||||
json_generator(entities, root=name),
|
||||
mimetype='application/json'
|
||||
)
|
||||
if get_method() == "DELETE":
|
||||
for entity in entities:
|
||||
entity.remove(delete=is_delete())
|
||||
|
||||
return flask.make_response(jsonify({'deleted': True}), 200)
|
||||
|
||||
elif get_method() == "PATCH" and patchable:
|
||||
for entity in entities:
|
||||
entity.update(flask.request.get_json())
|
||||
entity.try_sync(True, False) # write, don't move
|
||||
|
||||
if len(entities) == 1:
|
||||
return flask.jsonify(_rep(entities[0], expand=is_expand()))
|
||||
elif entities:
|
||||
return app.response_class(
|
||||
json_generator(entities, root=name),
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
elif get_method() == "GET":
|
||||
if len(entities) == 1:
|
||||
return flask.jsonify(_rep(entities[0], expand=is_expand()))
|
||||
elif entities:
|
||||
return app.response_class(
|
||||
json_generator(entities, root=name),
|
||||
mimetype='application/json'
|
||||
)
|
||||
else:
|
||||
return flask.abort(404)
|
||||
|
||||
else:
|
||||
return flask.abort(404)
|
||||
return flask.abort(405)
|
||||
|
||||
responder.__name__ = 'get_{0}'.format(name)
|
||||
|
||||
return responder
|
||||
return make_responder
|
||||
|
||||
|
||||
def resource_query(name):
|
||||
def resource_query(name, patchable=False):
|
||||
"""Decorates a function to handle RESTful HTTP queries for resources.
|
||||
"""
|
||||
def make_responder(query_func):
|
||||
def responder(queries):
|
||||
return app.response_class(
|
||||
json_generator(
|
||||
query_func(queries),
|
||||
root='results', expand=is_expand()
|
||||
),
|
||||
mimetype='application/json'
|
||||
)
|
||||
entities = query_func(queries)
|
||||
|
||||
if get_method() == "DELETE":
|
||||
for entity in entities:
|
||||
entity.remove(delete=is_delete())
|
||||
|
||||
return flask.make_response(jsonify({'deleted': True}), 200)
|
||||
|
||||
elif get_method() == "PATCH" and patchable:
|
||||
for entity in entities:
|
||||
entity.update(flask.request.get_json())
|
||||
entity.try_sync(True, False) # write, don't move
|
||||
|
||||
return app.response_class(
|
||||
json_generator(entities, root=name),
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
elif get_method() == "GET":
|
||||
return app.response_class(
|
||||
json_generator(
|
||||
entities,
|
||||
root='results', expand=is_expand()
|
||||
),
|
||||
mimetype='application/json'
|
||||
)
|
||||
|
||||
else:
|
||||
return flask.abort(405)
|
||||
|
||||
responder.__name__ = 'query_{0}'.format(name)
|
||||
|
||||
return responder
|
||||
|
||||
return make_responder
|
||||
|
||||
|
||||
|
|
@ -203,8 +266,8 @@ def before_request():
|
|||
|
||||
# Items.
|
||||
|
||||
@app.route('/item/<idlist:ids>')
|
||||
@resource('items')
|
||||
@app.route('/item/<idlist:ids>', methods=["GET", "DELETE", "PATCH"])
|
||||
@resource('items', patchable=True)
|
||||
def get_item(id):
|
||||
return g.lib.get_item(id)
|
||||
|
||||
|
|
@ -250,8 +313,8 @@ def item_file(item_id):
|
|||
return response
|
||||
|
||||
|
||||
@app.route('/item/query/<query:queries>')
|
||||
@resource_query('items')
|
||||
@app.route('/item/query/<query:queries>', methods=["GET", "DELETE", "PATCH"])
|
||||
@resource_query('items', patchable=True)
|
||||
def item_query(queries):
|
||||
return g.lib.items(queries)
|
||||
|
||||
|
|
@ -279,7 +342,7 @@ def item_unique_field_values(key):
|
|||
|
||||
# Albums.
|
||||
|
||||
@app.route('/album/<idlist:ids>')
|
||||
@app.route('/album/<idlist:ids>', methods=["GET", "DELETE"])
|
||||
@resource('albums')
|
||||
def get_album(id):
|
||||
return g.lib.get_album(id)
|
||||
|
|
@ -292,7 +355,7 @@ def all_albums():
|
|||
return g.lib.albums()
|
||||
|
||||
|
||||
@app.route('/album/query/<query:queries>')
|
||||
@app.route('/album/query/<query:queries>', methods=["GET", "DELETE"])
|
||||
@resource_query('albums')
|
||||
def album_query(queries):
|
||||
return g.lib.albums(queries)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@ Changelog
|
|||
|
||||
New features:
|
||||
|
||||
* When config is printed with no available configuration a new message is printed.
|
||||
:bug:`3779`
|
||||
* When importing a duplicate album it ask if it should "Keep all" instead of "Keep both".
|
||||
:bug:`3569`
|
||||
* :doc:`/plugins/chroma`: Update file metadata after generating fingerprints through the `submit` command.
|
||||
* :doc:`/plugins/lastgenre`: Added more heavy metal genres: https://en.wikipedia.org/wiki/Heavy_metal_genres to genres.txt and genres-tree.yaml
|
||||
* :doc:`/plugins/subsonicplaylist`: import playlist from a subsonic server.
|
||||
* A new :ref:`reflink` config option instructs the importer to create fast,
|
||||
copy-on-write file clones on filesystems that support them. Thanks to
|
||||
:user:`rubdos`.
|
||||
* A new :ref:`extra_tags` configuration option allows more tagged metadata
|
||||
to be included in MusicBrainz queries.
|
||||
* A new :doc:`/plugins/fish` adds `Fish shell`_ tab autocompletion to beets
|
||||
|
|
@ -19,12 +27,12 @@ New features:
|
|||
* :doc:`plugins/fetchart`: Added a new ``high_resolution`` config option to
|
||||
allow downloading of higher resolution iTunes artwork (at the expense of
|
||||
file size).
|
||||
:bug: `3391`
|
||||
:bug:`3391`
|
||||
* :doc:`plugins/discogs` now adds two extra fields: `discogs_labelid` and
|
||||
`discogs_artistid`
|
||||
:bug: `3413`
|
||||
:bug:`3413`
|
||||
* :doc:`/plugins/export`: Added new ``-f`` (``--format``) flag;
|
||||
which allows for the ability to export in json, csv and xml.
|
||||
which allows for the ability to export in json, jsonlines, csv and xml.
|
||||
Thanks to :user:`austinmm`.
|
||||
:bug:`3402`
|
||||
* :doc:`/plugins/unimported`: lets you find untracked files in your library directory.
|
||||
|
|
@ -146,12 +154,29 @@ New features:
|
|||
be deleted after importing.
|
||||
Thanks to :user:`logan-arens`.
|
||||
:bug:`2947`
|
||||
* Added flac-specific reporting of samplerate and bitrate when importing duplicates.
|
||||
* :doc:`/plugins/fetchart`: Cover Art Archive source now iterates over
|
||||
all front images instead of blindly selecting the first one.
|
||||
* ``beet remove`` now also allows interactive selection of items from the query
|
||||
similar to ``beet modify``
|
||||
* :doc:`/plugins/web`: add DELETE and PATCH methods for modifying items
|
||||
* :doc:`/plugins/lyrics`: Removed LyricWiki source (shut down on 21/09/2020).
|
||||
* Added a ``--plugins`` (or ``-p``) flag to specify a list of plugins at startup.
|
||||
* Use the musicbrainz genre tag api to get genre information. This currently
|
||||
depends on functionality that is currently unreleased in musicbrainzngs.
|
||||
Once the functionality has been released, you can enable it with the
|
||||
``genres`` option inside the ``musicbrainz`` config. See
|
||||
https://github.com/alastair/python-musicbrainzngs/pull/247 and
|
||||
https://github.com/alastair/python-musicbrainzngs/pull/266 .
|
||||
Thanks to :user:`aereaux`.
|
||||
* :doc:`/plugins/replaygain` now does its analysis in parallel when using
|
||||
the ``command``, ``ffmpeg`` or ``bs1770gain`` backends.
|
||||
:bug:`3478`
|
||||
|
||||
Fixes:
|
||||
|
||||
* :doc:`/plugins/subsonicupdate`: REST was using `POST` method rather `GET` method.
|
||||
Also includes better exception handling, response parsing, and tests.
|
||||
* :doc:`/plugins/the`: Fixed incorrect regex for 'the' that matched any
|
||||
3-letter combination of the letters t, h, e.
|
||||
:bug:`3701`
|
||||
|
|
@ -196,8 +221,10 @@ Fixes:
|
|||
* ``beet update`` will now confirm that the user still wants to update if
|
||||
their library folder cannot be found, preventing the user from accidentally
|
||||
wiping out their beets database.
|
||||
Thanks to :user:`logan-arens`.
|
||||
Thanks to user: `logan-arens`.
|
||||
:bug:`1934`
|
||||
* ``beet import`` now logs which files are ignored when in debug mode.
|
||||
:bug:`3764`
|
||||
* :doc:`/plugins/bpd`: Fix the transition to next track when in consume mode.
|
||||
Thanks to :user:`aereaux`.
|
||||
:bug:`3437`
|
||||
|
|
@ -252,6 +279,14 @@ Fixes:
|
|||
the current track in the queue.
|
||||
Thanks to :user:`aereaux`.
|
||||
:bug:`3722`
|
||||
* String-typed fields are now normalized to string values, avoiding an
|
||||
occasional crash when using both the :doc:`/plugins/fetchart` and the
|
||||
:doc:`/plugins/discogs` together.
|
||||
:bug:`3773` :bug:`3774`
|
||||
* Fix a bug causing PIL to generate poor quality JPEGs when resizing artwork.
|
||||
:bug:`3743`
|
||||
* :doc:`plugins/keyfinder`: Catch output from ``keyfinder-cli`` that is missing key.
|
||||
:bug:`2242`
|
||||
|
||||
For plugin developers:
|
||||
|
||||
|
|
@ -1273,7 +1308,7 @@ And there are a few bug fixes too:
|
|||
The last release, 1.3.19, also erroneously reported its version as "1.3.18"
|
||||
when you typed ``beet version``. This has been corrected.
|
||||
|
||||
.. _six: https://pythonhosted.org/six/
|
||||
.. _six: https://pypi.org/project/six/
|
||||
|
||||
|
||||
1.3.19 (June 25, 2016)
|
||||
|
|
@ -2119,7 +2154,7 @@ As usual, there are loads of little fixes and improvements:
|
|||
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
|
||||
arguments.
|
||||
|
||||
.. _API changes: https://developer.echonest.com/forums/thread/3650
|
||||
.. _API changes: https://web.archive.org/web/20160814092627/https://developer.echonest.com/forums/thread/3650
|
||||
.. _Plex: https://plex.tv/
|
||||
.. _musixmatch: https://www.musixmatch.com/
|
||||
|
||||
|
|
@ -2344,7 +2379,7 @@ The big new features are:
|
|||
* A new :ref:`asciify-paths` configuration option replaces all non-ASCII
|
||||
characters in paths.
|
||||
|
||||
.. _Mutagen: https://bitbucket.org/lazka/mutagen
|
||||
.. _Mutagen: https://github.com/quodlibet/mutagen
|
||||
.. _Spotify: https://www.spotify.com/
|
||||
|
||||
And the multitude of little improvements and fixes:
|
||||
|
|
@ -2599,7 +2634,7 @@ Fixes:
|
|||
* :doc:`/plugins/convert`: Display a useful error message when the FFmpeg
|
||||
executable can't be found.
|
||||
|
||||
.. _requests: https://www.python-requests.org/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
|
||||
|
||||
1.3.3 (February 26, 2014)
|
||||
|
|
@ -2780,7 +2815,7 @@ As usual, there are also innumerable little fixes and improvements:
|
|||
Bezman.
|
||||
|
||||
|
||||
.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html
|
||||
.. _Acoustic Attributes: https://web.archive.org/web/20160701063109/http://developer.echonest.com/acoustic-attributes.html
|
||||
.. _MPD: https://www.musicpd.org/
|
||||
|
||||
|
||||
|
|
@ -3130,7 +3165,7 @@ will automatically migrate your configuration to the new system.
|
|||
header. Thanks to Uwe L. Korn.
|
||||
* :doc:`/plugins/lastgenre`: Fix an error when using genre canonicalization.
|
||||
|
||||
.. _Tomahawk: https://tomahawk-player.org/
|
||||
.. _Tomahawk: https://github.com/tomahawk-player/tomahawk
|
||||
|
||||
1.1b3 (March 16, 2013)
|
||||
----------------------
|
||||
|
|
@ -3473,7 +3508,7 @@ begins today on features for version 1.1.
|
|||
* Changed plugin loading so that modules can be imported without
|
||||
unintentionally loading the plugins they contain.
|
||||
|
||||
.. _The Echo Nest: http://the.echonest.com/
|
||||
.. _The Echo Nest: https://web.archive.org/web/20180329103558/http://the.echonest.com/
|
||||
.. _Tomahawk resolver: https://beets.io/blog/tomahawk-resolver.html
|
||||
.. _mp3gain: http://mp3gain.sourceforge.net/download.php
|
||||
.. _aacgain: https://aacgain.altosdesign.com
|
||||
|
|
@ -3911,7 +3946,7 @@ plugin.
|
|||
|
||||
* The :doc:`/plugins/web` encapsulates a simple **Web-based GUI for beets**. The
|
||||
current iteration can browse the library and play music in browsers that
|
||||
support `HTML5 Audio`_.
|
||||
support HTML5 Audio.
|
||||
|
||||
* When moving items that are part of an album, the album art implicitly moves
|
||||
too.
|
||||
|
|
@ -3928,8 +3963,6 @@ plugin.
|
|||
|
||||
* Fix crash when "copying" an art file that's already in place.
|
||||
|
||||
.. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html
|
||||
|
||||
1.0b9 (July 9, 2011)
|
||||
--------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ extlinks = {
|
|||
'stdlib': ('https://docs.python.org/3/library/%s.html', ''),
|
||||
}
|
||||
|
||||
linkcheck_ignore = [
|
||||
r'https://github.com/beetbox/beets/issues/',
|
||||
r'https://github.com/[^/]+$', # ignore user pages
|
||||
r'.*localhost.*',
|
||||
r'https://www.musixmatch.com/', # blocks requests
|
||||
r'https://genius.com/', # blocks requests
|
||||
]
|
||||
|
||||
# Options for HTML output
|
||||
htmlhelp_basename = 'beetsdoc'
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ in hacking beets itself or creating plugins for it.
|
|||
See also the documentation for `MediaFile`_, the library used by beets to read
|
||||
and write metadata tags in media files.
|
||||
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
|
||||
|
||||
.. toctree::
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ responsible for handling queries to retrieve stored objects.
|
|||
|
||||
.. automethod:: transaction
|
||||
|
||||
.. _SQLite: https://sqlite.org/
|
||||
.. _SQLite: https://sqlite.org/index.html
|
||||
.. _ORM: https://en.wikipedia.org/wiki/Object-relational_mapping
|
||||
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ To make changes to either the database or the tags on a file, you
|
|||
update an item's fields (e.g., ``item.title = "Let It Be"``) and then call
|
||||
``item.write()``.
|
||||
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
|
||||
|
||||
Items also track their modification times (mtimes) to help detect when they
|
||||
become out of sync with on-disk metadata, mainly to speed up the
|
||||
|
|
|
|||
|
|
@ -164,6 +164,10 @@ The events currently available are:
|
|||
created for a file.
|
||||
Parameters: ``item``, ``source`` path, ``destination`` path
|
||||
|
||||
* `item_reflinked`: called with an ``Item`` object whenever a reflink is
|
||||
created for a file.
|
||||
Parameters: ``item``, ``source`` path, ``destination`` path
|
||||
|
||||
* `item_removed`: called with an ``Item`` object every time an item (singleton
|
||||
or album's part) is removed from the library (even when its file is not
|
||||
deleted from disk).
|
||||
|
|
@ -301,7 +305,7 @@ To access this value, say ``self.config['foo'].get()`` at any point in your
|
|||
plugin's code. The `self.config` object is a *view* as defined by the `Confuse`_
|
||||
library.
|
||||
|
||||
.. _Confuse: https://confuse.readthedocs.org/
|
||||
.. _Confuse: https://confuse.readthedocs.io/en/latest/
|
||||
|
||||
If you want to access configuration values *outside* of your plugin's section,
|
||||
import the `config` object from the `beets` module. That is, just put ``from
|
||||
|
|
@ -379,7 +383,7 @@ access to file tags. If you have created a descriptor you can add it through
|
|||
your plugins ``add_media_field()`` method.
|
||||
|
||||
.. automethod:: beets.plugins.BeetsPlugin.add_media_field
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/
|
||||
.. _MediaFile: https://mediafile.readthedocs.io/en/latest/
|
||||
|
||||
|
||||
Here's an example plugin that provides a meaningless new field "foo"::
|
||||
|
|
|
|||
26
docs/faq.rst
26
docs/faq.rst
|
|
@ -2,10 +2,9 @@ FAQ
|
|||
###
|
||||
|
||||
Here are some answers to frequently-asked questions from IRC and elsewhere.
|
||||
Got a question that isn't answered here? Try `IRC`_, the `discussion board`_, or
|
||||
Got a question that isn't answered here? Try the `discussion board`_, or
|
||||
:ref:`filing an issue <bugs>` in the bug tracker.
|
||||
|
||||
.. _IRC: irc://irc.freenode.net/beets
|
||||
.. _mailing list: https://groups.google.com/group/beets-users
|
||||
.. _discussion board: https://discourse.beets.io
|
||||
|
||||
|
|
@ -119,7 +118,7 @@ Run a command like this::
|
|||
|
||||
pip install -U beets
|
||||
|
||||
The ``-U`` flag tells `pip <https://pip.pypa.io/>`__ to upgrade
|
||||
The ``-U`` flag tells `pip`_ to upgrade
|
||||
beets to the latest version. If you want a specific version, you can
|
||||
specify with using ``==`` like so::
|
||||
|
||||
|
|
@ -136,13 +135,13 @@ it's helpful to run on the "bleeding edge". To run the latest source:
|
|||
|
||||
1. Uninstall beets. If you installed using ``pip``, you can just run
|
||||
``pip uninstall beets``.
|
||||
2. Install from source. There are a few easy ways to do this:
|
||||
2. Install from source. Choose one of these methods:
|
||||
|
||||
- Use ``pip`` to install the latest snapshot tarball: just type
|
||||
``pip install https://github.com/beetbox/beets/tarball/master``.
|
||||
- Grab the source using Git:
|
||||
``git clone https://github.com/beetbox/beets.git``. Then
|
||||
``cd beets`` and type ``python setup.py install``.
|
||||
- Use ``pip`` to install the latest snapshot tarball. Type:
|
||||
``pip install https://github.com/beetbox/beets/tarball/master``
|
||||
- Grab the source using git. First, clone the repository:
|
||||
``git clone https://github.com/beetbox/beets.git``.
|
||||
Then, ``cd beets`` and ``python setup.py install``.
|
||||
- Use ``pip`` to install an "editable" version of beets based on an
|
||||
automatic source checkout. For example, run
|
||||
``pip install -e git+https://github.com/beetbox/beets#egg=beets``
|
||||
|
|
@ -188,7 +187,9 @@ there to report a bug. Please follow these guidelines when reporting an issue:
|
|||
|
||||
If you've never reported a bug before, Mozilla has some well-written
|
||||
`general guidelines for good bug
|
||||
reports <https://www.mozilla.org/bugs/>`__.
|
||||
reports`_.
|
||||
|
||||
.. _general guidelines for good bug reports: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Bug_writing_guidelines
|
||||
|
||||
|
||||
.. _find-config:
|
||||
|
|
@ -300,8 +301,7 @@ a flag. There is no simple way to remedy this.)
|
|||
…not change my ID3 tags?
|
||||
------------------------
|
||||
|
||||
Beets writes `ID3v2.4 <http://www.id3.org/id3v2.4.0-structure>`__ tags by
|
||||
default.
|
||||
Beets writes `ID3v2.4`_ tags by default.
|
||||
Some software, including Windows (i.e., Windows Explorer and Windows
|
||||
Media Player) and `id3lib/id3v2 <http://id3v2.sourceforge.net/>`__,
|
||||
don't support v2.4 tags. When using 2.4-unaware software, it might look
|
||||
|
|
@ -311,6 +311,7 @@ To enable ID3v2.3 tags, enable the :ref:`id3v23` config option.
|
|||
|
||||
|
||||
.. _invalid:
|
||||
.. _ID3v2.4: https://id3.org/id3v2.4.0-structure
|
||||
|
||||
…complain that a file is "unreadable"?
|
||||
--------------------------------------
|
||||
|
|
@ -379,3 +380,4 @@ installed using pip, the command ``pip show -f beets`` can show you where
|
|||
try `this Super User answer`_.
|
||||
|
||||
.. _this Super User answer: https://superuser.com/a/284361/4569
|
||||
.. _pip: https://pip.pypa.io/en/stable/
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ beets`` if you run into permissions problems).
|
|||
To install without pip, download beets from `its PyPI page`_ and run ``python
|
||||
setup.py install`` in the directory therein.
|
||||
|
||||
.. _its PyPI page: https://pypi.org/project/beets#downloads
|
||||
.. _its PyPI page: https://pypi.org/project/beets/#files
|
||||
.. _pip: https://pip.pypa.io
|
||||
|
||||
The best way to upgrade beets to a new version is by running ``pip install -U
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ If beets finds an album or item in your library that seems to be the same as the
|
|||
one you're importing, you may see a prompt like this::
|
||||
|
||||
This album is already in the library!
|
||||
[S]kip new, Keep both, Remove old, Merge all?
|
||||
[S]kip new, Keep all, Remove old, Merge all?
|
||||
|
||||
Beets wants to keep you safe from duplicates, which can be a real pain, so you
|
||||
have four choices in this situation. You can skip importing the new music,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,6 @@ file. The available options are:
|
|||
.. _streaming_extractor_music: https://acousticbrainz.org/download
|
||||
.. _FAQ: https://acousticbrainz.org/faq
|
||||
.. _pip: https://pip.pypa.io
|
||||
.. _requests: https://docs.python-requests.org/en/master/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
.. _github: https://github.com/MTG/essentia
|
||||
.. _AcousticBrainz: https://acousticbrainz.org
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ Configuration
|
|||
|
||||
This plugin can be configured like other metadata source plugins as described in :ref:`metadata-source-plugin-configuration`.
|
||||
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
.. _requests_oauthlib: https://github.com/requests/requests-oauthlib
|
||||
.. _Beatport: https://beetport.com
|
||||
.. _Beatport: https://www.beatport.com/
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ BPD is a music player using music from a beets library. It runs as a daemon and
|
|||
implements the MPD protocol, so it's compatible with all the great MPD clients
|
||||
out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
|
||||
|
||||
.. _Theremin: https://theremin.sigterm.eu/
|
||||
.. _Theremin: https://github.com/TheStalwart/Theremin
|
||||
.. _gmpc: https://gmpc.wikia.com/wiki/Gnome_Music_Player_Client
|
||||
.. _Sonata: http://sonata.berlios.de/
|
||||
.. _Ario: http://ario-player.sourceforge.net/
|
||||
|
|
@ -13,7 +13,7 @@ out there. I'm using `Theremin`_, `gmpc`_, `Sonata`_, and `Ario`_ successfully.
|
|||
Dependencies
|
||||
------------
|
||||
|
||||
Before you can use BPD, you'll need the media library called GStreamer (along
|
||||
Before you can use BPD, you'll need the media library called `GStreamer`_ (along
|
||||
with its Python bindings) on your system.
|
||||
|
||||
* On Mac OS X, you can use `Homebrew`_. Run ``brew install gstreamer
|
||||
|
|
@ -22,14 +22,11 @@ with its Python bindings) on your system.
|
|||
* On Linux, you need to install GStreamer 1.0 and the GObject bindings for
|
||||
python. Under Ubuntu, they are called ``python-gi`` and ``gstreamer1.0``.
|
||||
|
||||
* On Windows, you may want to try `GStreamer WinBuilds`_ (caveat emptor: I
|
||||
haven't tried this).
|
||||
|
||||
You will also need the various GStreamer plugin packages to make everything
|
||||
work. See the :doc:`/plugins/chroma` documentation for more information on
|
||||
installing GStreamer plugins.
|
||||
|
||||
.. _GStreamer WinBuilds: https://www.gstreamer-winbuild.ylatuya.es/
|
||||
.. _GStreamer: https://gstreamer.freedesktop.org/download
|
||||
.. _Homebrew: https://brew.sh
|
||||
|
||||
Usage
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ You will also need a mechanism for decoding audio files supported by the
|
|||
.. _audioread: https://github.com/beetbox/audioread
|
||||
.. _pyacoustid: https://github.com/beetbox/pyacoustid
|
||||
.. _FFmpeg: https://ffmpeg.org/
|
||||
.. _MAD: https://spacepants.org/src/pymad/
|
||||
.. _pymad: https://www.underbit.com/products/mad/
|
||||
.. _pymad: https://spacepants.org/src/pymad/
|
||||
.. _MAD: https://www.underbit.com/products/mad/
|
||||
.. _Core Audio: https://developer.apple.com/technologies/mac/audio-and-video.html
|
||||
.. _Gstreamer: https://gstreamer.freedesktop.org/
|
||||
.. _PyGObject: https://wiki.gnome.org/Projects/PyGObject
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ can use the :doc:`/plugins/replaygain` to do this analysis. See the LAME
|
|||
`documentation`_ and the `HydrogenAudio wiki`_ for other LAME configuration
|
||||
options and a thorough discussion of MP3 encoding.
|
||||
|
||||
.. _documentation: http://lame.sourceforge.net/using.php
|
||||
.. _documentation: https://lame.sourceforge.io/index.php
|
||||
.. _HydrogenAudio wiki: https://wiki.hydrogenaud.io/index.php?title=LAME
|
||||
.. _gapless: https://wiki.hydrogenaud.io/index.php?title=Gapless_playback
|
||||
.. _LAME: https://lame.sourceforge.net/
|
||||
.. _LAME: https://lame.sourceforge.io/index.php
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ To use the ``embyupdate`` plugin you need to install the `requests`_ library wit
|
|||
With that all in place, you'll see beets send the "update" command to your Emby server every time you change your beets library.
|
||||
|
||||
.. _Emby: https://emby.media/
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
|
|
|||
|
|
@ -39,14 +39,15 @@ The ``export`` command has these command-line options:
|
|||
|
||||
* ``--append``: Appends the data to the file instead of writing.
|
||||
|
||||
* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json and xml.
|
||||
* ``--format`` or ``-f``: Specifies the format the data will be exported as. If not informed, JSON will be used by default. The format options include csv, json, `jsonlines <https://jsonlines.org/>`_ and xml.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
To configure the plugin, make a ``export:`` section in your configuration
|
||||
file.
|
||||
For JSON export, these options are available under the ``json`` key:
|
||||
For JSON export, these options are available under the ``json`` and
|
||||
``jsonlines`` keys:
|
||||
|
||||
- **ensure_ascii**: Escape non-ASCII characters with ``\uXXXX`` entities.
|
||||
- **indent**: The number of spaces for indentation.
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ Here are a few of the plugins written by the beets community:
|
|||
|
||||
* `beet-amazon`_ adds Amazon.com as a tagger data source.
|
||||
|
||||
* `copyartifacts`_ helps bring non-music files along during import.
|
||||
* `beets-copyartifacts`_ helps bring non-music files along during import.
|
||||
|
||||
* `beets-check`_ automatically checksums your files to detect corruption.
|
||||
|
||||
|
|
@ -283,6 +283,8 @@ Here are a few of the plugins written by the beets community:
|
|||
|
||||
* `beets-follow`_ lets you check for new albums from artists you like.
|
||||
|
||||
* `beets-ibroadcast`_ uploads tracks to the `iBroadcast`_ cloud service.
|
||||
|
||||
* `beets-setlister`_ generate playlists from the setlists of a given artist.
|
||||
|
||||
* `beets-noimport`_ adds and removes directories from the incremental import skip list.
|
||||
|
|
@ -324,7 +326,7 @@ Here are a few of the plugins written by the beets community:
|
|||
|
||||
.. _beets-barcode: https://github.com/8h2a/beets-barcode
|
||||
.. _beets-check: https://github.com/geigerzaehler/beets-check
|
||||
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
|
||||
.. _beets-copyartifacts: https://github.com/adammillerio/beets-copyartifacts
|
||||
.. _dsedivec: https://github.com/dsedivec/beets-plugins
|
||||
.. _beets-artistcountry: https://github.com/agrausem/beets-artistcountry
|
||||
.. _beetFs: https://github.com/jbaiter/beetfs
|
||||
|
|
@ -336,6 +338,8 @@ Here are a few of the plugins written by the beets community:
|
|||
.. _beet-amazon: https://github.com/jmwatte/beet-amazon
|
||||
.. _beets-alternatives: https://github.com/geigerzaehler/beets-alternatives
|
||||
.. _beets-follow: https://github.com/nolsto/beets-follow
|
||||
.. _beets-ibroadcast: https://github.com/ctrueden/beets-ibroadcast
|
||||
.. _iBroadcast: https://ibroadcast.com/
|
||||
.. _beets-setlister: https://github.com/tomjaspers/beets-setlister
|
||||
.. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport
|
||||
.. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ configuration file. The available options are:
|
|||
`initial_key` value.
|
||||
Default: ``no``.
|
||||
|
||||
.. _KeyFinder: https://www.ibrahimshaath.co.uk/keyfinder/
|
||||
.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/
|
||||
.. _keyfinder-cli: https://github.com/EvanPurkhiser/keyfinder-cli/
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ With that all in place, you'll see beets send the "update" command to your Kodi
|
|||
host every time you change your beets library.
|
||||
|
||||
.. _Kodi: https://kodi.tv/
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
LastGenre Plugin
|
||||
================
|
||||
|
||||
The MusicBrainz database `does not contain genre information`_. Therefore, when
|
||||
importing and autotagging music, beets does not assign a genre. The
|
||||
``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres
|
||||
|
||||
The ``lastgenre`` plugin fetches *tags* from `Last.fm`_ and assigns them as genres
|
||||
to your albums and items.
|
||||
|
||||
.. _does not contain genre information:
|
||||
https://musicbrainz.org/doc/General_FAQ#Why_does_MusicBrainz_not_support_genre_information.3F
|
||||
.. _Last.fm: https://last.fm/
|
||||
|
||||
Installation
|
||||
|
|
@ -72,7 +69,7 @@ nothing would ever be matched to a more generic node since all the specific
|
|||
subgenres are in the whitelist to begin with.
|
||||
|
||||
|
||||
.. _YAML: https://www.yaml.org/
|
||||
.. _YAML: https://yaml.org/
|
||||
.. _tree of nested genre names: https://raw.githubusercontent.com/beetbox/beets/master/beetsplug/lastgenre/genres-tree.yaml
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ Lyrics Plugin
|
|||
=============
|
||||
|
||||
The ``lyrics`` plugin fetches and stores song lyrics from databases on the Web.
|
||||
Namely, the current version of the plugin uses `Lyric Wiki`_,
|
||||
`Musixmatch`_, `Genius.com`_, and, optionally, the Google custom search API.
|
||||
Namely, the current version of the plugin uses `Musixmatch`_, `Genius.com`_,
|
||||
and, optionally, the Google custom search API.
|
||||
|
||||
.. _Lyric Wiki: https://lyrics.wikia.com/
|
||||
.. _Musixmatch: https://www.musixmatch.com/
|
||||
.. _Genius.com: https://genius.com/
|
||||
|
||||
|
|
@ -26,7 +25,7 @@ already have them. The lyrics will be stored in the beets database. If the
|
|||
``import.write`` config option is on, then the lyrics will also be written to
|
||||
the files' tags.
|
||||
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
|
||||
|
||||
Configuration
|
||||
|
|
@ -180,8 +179,7 @@ You also need to register for a Microsoft Azure Marketplace free account and
|
|||
to the `Microsoft Translator API`_. Follow the four steps process, specifically
|
||||
at step 3 enter ``beets`` as *Client ID* and copy/paste the generated
|
||||
*Client secret* into your ``bing_client_secret`` configuration, alongside
|
||||
``bing_lang_to`` target `language code`_.
|
||||
``bing_lang_to`` target `language code`.
|
||||
|
||||
.. _langdetect: https://pypi.python.org/pypi/langdetect
|
||||
.. _Microsoft Translator API: https://www.microsoft.com/en-us/translator/getstarted.aspx
|
||||
.. _language code: https://msdn.microsoft.com/en-us/library/hh456380.aspx
|
||||
.. _Microsoft Translator API: https://docs.microsoft.com/en-us/azure/cognitive-services/translator/translator-how-to-signup
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ With that all in place, you'll see beets send the "update" command to your Plex
|
|||
server every time you change your beets library.
|
||||
|
||||
.. _Plex: https://plex.tv/
|
||||
.. _requests: https://docs.python-requests.org/en/latest/
|
||||
.. _requests: https://requests.readthedocs.io/en/master/
|
||||
.. _documentation about tokens: https://support.plex.tv/hc/en-us/articles/204059436-Finding-your-account-token-X-Plex-Token
|
||||
|
||||
Configuration
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SubsonicUpdate Plugin
|
|||
``subsonicupdate`` is a very simple plugin for beets that lets you automatically
|
||||
update `Subsonic`_'s index whenever you change your beets library.
|
||||
|
||||
.. _Subsonic: https://www.subsonic.org
|
||||
.. _Subsonic: http://www.subsonic.org/pages/index.jsp
|
||||
|
||||
To use ``subsonicupdate`` plugin, enable it in your configuration
|
||||
(see :ref:`using-plugins`).
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ The Web interface depends on `Flask`_. To get it, just run ``pip install
|
|||
flask``. Then enable the ``web`` plugin in your configuration (see
|
||||
:ref:`using-plugins`).
|
||||
|
||||
.. _Flask: https://flask.pocoo.org/
|
||||
|
||||
If you need CORS (it's disabled by default---see :ref:`web-cors`, below), then
|
||||
you also need `flask-cors`_. Just type ``pip install flask-cors``.
|
||||
|
||||
|
|
@ -47,9 +45,7 @@ Usage
|
|||
-----
|
||||
|
||||
Type queries into the little search box. Double-click a track to play it with
|
||||
`HTML5 Audio`_.
|
||||
|
||||
.. _HTML5 Audio: http://www.w3.org/TR/html-markup/audio.html
|
||||
HTML5 Audio.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
|
@ -78,7 +74,7 @@ The Web backend is built using a simple REST+JSON API with the excellent
|
|||
`Flask`_ library. The frontend is a single-page application written with
|
||||
`Backbone.js`_. This allows future non-Web clients to use the same backend API.
|
||||
|
||||
.. _Flask: https://flask.pocoo.org/
|
||||
|
||||
.. _Backbone.js: https://backbonejs.org
|
||||
|
||||
Eventually, to make the Web player really viable, we should use a Flash fallback
|
||||
|
|
@ -90,7 +86,7 @@ for unsupported formats/browsers. There are a number of options for this:
|
|||
|
||||
.. _audio.js: https://kolber.github.io/audiojs/
|
||||
.. _html5media: https://html5media.info/
|
||||
.. _MediaElement.js: https://mediaelementjs.com/
|
||||
.. _MediaElement.js: https://www.mediaelementjs.com/
|
||||
|
||||
.. _web-cors:
|
||||
|
||||
|
|
@ -187,6 +183,25 @@ representation. ::
|
|||
If there is no item with that id responds with a *404* status
|
||||
code.
|
||||
|
||||
``DELETE /item/6``
|
||||
++++++++++++++++++
|
||||
|
||||
Removes the item with id *6* from the beets library. If the *?delete* query string is included,
|
||||
the matching file will be deleted from disk.
|
||||
|
||||
``PATCH /item/6``
|
||||
++++++++++++++++++
|
||||
|
||||
Updates the item with id *6* and write the changes to the music file. The body should be a JSON object
|
||||
containing the changes to the object.
|
||||
|
||||
Returns the updated JSON representation. ::
|
||||
|
||||
{
|
||||
"id": 6,
|
||||
"title": "A Song",
|
||||
...
|
||||
}
|
||||
|
||||
``GET /item/6,12,13``
|
||||
+++++++++++++++++++++
|
||||
|
|
@ -196,6 +211,8 @@ the response is the same as for `GET /item/`_. It is *not guaranteed* that the
|
|||
response includes all the items requested. If a track is not found it is silently
|
||||
dropped from the response.
|
||||
|
||||
This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all
|
||||
items of the list.
|
||||
|
||||
``GET /item/path/...``
|
||||
++++++++++++++++++++++
|
||||
|
|
@ -225,6 +242,8 @@ Path elements are joined as parts of a query. For example,
|
|||
To specify literal path separators in a query, use a backslash instead of a
|
||||
slash.
|
||||
|
||||
This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all
|
||||
items returned by the query.
|
||||
|
||||
``GET /item/6/file``
|
||||
++++++++++++++++++++
|
||||
|
|
@ -242,10 +261,16 @@ For albums, the following endpoints are provided:
|
|||
|
||||
* ``GET /album/5``
|
||||
|
||||
* ``DELETE /album/5``
|
||||
|
||||
* ``GET /album/5,7``
|
||||
|
||||
* ``DELETE /album/5,7``
|
||||
|
||||
* ``GET /album/query/querystring``
|
||||
|
||||
* ``DELETE /album/query/querystring``
|
||||
|
||||
The interface and response format is similar to the item API, except replacing
|
||||
the encapsulation key ``"items"`` with ``"albums"`` when requesting ``/album/``
|
||||
or ``/album/5,7``. In addition we can request the cover art of an album with
|
||||
|
|
@ -262,3 +287,5 @@ Responds with the number of tracks and albums in the database. ::
|
|||
"items": 5,
|
||||
"albums": 3
|
||||
}
|
||||
|
||||
.. _Flask: https://flask.palletsprojects.com/en/1.1.x/
|
||||
|
|
|
|||
|
|
@ -230,10 +230,21 @@ remove
|
|||
Remove music from your library.
|
||||
|
||||
This command uses the same :doc:`query <query>` syntax as the ``list`` command.
|
||||
You'll be shown a list of the files that will be removed and asked to confirm.
|
||||
By default, this just removes entries from the library database; it doesn't
|
||||
touch the files on disk. To actually delete the files, use ``beet remove -d``.
|
||||
If you do not want to be prompted to remove the files, use ``beet remove -f``.
|
||||
By default, it just removes entries from the library database; it doesn't
|
||||
touch the files on disk. To actually delete the files, use the ``-d`` flag.
|
||||
When the ``-a`` flag is given, the command operates on albums instead of
|
||||
individual tracks.
|
||||
|
||||
When you run the ``remove`` command, it prints a list of all
|
||||
affected items in the library and asks for your permission before removing
|
||||
them. You can then choose to abort (type `n`), confirm (`y`), or interactively
|
||||
choose some of the items (`s`). In the latter case, the command will prompt you
|
||||
for every matching item or album and invite you to type `y` to remove the
|
||||
item/album, `n` to keep it or `q` to exit and only remove the items/albums
|
||||
selected up to this point.
|
||||
This option lets you choose precisely which tracks/albums to remove without
|
||||
spending too much time to carefully craft a query.
|
||||
If you do not want to be prompted at all, use the ``-f`` option.
|
||||
|
||||
.. _modify-cmd:
|
||||
|
||||
|
|
@ -429,6 +440,10 @@ import ...``.
|
|||
configuration options entirely, the two are merged. Any individual options set
|
||||
in this config file will override the corresponding settings in your base
|
||||
configuration.
|
||||
* ``-p plugins``: specify a comma-separated list of plugins to enable. If
|
||||
specified, the plugin list in your configuration is ignored. The long form
|
||||
of this argument also allows specifying no plugins, effectively disabling
|
||||
all plugins: ``--plugins=``.
|
||||
|
||||
Beets also uses the ``BEETSDIR`` environment variable to look for
|
||||
configuration and data.
|
||||
|
|
|
|||
|
|
@ -356,7 +356,6 @@ Sets the albumartist for various-artist compilations. Defaults to ``'Various
|
|||
Artists'`` (the MusicBrainz standard). Affects other sources, such as
|
||||
:doc:`/plugins/discogs`, too.
|
||||
|
||||
|
||||
UI Options
|
||||
----------
|
||||
|
||||
|
|
@ -476,13 +475,35 @@ hardlink
|
|||
~~~~~~~~
|
||||
|
||||
Either ``yes`` or ``no``, indicating whether to use hard links instead of
|
||||
moving or copying or symlinking files. (It conflicts with the ``move``,
|
||||
moving, copying, or symlinking files. (It conflicts with the ``move``,
|
||||
``copy``, and ``link`` options.) Defaults to ``no``.
|
||||
|
||||
As with symbolic links (see :ref:`link`, above), this will not work on Windows
|
||||
and you will want to set ``write`` to ``no``. Otherwise, metadata on the
|
||||
original file will be modified.
|
||||
|
||||
.. _reflink:
|
||||
|
||||
reflink
|
||||
~~~~~~~
|
||||
|
||||
Either ``yes``, ``no``, or ``auto``, indicating whether to use copy-on-write
|
||||
`file clones`_ (a.k.a. "reflinks") instead of copying or moving files.
|
||||
The ``auto`` option uses reflinks when possible and falls back to plain
|
||||
copying when necessary.
|
||||
Defaults to ``no``.
|
||||
|
||||
This kind of clone is only available on certain filesystems: for example,
|
||||
btrfs and APFS. For more details on filesystem support, see the `pyreflink`_
|
||||
documentation. Note that you need to install ``pyreflink``, either through
|
||||
``python -m pip install beets[reflink]`` or ``python -m pip install reflink``.
|
||||
|
||||
The option is ignored if ``move`` is enabled (i.e., beets can move or
|
||||
copy files but it doesn't make sense to do both).
|
||||
|
||||
.. _file clones: https://blogs.oracle.com/otn/save-disk-space-on-linux-by-cloning-files-on-btrfs-and-ocfs2
|
||||
.. _pyreflink: https://reflink.readthedocs.io/en/latest/
|
||||
|
||||
resume
|
||||
~~~~~~
|
||||
|
||||
|
|
@ -689,7 +710,7 @@ to one request per second.
|
|||
.. _your own MusicBrainz database: https://musicbrainz.org/doc/MusicBrainz_Server/Setup
|
||||
.. _main server: https://musicbrainz.org/
|
||||
.. _limited: https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting
|
||||
.. _Building search indexes: https://musicbrainz.org/doc/MusicBrainz_Server/Setup#Building_search_indexes
|
||||
.. _Building search indexes: https://musicbrainz.org/doc/Development/Search_server_setup
|
||||
|
||||
.. _searchlimit:
|
||||
|
||||
|
|
@ -721,6 +742,17 @@ above example.
|
|||
|
||||
Default: ``[]``
|
||||
|
||||
.. _genres:
|
||||
|
||||
genres
|
||||
~~~~~~
|
||||
|
||||
Use MusicBrainz genre tags to populate the ``genre`` tag. This will make it a
|
||||
semicolon-separated list of all the genres tagged for the release on
|
||||
MusicBrainz.
|
||||
|
||||
Default: ``no``
|
||||
|
||||
.. _match-config:
|
||||
|
||||
Autotagger Matching Options
|
||||
|
|
|
|||
2
setup.py
2
setup.py
|
|
@ -122,6 +122,7 @@ setup(
|
|||
'pyxdg',
|
||||
'responses>=0.3.0',
|
||||
'requests_oauthlib',
|
||||
'reflink',
|
||||
] + (
|
||||
# Tests for the thumbnails plugin need pathlib on Python 2 too.
|
||||
['pathlib'] if (sys.version_info < (3, 4, 0)) else []
|
||||
|
|
@ -163,6 +164,7 @@ setup(
|
|||
'scrub': ['mutagen>=1.33'],
|
||||
'bpd': ['PyGObject'],
|
||||
'replaygain': ['PyGObject'],
|
||||
'reflink': ['reflink'],
|
||||
},
|
||||
# Non-Python/non-PyPI plugin dependencies:
|
||||
# chroma: chromaprint or fpcalc
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import six
|
|||
import unittest
|
||||
from contextlib import contextmanager
|
||||
|
||||
import reflink
|
||||
|
||||
|
||||
# Mangle the search path to include the beets sources.
|
||||
sys.path.insert(0, '..')
|
||||
|
|
@ -55,6 +57,7 @@ _item_ident = 0
|
|||
# OS feature test.
|
||||
HAVE_SYMLINK = sys.platform != 'win32'
|
||||
HAVE_HARDLINK = sys.platform != 'win32'
|
||||
HAVE_REFLINK = reflink.supported_at(tempfile.gettempdir())
|
||||
|
||||
|
||||
def item(lib=None):
|
||||
|
|
|
|||
136
test/test_art.py
136
test/test_art.py
|
|
@ -76,6 +76,96 @@ class FetchImageHelper(_common.TestCase):
|
|||
file_type, b'').ljust(32, b'\x00'))
|
||||
|
||||
|
||||
class CAAHelper():
|
||||
"""Helper mixin for mocking requests to the Cover Art Archive."""
|
||||
MBID_RELASE = 'rid'
|
||||
MBID_GROUP = 'rgid'
|
||||
|
||||
RELEASE_URL = 'coverartarchive.org/release/{0}' \
|
||||
.format(MBID_RELASE)
|
||||
GROUP_URL = 'coverartarchive.org/release-group/{0}' \
|
||||
.format(MBID_GROUP)
|
||||
|
||||
if util.SNI_SUPPORTED:
|
||||
RELEASE_URL = "https://" + RELEASE_URL
|
||||
GROUP_URL = "https://" + GROUP_URL
|
||||
else:
|
||||
RELEASE_URL = "http://" + RELEASE_URL
|
||||
GROUP_URL = "http://" + GROUP_URL
|
||||
|
||||
RESPONSE_RELEASE = """{
|
||||
"images": [
|
||||
{
|
||||
"approved": false,
|
||||
"back": false,
|
||||
"comment": "GIF",
|
||||
"edit": 12345,
|
||||
"front": true,
|
||||
"id": 12345,
|
||||
"image": "http://coverartarchive.org/release/rid/12345.gif",
|
||||
"thumbnails": {
|
||||
"1200": "http://coverartarchive.org/release/rid/12345-1200.jpg",
|
||||
"250": "http://coverartarchive.org/release/rid/12345-250.jpg",
|
||||
"500": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
||||
"large": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
||||
"small": "http://coverartarchive.org/release/rid/12345-250.jpg"
|
||||
},
|
||||
"types": [
|
||||
"Front"
|
||||
]
|
||||
},
|
||||
{
|
||||
"approved": false,
|
||||
"back": false,
|
||||
"comment": "",
|
||||
"edit": 12345,
|
||||
"front": false,
|
||||
"id": 12345,
|
||||
"image": "http://coverartarchive.org/release/rid/12345.jpg",
|
||||
"thumbnails": {
|
||||
"1200": "http://coverartarchive.org/release/rid/12345-1200.jpg",
|
||||
"250": "http://coverartarchive.org/release/rid/12345-250.jpg",
|
||||
"500": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
||||
"large": "http://coverartarchive.org/release/rid/12345-500.jpg",
|
||||
"small": "http://coverartarchive.org/release/rid/12345-250.jpg"
|
||||
},
|
||||
"types": [
|
||||
"Front"
|
||||
]
|
||||
}
|
||||
],
|
||||
"release": "https://musicbrainz.org/release/releaseid"
|
||||
}"""
|
||||
RESPONSE_GROUP = """{
|
||||
"images": [
|
||||
{
|
||||
"approved": false,
|
||||
"back": false,
|
||||
"comment": "",
|
||||
"edit": 12345,
|
||||
"front": true,
|
||||
"id": 12345,
|
||||
"image": "http://coverartarchive.org/release/releaseid/12345.jpg",
|
||||
"thumbnails": {
|
||||
"1200": "http://coverartarchive.org/release/rgid/12345-1200.jpg",
|
||||
"250": "http://coverartarchive.org/release/rgid/12345-250.jpg",
|
||||
"500": "http://coverartarchive.org/release/rgid/12345-500.jpg",
|
||||
"large": "http://coverartarchive.org/release/rgid/12345-500.jpg",
|
||||
"small": "http://coverartarchive.org/release/rgid/12345-250.jpg"
|
||||
},
|
||||
"types": [
|
||||
"Front"
|
||||
]
|
||||
}
|
||||
],
|
||||
"release": "https://musicbrainz.org/release/release-id"
|
||||
}"""
|
||||
|
||||
def mock_caa_response(self, url, json):
|
||||
responses.add(responses.GET, url, body=json,
|
||||
content_type='application/json')
|
||||
|
||||
|
||||
class FetchImageTest(FetchImageHelper, UseThePlugin):
|
||||
URL = 'http://example.com/test.jpg'
|
||||
|
||||
|
|
@ -156,15 +246,13 @@ class FSArtTest(UseThePlugin):
|
|||
self.assertEqual(candidates, paths)
|
||||
|
||||
|
||||
class CombinedTest(FetchImageHelper, UseThePlugin):
|
||||
class CombinedTest(FetchImageHelper, UseThePlugin, CAAHelper):
|
||||
ASIN = 'xxxx'
|
||||
MBID = 'releaseid'
|
||||
AMAZON_URL = 'https://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \
|
||||
.format(ASIN)
|
||||
AAO_URL = 'https://www.albumart.org/index_detail.php?asin={0}' \
|
||||
.format(ASIN)
|
||||
CAA_URL = 'coverartarchive.org/release/{0}/front' \
|
||||
.format(MBID)
|
||||
|
||||
def setUp(self):
|
||||
super(CombinedTest, self).setUp()
|
||||
|
|
@ -211,17 +299,19 @@ class CombinedTest(FetchImageHelper, UseThePlugin):
|
|||
self.assertEqual(responses.calls[-1].request.url, self.AAO_URL)
|
||||
|
||||
def test_main_interface_uses_caa_when_mbid_available(self):
|
||||
self.mock_response("http://" + self.CAA_URL)
|
||||
self.mock_response("https://" + self.CAA_URL)
|
||||
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
|
||||
self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
|
||||
self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
|
||||
self.mock_response('http://coverartarchive.org/release/rid/12345.gif',
|
||||
content_type='image/gif')
|
||||
self.mock_response('http://coverartarchive.org/release/rid/12345.jpg',
|
||||
content_type='image/jpeg')
|
||||
album = _common.Bag(mb_albumid=self.MBID_RELASE,
|
||||
mb_releasegroupid=self.MBID_GROUP,
|
||||
asin=self.ASIN)
|
||||
candidate = self.plugin.art_for_album(album, None)
|
||||
self.assertIsNotNone(candidate)
|
||||
self.assertEqual(len(responses.calls), 1)
|
||||
if util.SNI_SUPPORTED:
|
||||
url = "https://" + self.CAA_URL
|
||||
else:
|
||||
url = "http://" + self.CAA_URL
|
||||
self.assertEqual(responses.calls[0].request.url, url)
|
||||
self.assertEqual(len(responses.calls), 3)
|
||||
self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL)
|
||||
|
||||
def test_local_only_does_not_access_network(self):
|
||||
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
|
||||
|
|
@ -416,6 +506,28 @@ class GoogleImageTest(UseThePlugin):
|
|||
next(self.source.get(album, self.settings, []))
|
||||
|
||||
|
||||
class CoverArtArchiveTest(UseThePlugin, CAAHelper):
|
||||
|
||||
def setUp(self):
|
||||
super(CoverArtArchiveTest, self).setUp()
|
||||
self.source = fetchart.CoverArtArchive(logger, self.plugin.config)
|
||||
self.settings = Settings(maxwidth=0)
|
||||
|
||||
@responses.activate
|
||||
def run(self, *args, **kwargs):
|
||||
super(CoverArtArchiveTest, self).run(*args, **kwargs)
|
||||
|
||||
def test_caa_finds_image(self):
|
||||
album = _common.Bag(mb_albumid=self.MBID_RELASE,
|
||||
mb_releasegroupid=self.MBID_GROUP)
|
||||
self.mock_caa_response(self.RELEASE_URL, self.RESPONSE_RELEASE)
|
||||
self.mock_caa_response(self.GROUP_URL, self.RESPONSE_GROUP)
|
||||
candidates = list(self.source.get(album, self.settings, []))
|
||||
self.assertEqual(len(candidates), 3)
|
||||
self.assertEqual(len(responses.calls), 2)
|
||||
self.assertEqual(responses.calls[0].request.url, self.RELEASE_URL)
|
||||
|
||||
|
||||
class FanartTVTest(UseThePlugin):
|
||||
RESPONSE_MULTIPLE = u"""{
|
||||
"name": "artistname",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,17 @@ class ExportPluginTest(unittest.TestCase, TestHelper):
|
|||
self.assertTrue(key in json_data)
|
||||
self.assertEqual(val, json_data[key])
|
||||
|
||||
def test_jsonlines_output(self):
|
||||
item1 = self.create_item()
|
||||
out = self.execute_command(
|
||||
format_type='jsonlines',
|
||||
artist=item1.artist
|
||||
)
|
||||
json_data = json.loads(out)
|
||||
for key, val in self.test_values.items():
|
||||
self.assertTrue(key in json_data)
|
||||
self.assertEqual(val, json_data[key])
|
||||
|
||||
def test_csv_output(self):
|
||||
item1 = self.create_item()
|
||||
out = self.execute_command(
|
||||
|
|
|
|||
|
|
@ -86,6 +86,24 @@ class MoveTest(_common.TestCase):
|
|||
self.i.move(operation=MoveOperation.COPY)
|
||||
self.assertExists(self.path)
|
||||
|
||||
def test_reflink_arrives(self):
|
||||
self.i.move(operation=MoveOperation.REFLINK_AUTO)
|
||||
self.assertExists(self.dest)
|
||||
|
||||
def test_reflink_does_not_depart(self):
|
||||
self.i.move(operation=MoveOperation.REFLINK_AUTO)
|
||||
self.assertExists(self.path)
|
||||
|
||||
@unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
|
||||
def test_force_reflink_arrives(self):
|
||||
self.i.move(operation=MoveOperation.REFLINK)
|
||||
self.assertExists(self.dest)
|
||||
|
||||
@unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
|
||||
def test_force_reflink_does_not_depart(self):
|
||||
self.i.move(operation=MoveOperation.REFLINK)
|
||||
self.assertExists(self.path)
|
||||
|
||||
def test_move_changes_path(self):
|
||||
self.i.move()
|
||||
self.assertEqual(self.i.path, util.normpath(self.dest))
|
||||
|
|
@ -268,6 +286,17 @@ class AlbumFileTest(_common.TestCase):
|
|||
self.assertTrue(os.path.exists(oldpath))
|
||||
self.assertTrue(os.path.exists(self.i.path))
|
||||
|
||||
@unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
|
||||
def test_albuminfo_move_reflinks_file(self):
|
||||
oldpath = self.i.path
|
||||
self.ai.album = u'newAlbumName'
|
||||
self.ai.move(operation=MoveOperation.REFLINK)
|
||||
self.ai.store()
|
||||
self.i.load()
|
||||
|
||||
self.assertTrue(os.path.exists(oldpath))
|
||||
self.assertTrue(os.path.exists(self.i.path))
|
||||
|
||||
def test_albuminfo_move_to_custom_dir(self):
|
||||
self.ai.move(basedir=self.otherdir)
|
||||
self.i.load()
|
||||
|
|
@ -549,6 +578,12 @@ class SafeMoveCopyTest(_common.TestCase):
|
|||
self.assertExists(self.dest)
|
||||
self.assertExists(self.path)
|
||||
|
||||
@unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
|
||||
def test_successful_reflink(self):
|
||||
util.reflink(self.path, self.dest)
|
||||
self.assertExists(self.dest)
|
||||
self.assertExists(self.path)
|
||||
|
||||
def test_unsuccessful_move(self):
|
||||
with self.assertRaises(util.FilesystemError):
|
||||
util.move(self.path, self.otherpath)
|
||||
|
|
@ -557,6 +592,11 @@ class SafeMoveCopyTest(_common.TestCase):
|
|||
with self.assertRaises(util.FilesystemError):
|
||||
util.copy(self.path, self.otherpath)
|
||||
|
||||
@unittest.skipUnless(_common.HAVE_REFLINK, "need reflink")
|
||||
def test_unsuccessful_reflink(self):
|
||||
with self.assertRaises(util.FilesystemError):
|
||||
util.reflink(self.path, self.otherpath)
|
||||
|
||||
def test_self_move(self):
|
||||
util.move(self.path, self.path)
|
||||
self.assertExists(self.path)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,16 @@ class KeyFinderTest(unittest.TestCase, TestHelper):
|
|||
item.load()
|
||||
self.assertEqual(item['initial_key'], 'F')
|
||||
|
||||
def test_no_key(self, command_output):
|
||||
item = Item(path='/file')
|
||||
item.add(self.lib)
|
||||
|
||||
command_output.return_value = util.CommandOutput(b"", b"")
|
||||
self.run_command('keyfinder')
|
||||
|
||||
item.load()
|
||||
self.assertEqual(item['initial_key'], None)
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
|
|
|||
|
|
@ -48,71 +48,72 @@ class LyricsPluginTest(unittest.TestCase):
|
|||
lyrics.LyricsPlugin()
|
||||
|
||||
def test_search_artist(self):
|
||||
item = Item(artist='Alice ft. Bob', title='song')
|
||||
self.assertIn(('Alice ft. Bob', ['song']),
|
||||
item = Item(artist=u'Alice ft. Bob', title=u'song')
|
||||
self.assertIn((u'Alice ft. Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Alice', ['song']),
|
||||
self.assertIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice feat Bob', title='song')
|
||||
self.assertIn(('Alice feat Bob', ['song']),
|
||||
item = Item(artist=u'Alice feat Bob', title=u'song')
|
||||
self.assertIn((u'Alice feat Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Alice', ['song']),
|
||||
self.assertIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice feat. Bob', title='song')
|
||||
self.assertIn(('Alice feat. Bob', ['song']),
|
||||
item = Item(artist=u'Alice feat. Bob', title=u'song')
|
||||
self.assertIn((u'Alice feat. Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Alice', ['song']),
|
||||
self.assertIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice feats Bob', title='song')
|
||||
self.assertIn(('Alice feats Bob', ['song']),
|
||||
item = Item(artist=u'Alice feats Bob', title=u'song')
|
||||
self.assertIn((u'Alice feats Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertNotIn(('Alice', ['song']),
|
||||
self.assertNotIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice featuring Bob', title='song')
|
||||
self.assertIn(('Alice featuring Bob', ['song']),
|
||||
item = Item(artist=u'Alice featuring Bob', title=u'song')
|
||||
self.assertIn((u'Alice featuring Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Alice', ['song']),
|
||||
self.assertIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice & Bob', title='song')
|
||||
self.assertIn(('Alice & Bob', ['song']),
|
||||
item = Item(artist=u'Alice & Bob', title=u'song')
|
||||
self.assertIn((u'Alice & Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Alice', ['song']),
|
||||
self.assertIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice and Bob', title='song')
|
||||
self.assertIn(('Alice and Bob', ['song']),
|
||||
item = Item(artist=u'Alice and Bob', title=u'song')
|
||||
self.assertIn((u'Alice and Bob', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Alice', ['song']),
|
||||
self.assertIn((u'Alice', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
item = Item(artist='Alice and Bob', title='song')
|
||||
self.assertEqual(('Alice and Bob', ['song']),
|
||||
item = Item(artist=u'Alice and Bob', title=u'song')
|
||||
self.assertEqual((u'Alice and Bob', [u'song']),
|
||||
list(lyrics.search_pairs(item))[0])
|
||||
|
||||
def test_search_artist_sort(self):
|
||||
item = Item(artist='CHVRCHΞS', title='song', artist_sort='CHVRCHES')
|
||||
self.assertIn(('CHVRCHΞS', ['song']),
|
||||
item = Item(artist=u'CHVRCHΞS', title=u'song', artist_sort=u'CHVRCHES')
|
||||
self.assertIn((u'CHVRCHΞS', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('CHVRCHES', ['song']),
|
||||
self.assertIn((u'CHVRCHES', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
# Make sure that the original artist name is still the first entry
|
||||
self.assertEqual(('CHVRCHΞS', ['song']),
|
||||
self.assertEqual((u'CHVRCHΞS', [u'song']),
|
||||
list(lyrics.search_pairs(item))[0])
|
||||
|
||||
item = Item(artist='横山克', title='song', artist_sort='Masaru Yokoyama')
|
||||
self.assertIn(('横山克', ['song']),
|
||||
item = Item(artist=u'横山克', title=u'song',
|
||||
artist_sort=u'Masaru Yokoyama')
|
||||
self.assertIn((u'横山克', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
self.assertIn(('Masaru Yokoyama', ['song']),
|
||||
self.assertIn((u'Masaru Yokoyama', [u'song']),
|
||||
lyrics.search_pairs(item))
|
||||
|
||||
# Make sure that the original artist name is still the first entry
|
||||
self.assertEqual(('横山克', ['song']),
|
||||
self.assertEqual((u'横山克', [u'song']),
|
||||
list(lyrics.search_pairs(item))[0])
|
||||
|
||||
def test_search_pairs_multi_titles(self):
|
||||
|
|
@ -268,10 +269,11 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
DEFAULT_SONG = dict(artist=u'The Beatles', title=u'Lady Madonna')
|
||||
|
||||
DEFAULT_SOURCES = [
|
||||
dict(DEFAULT_SONG, backend=lyrics.LyricsWiki),
|
||||
# dict(artist=u'Santana', title=u'Black magic woman',
|
||||
# backend=lyrics.MusiXmatch),
|
||||
dict(DEFAULT_SONG, backend=lyrics.Genius),
|
||||
dict(DEFAULT_SONG, backend=lyrics.Genius,
|
||||
# GitHub actions is on some form of Cloudflare blacklist.
|
||||
skip=os.environ.get('GITHUB_ACTIONS') == 'true'),
|
||||
]
|
||||
|
||||
GOOGLE_SOURCES = [
|
||||
|
|
@ -280,7 +282,9 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
path=u'/lyrics/view/the_beatles/lady_madonna'),
|
||||
dict(DEFAULT_SONG,
|
||||
url=u'http://www.azlyrics.com',
|
||||
path=u'/lyrics/beatles/ladymadonna.html'),
|
||||
path=u'/lyrics/beatles/ladymadonna.html',
|
||||
# AZLyrics returns a 403 on GitHub actions.
|
||||
skip=os.environ.get('GITHUB_ACTIONS') == 'true'),
|
||||
dict(DEFAULT_SONG,
|
||||
url=u'http://www.chartlyrics.com',
|
||||
path=u'/_LsLsZ7P4EK-F-LD4dJgDQ/Lady+Madonna.aspx'),
|
||||
|
|
@ -295,8 +299,6 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
dict(DEFAULT_SONG,
|
||||
url='http://www.lyricsmania.com/',
|
||||
path='lady_madonna_lyrics_the_beatles.html'),
|
||||
dict(DEFAULT_SONG, url=u'http://lyrics.wikia.com/',
|
||||
path=u'The_Beatles:Lady_Madonna'),
|
||||
dict(DEFAULT_SONG,
|
||||
url=u'http://www.lyricsmode.com',
|
||||
path=u'/lyrics/b/beatles/lady_madonna.html'),
|
||||
|
|
@ -330,11 +332,8 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
"""Test default backends with songs known to exist in respective databases.
|
||||
"""
|
||||
errors = []
|
||||
# GitHub actions seems to be on a Cloudflare blacklist, so we can't
|
||||
# contact genius.
|
||||
sources = [s for s in self.DEFAULT_SOURCES if
|
||||
s['backend'] != lyrics.Genius or
|
||||
os.environ.get('GITHUB_ACTIONS') != 'true']
|
||||
# Don't test any sources marked as skipped.
|
||||
sources = [s for s in self.DEFAULT_SOURCES if not s.get("skip", False)]
|
||||
for s in sources:
|
||||
res = s['backend'](self.plugin.config, self.plugin._log).fetch(
|
||||
s['artist'], s['title'])
|
||||
|
|
@ -349,7 +348,9 @@ class LyricsPluginSourcesTest(LyricsGoogleBaseTest):
|
|||
"""Test if lyrics present on websites registered in beets google custom
|
||||
search engine are correctly scraped.
|
||||
"""
|
||||
for s in self.GOOGLE_SOURCES:
|
||||
# Don't test any sources marked as skipped.
|
||||
sources = [s for s in self.GOOGLE_SOURCES if not s.get("skip", False)]
|
||||
for s in sources:
|
||||
url = s['url'] + s['path']
|
||||
res = lyrics.scrape_lyrics_from_html(
|
||||
raw_backend.fetch_url(url))
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Tests for the 'subsonic' plugin"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import requests
|
||||
import responses
|
||||
import unittest
|
||||
|
||||
from test import _common
|
||||
from beets import config
|
||||
from beetsplug import subsonicupdate
|
||||
from test.helper import TestHelper
|
||||
from six.moves.urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
class ArgumentsMock(object):
|
||||
def __init__(self, mode, show_failures):
|
||||
self.mode = mode
|
||||
self.show_failures = show_failures
|
||||
self.verbose = 1
|
||||
|
||||
|
||||
def _params(url):
|
||||
"""Get the query parameters from a URL."""
|
||||
return parse_qs(urlparse(url).query)
|
||||
|
||||
|
||||
class SubsonicPluginTest(_common.TestCase, TestHelper):
|
||||
@responses.activate
|
||||
def setUp(self):
|
||||
config.clear()
|
||||
self.setup_beets()
|
||||
|
||||
config["subsonic"]["user"] = "admin"
|
||||
config["subsonic"]["pass"] = "admin"
|
||||
config["subsonic"]["url"] = "http://localhost:4040"
|
||||
|
||||
self.subsonicupdate = subsonicupdate.SubsonicUpdate()
|
||||
|
||||
def tearDown(self):
|
||||
self.teardown_beets()
|
||||
|
||||
@responses.activate
|
||||
def test_start_scan(self):
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_extra_forward_slash_url(self):
|
||||
config["subsonic"]["url"] = "http://localhost:4040/contextPath"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/contextPath/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_context_path(self):
|
||||
config["subsonic"]["url"] = "http://localhost:4040/"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_missing_port(self):
|
||||
config["subsonic"]["url"] = "http://localhost/airsonic"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
with self.assertRaises(requests.exceptions.ConnectionError):
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_missing_schema(self):
|
||||
config["subsonic"]["url"] = "localhost:4040/airsonic"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200
|
||||
)
|
||||
|
||||
with self.assertRaises(requests.exceptions.InvalidSchema):
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
188
test/test_subsonicupdate.py
Normal file
188
test/test_subsonicupdate.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""Tests for the 'subsonic' plugin."""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import responses
|
||||
import unittest
|
||||
|
||||
from test import _common
|
||||
from beets import config
|
||||
from beetsplug import subsonicupdate
|
||||
from test.helper import TestHelper
|
||||
from six.moves.urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
class ArgumentsMock(object):
|
||||
"""Argument mocks for tests."""
|
||||
def __init__(self, mode, show_failures):
|
||||
"""Constructs ArgumentsMock."""
|
||||
self.mode = mode
|
||||
self.show_failures = show_failures
|
||||
self.verbose = 1
|
||||
|
||||
|
||||
def _params(url):
|
||||
"""Get the query parameters from a URL."""
|
||||
return parse_qs(urlparse(url).query)
|
||||
|
||||
|
||||
class SubsonicPluginTest(_common.TestCase, TestHelper):
|
||||
"""Test class for subsonicupdate."""
|
||||
@responses.activate
|
||||
def setUp(self):
|
||||
"""Sets up config and plugin for test."""
|
||||
config.clear()
|
||||
self.setup_beets()
|
||||
|
||||
config["subsonic"]["user"] = "admin"
|
||||
config["subsonic"]["pass"] = "admin"
|
||||
config["subsonic"]["url"] = "http://localhost:4040"
|
||||
|
||||
self.subsonicupdate = subsonicupdate.SubsonicUpdate()
|
||||
|
||||
SUCCESS_BODY = '''
|
||||
{
|
||||
"subsonic-response": {
|
||||
"status": "ok",
|
||||
"version": "1.15.0",
|
||||
"scanStatus": {
|
||||
"scanning": true,
|
||||
"count": 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
FAILED_BODY = '''
|
||||
{
|
||||
"subsonic-response": {
|
||||
"status": "failed",
|
||||
"version": "1.15.0",
|
||||
"error": {
|
||||
"code": 40,
|
||||
"message": "Wrong username or password."
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
ERROR_BODY = '''
|
||||
{
|
||||
"timestamp": 1599185854498,
|
||||
"status": 404,
|
||||
"error": "Not Found",
|
||||
"message": "No message available",
|
||||
"path": "/rest/startScn"
|
||||
}
|
||||
'''
|
||||
|
||||
def tearDown(self):
|
||||
"""Tears down tests."""
|
||||
self.teardown_beets()
|
||||
|
||||
@responses.activate
|
||||
def test_start_scan(self):
|
||||
"""Tests success path based on best case scenario."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200,
|
||||
body=self.SUCCESS_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_start_scan_failed_bad_credentials(self):
|
||||
"""Tests failed path based on bad credentials."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200,
|
||||
body=self.FAILED_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_start_scan_failed_not_found(self):
|
||||
"""Tests failed path based on resource not found."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=404,
|
||||
body=self.ERROR_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
def test_start_scan_failed_unreachable(self):
|
||||
"""Tests failed path based on service not available."""
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_context_path(self):
|
||||
"""Tests success for included with contextPath."""
|
||||
config["subsonic"]["url"] = "http://localhost:4040/contextPath/"
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost:4040/contextPath/rest/startScan',
|
||||
status=200,
|
||||
body=self.SUCCESS_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_trailing_forward_slash_url(self):
|
||||
"""Tests success path based on trailing forward slash."""
|
||||
config["subsonic"]["url"] = "http://localhost:4040/"
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200,
|
||||
body=self.SUCCESS_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_missing_port(self):
|
||||
"""Tests failed path based on missing port."""
|
||||
config["subsonic"]["url"] = "http://localhost/airsonic"
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost/airsonic/rest/startScan',
|
||||
status=200,
|
||||
body=self.SUCCESS_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
@responses.activate
|
||||
def test_url_with_missing_schema(self):
|
||||
"""Tests failed path based on missing schema."""
|
||||
config["subsonic"]["url"] = "localhost:4040/airsonic"
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'http://localhost:4040/rest/startScan',
|
||||
status=200,
|
||||
body=self.SUCCESS_BODY
|
||||
)
|
||||
|
||||
self.subsonicupdate.start_scan()
|
||||
|
||||
|
||||
def suite():
|
||||
"""Default test suite."""
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
|
|
@ -111,7 +111,7 @@ class ListTest(unittest.TestCase):
|
|||
self.assertNotIn(u'the album', stdout.getvalue())
|
||||
|
||||
|
||||
class RemoveTest(_common.TestCase):
|
||||
class RemoveTest(_common.TestCase, TestHelper):
|
||||
def setUp(self):
|
||||
super(RemoveTest, self).setUp()
|
||||
|
||||
|
|
@ -122,8 +122,8 @@ class RemoveTest(_common.TestCase):
|
|||
|
||||
# Copy a file into the library.
|
||||
self.lib = library.Library(':memory:', self.libdir)
|
||||
item_path = os.path.join(_common.RSRC, b'full.mp3')
|
||||
self.i = library.Item.from_path(item_path)
|
||||
self.item_path = os.path.join(_common.RSRC, b'full.mp3')
|
||||
self.i = library.Item.from_path(self.item_path)
|
||||
self.lib.add(self.i)
|
||||
self.i.move(operation=MoveOperation.COPY)
|
||||
|
||||
|
|
@ -153,6 +153,44 @@ class RemoveTest(_common.TestCase):
|
|||
self.assertEqual(len(list(items)), 0)
|
||||
self.assertFalse(os.path.exists(self.i.path))
|
||||
|
||||
def test_remove_items_select_with_delete(self):
|
||||
i2 = library.Item.from_path(self.item_path)
|
||||
self.lib.add(i2)
|
||||
i2.move(operation=MoveOperation.COPY)
|
||||
|
||||
for s in ('s', 'y', 'n'):
|
||||
self.io.addinput(s)
|
||||
commands.remove_items(self.lib, u'', False, True, False)
|
||||
items = self.lib.items()
|
||||
self.assertEqual(len(list(items)), 1)
|
||||
# There is probably no guarantee that the items are queried in any
|
||||
# spcecific order, thus just ensure that exactly one was removed.
|
||||
# To improve upon this, self.io would need to have the capability to
|
||||
# generate input that depends on previous output.
|
||||
num_existing = 0
|
||||
num_existing += 1 if os.path.exists(syspath(self.i.path)) else 0
|
||||
num_existing += 1 if os.path.exists(syspath(i2.path)) else 0
|
||||
self.assertEqual(num_existing, 1)
|
||||
|
||||
def test_remove_albums_select_with_delete(self):
|
||||
a1 = self.add_album_fixture()
|
||||
a2 = self.add_album_fixture()
|
||||
path1 = a1.items()[0].path
|
||||
path2 = a2.items()[0].path
|
||||
items = self.lib.items()
|
||||
self.assertEqual(len(list(items)), 3)
|
||||
|
||||
for s in ('s', 'y', 'n'):
|
||||
self.io.addinput(s)
|
||||
commands.remove_items(self.lib, u'', True, True, False)
|
||||
items = self.lib.items()
|
||||
self.assertEqual(len(list(items)), 2) # incl. the item from setUp()
|
||||
# See test_remove_items_select_with_delete()
|
||||
num_existing = 0
|
||||
num_existing += 1 if os.path.exists(syspath(path1)) else 0
|
||||
num_existing += 1 if os.path.exists(syspath(path2)) else 0
|
||||
self.assertEqual(num_existing, 1)
|
||||
|
||||
|
||||
class ModifyTest(unittest.TestCase, TestHelper):
|
||||
|
||||
|
|
|
|||
6
tox.ini
6
tox.ini
|
|
@ -27,6 +27,12 @@ basepython = python2.7
|
|||
deps = sphinx
|
||||
commands = sphinx-build -W -q -b html docs {envtmpdir}/html {posargs}
|
||||
|
||||
# checks all links in the docs
|
||||
[testenv:links]
|
||||
deps = sphinx
|
||||
allowlist_externals = /bin/bash
|
||||
commands = /bin/bash -c '! sphinx-build -b linkcheck docs {envtmpdir}/linkcheck | grep "broken\s"'
|
||||
|
||||
[testenv:int]
|
||||
deps = {[_test]deps}
|
||||
setenv = INTEGRATION_TEST = 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue