Merge remote-tracking branch 'origin/master' into parallel-replaygain

This commit is contained in:
ybnd 2020-12-14 22:13:33 +01:00
commit e3205aacbd
56 changed files with 1023 additions and 409 deletions

View file

@ -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

View file

@ -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:

View file

@ -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/>`__. Its
- Improve the `documentation`_. Its
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! Well 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 doesnt
@ -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/

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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):

View file

@ -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',

View file

@ -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():

View file

@ -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

View file

@ -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:

View file

@ -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 = {

View file

@ -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):

View file

@ -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):

View file

@ -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"

View file

@ -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:

View file

@ -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,
}

View file

@ -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))

View file

@ -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)

View file

@ -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)
--------------------

View file

@ -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'

View file

@ -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::

View file

@ -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

View file

@ -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"::

View file

@ -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/

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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/

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
-------------

View file

@ -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.

View file

@ -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

View file

@ -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/

View file

@ -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
-------------

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`).

View file

@ -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/

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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",

View file

@ -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(

View file

@ -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)

View file

@ -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__)

View file

@ -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))

View file

@ -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
View 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')

View file

@ -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):

View file

@ -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