Merge remote-tracking branch 'upstream/master'

This commit is contained in:
inytar 2017-03-16 11:08:17 -04:00
commit aab84413ec
78 changed files with 1201 additions and 402 deletions

View file

@ -12,10 +12,10 @@ environment:
matrix:
- PYTHON: C:\Python27
TOX_ENV: py27-test
- PYTHON: C:\Python34
TOX_ENV: py34-test
- PYTHON: C:\Python35
TOX_ENV: py35-test
- PYTHON: C:\Python36
TOX_ENV: py36-test
# Install Tox for running tests.
install:

View file

@ -19,7 +19,7 @@ import os
from beets.util import confit
__version__ = u'1.4.3'
__version__ = u'1.4.4'
__author__ = u'Adrian Sampson <adrian@radbox.org>'

26
beets/__main__.py Normal file
View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""The __main__ module lets you run the beets CLI interface by typing
`python -m beets`.
"""
from __future__ import division, absolute_import, print_function
import sys
from .ui import main
if __name__ == "__main__":
main(sys.argv[1:])

View file

@ -157,3 +157,5 @@ def apply_metadata(album_info, mapping):
item.composer = track_info.composer
if track_info.arranger is not None:
item.arranger = track_info.arranger
item.track_alt = track_info.track_alt

View file

@ -145,6 +145,7 @@ class TrackInfo(object):
- ``lyricist``: individual track lyricist name
- ``composer``: individual track composer name
- ``arranger`: individual track arranger name
- ``track_alt``: alternative track number (tape, vinyl, etc.)
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index``
@ -154,7 +155,8 @@ class TrackInfo(object):
length=None, index=None, medium=None, medium_index=None,
medium_total=None, artist_sort=None, disctitle=None,
artist_credit=None, data_source=None, data_url=None,
media=None, lyricist=None, composer=None, arranger=None):
media=None, lyricist=None, composer=None, arranger=None,
track_alt=None):
self.title = title
self.track_id = track_id
self.artist = artist
@ -173,6 +175,7 @@ class TrackInfo(object):
self.lyricist = lyricist
self.composer = composer
self.arranger = arranger
self.track_alt = track_alt
# As above, work around a bug in python-musicbrainz-ngs.
def decode(self, codec='utf-8'):

View file

@ -103,7 +103,9 @@ def assign_items(items, tracks):
costs.append(row)
# Find a minimum-cost bipartite matching.
log.debug('Computing track assignment...')
matching = Munkres().compute(costs)
log.debug('...done.')
# Produce the output matching.
mapping = dict((items[i], tracks[j]) for (i, j) in matching)
@ -349,7 +351,8 @@ def _add_candidate(items, results, info):
checking the track count, ordering the items, checking for
duplicates, and calculating the distance.
"""
log.debug(u'Candidate: {0} - {1}', info.artist, info.album)
log.debug(u'Candidate: {0} - {1} ({2})',
info.artist, info.album, info.album_id)
# Discard albums with zero tracks.
if not info.tracks:

View file

@ -30,7 +30,11 @@ from beets import config
import six
VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377'
BASE_URL = 'http://musicbrainz.org/'
if util.SNI_SUPPORTED:
BASE_URL = 'https://musicbrainz.org/'
else:
BASE_URL = 'http://musicbrainz.org/'
musicbrainzngs.set_useragent('beets', beets.__version__,
'http://beets.io/')
@ -265,6 +269,7 @@ def album_info(release):
)
ti.disctitle = disctitle
ti.media = format
ti.track_alt = track['number']
# Prefer track data, where present, over recording data.
if track.get('title'):
@ -369,6 +374,7 @@ def match_album(artist, album, tracks=None):
return
try:
log.debug(u'Searching for MusicBrainz releases with: {!r}', criteria)
res = musicbrainzngs.search_releases(
limit=config['musicbrainz']['searchlimit'].get(int), **criteria)
except musicbrainzngs.MusicBrainzError as exc:
@ -419,6 +425,7 @@ def album_for_id(releaseid):
object or None if the album is not found. May raise a
MusicBrainzAPIError.
"""
log.debug(u'Requesting MusicBrainz release {}', releaseid)
albumid = _parse_id(releaseid)
if not albumid:
log.debug(u'Invalid MBID ({0}).', releaseid)

View file

@ -6,6 +6,7 @@ import:
copy: yes
move: no
link: no
hardlink: no
delete: no
resume: ask
incremental: no

View file

@ -733,19 +733,28 @@ class Database(object):
if thread_id in self._connections:
return self._connections[thread_id]
else:
# Make a new connection. The `sqlite3` module can't use
# bytestring paths here on Python 3, so we need to
# provide a `str` using `py3_path`.
conn = sqlite3.connect(
py3_path(self.path), timeout=self.timeout
)
# Access SELECT results like dictionaries.
conn.row_factory = sqlite3.Row
conn = self._create_connection()
self._connections[thread_id] = conn
return conn
def _create_connection(self):
"""Create a SQLite connection to the underlying database.
Makes a new connection every time. If you need to configure the
connection settings (e.g., add custom functions), override this
method.
"""
# Make a new connection. The `sqlite3` module can't use
# bytestring paths here on Python 3, so we need to
# provide a `str` using `py3_path`.
conn = sqlite3.connect(
py3_path(self.path), timeout=self.timeout
)
# Access SELECT results like dictionaries.
conn.row_factory = sqlite3.Row
return conn
def _close(self):
"""Close the all connections to the underlying SQLite database
from all threads. This does not render the database object

View file

@ -503,9 +503,13 @@ def _to_epoch_time(date):
"""Convert a `datetime` object to an integer number of seconds since
the (local) Unix epoch.
"""
epoch = datetime.fromtimestamp(0)
delta = date - epoch
return int(delta.total_seconds())
if hasattr(date, 'timestamp'):
# The `timestamp` method exists on Python 3.3+.
return int(date.timestamp())
else:
epoch = datetime.fromtimestamp(0)
delta = date - epoch
return int(delta.total_seconds())
def _parse_periods(pattern):

View file

@ -220,13 +220,19 @@ class ImportSession(object):
iconfig['resume'] = False
iconfig['incremental'] = False
# Copy, move, and link are mutually exclusive.
# Copy, move, link, and hardlink are mutually exclusive.
if iconfig['move']:
iconfig['copy'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
elif iconfig['link']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['hardlink'] = False
elif iconfig['hardlink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False
# Only delete when copying.
if not iconfig['copy']:
@ -654,19 +660,19 @@ class ImportTask(BaseImportTask):
item.update(changes)
def manipulate_files(self, move=False, copy=False, write=False,
link=False, session=None):
link=False, hardlink=False, session=None):
items = self.imported_items()
# Save the original paths of all items for deletion and pruning
# in the next step (finalization).
self.old_paths = [item.path for item in items]
for item in items:
if move or copy or link:
if move or copy or link or hardlink:
# In copy and link modes, treat re-imports specially:
# move in-library files. (Out-of-library files are
# copied/moved as usual).
old_path = item.path
if (copy or link) and self.replaced_items[item] and \
session.lib.directory in util.ancestry(old_path):
if (copy or link or hardlink) and self.replaced_items[item] \
and session.lib.directory in util.ancestry(old_path):
item.move()
# We moved the item, so remove the
# now-nonexistent file from old_paths.
@ -674,7 +680,7 @@ class ImportTask(BaseImportTask):
else:
# A normal import. Just copy files and keep track of
# old paths.
item.move(copy, link)
item.move(copy, link, hardlink)
if write and (self.apply or self.choice_flag == action.RETAG):
item.try_write()
@ -982,7 +988,7 @@ class ArchiveImportTask(SentinelImportTask):
`toppath` to that directory.
"""
for path_test, handler_class in self.handlers():
if path_test(self.toppath):
if path_test(util.py3_path(self.toppath)):
break
try:
@ -1412,6 +1418,7 @@ def manipulate_files(session, task):
copy=session.config['copy'],
write=session.config['write'],
link=session.config['link'],
hardlink=session.config['hardlink'],
session=session,
)

View file

@ -663,7 +663,7 @@ class Item(LibModel):
# Files themselves.
def move_file(self, dest, copy=False, link=False):
def move_file(self, dest, copy=False, link=False, hardlink=False):
"""Moves or copies the item's file, updating the path value if
the move succeeds. If a file exists at ``dest``, then it is
slightly modified to be unique.
@ -678,6 +678,10 @@ class Item(LibModel):
util.link(self.path, dest)
plugins.send("item_linked", item=self, source=self.path,
destination=dest)
elif hardlink:
util.hardlink(self.path, dest)
plugins.send("item_hardlinked", item=self, source=self.path,
destination=dest)
else:
plugins.send("before_item_moved", item=self, source=self.path,
destination=dest)
@ -730,15 +734,16 @@ class Item(LibModel):
self._db._memotable = {}
def move(self, copy=False, link=False, basedir=None, with_album=True,
store=True):
def move(self, copy=False, link=False, hardlink=False, basedir=None,
with_album=True, store=True):
"""Move the item to its designated location within the library
directory (provided by destination()). Subdirectories are
created as needed. If the operation succeeds, the item's path
field is updated to reflect the new location.
If `copy` is true, moving the file is copied rather than moved.
Similarly, `link` creates a symlink instead.
Similarly, `link` creates a symlink instead, and `hardlink`
creates a hardlink.
basedir overrides the library base directory for the
destination.
@ -761,7 +766,7 @@ class Item(LibModel):
# Perform the move and store the change.
old_path = self.path
self.move_file(dest, copy, link)
self.move_file(dest, copy, link, hardlink)
if store:
self.store()
@ -979,7 +984,7 @@ class Album(LibModel):
for item in self.items():
item.remove(delete, False)
def move_art(self, copy=False, link=False):
def move_art(self, copy=False, link=False, hardlink=False):
"""Move or copy any existing album art so that it remains in the
same directory as the items.
"""
@ -999,6 +1004,8 @@ class Album(LibModel):
util.copy(old_art, new_art)
elif link:
util.link(old_art, new_art)
elif hardlink:
util.hardlink(old_art, new_art)
else:
util.move(old_art, new_art)
self.artpath = new_art
@ -1008,7 +1015,8 @@ class Album(LibModel):
util.prune_dirs(os.path.dirname(old_art),
self._db.directory)
def move(self, copy=False, link=False, basedir=None, store=True):
def move(self, copy=False, link=False, hardlink=False, basedir=None,
store=True):
"""Moves (or copies) all items to their destination. Any album
art moves along with them. basedir overrides the library base
directory for the destination. By default, the album is stored to the
@ -1026,11 +1034,11 @@ class Album(LibModel):
# Move items.
items = list(self.items())
for item in items:
item.move(copy, link, basedir=basedir, with_album=False,
item.move(copy, link, hardlink, basedir=basedir, with_album=False,
store=store)
# Move art.
self.move_art(copy, link)
self.move_art(copy, link, hardlink)
if store:
self.store()
@ -1237,14 +1245,17 @@ class Library(dbcore.Database):
timeout = beets.config['timeout'].as_number()
super(Library, self).__init__(path, timeout=timeout)
self._connection().create_function('bytelower', 1, _sqlite_bytelower)
self.directory = bytestring_path(normpath(directory))
self.path_formats = path_formats
self.replacements = replacements
self._memotable = {} # Used for template substitution performance.
def _create_connection(self):
conn = super(Library, self)._create_connection()
conn.create_function('bytelower', 1, _sqlite_bytelower)
return conn
# Adding objects to the database.
def add(self, obj):
@ -1444,13 +1455,15 @@ class DefaultTemplateFunctions(object):
cur_fmt = beets.config['time_format'].as_str()
return time.strftime(fmt, time.strptime(s, cur_fmt))
def tmpl_aunique(self, keys=None, disam=None):
def tmpl_aunique(self, keys=None, disam=None, bracket=None):
"""Generate a string that is guaranteed to be unique among all
albums in the library who share the same set of keys. A fields
from "disam" is used in the string if one is sufficient to
disambiguate the albums. Otherwise, a fallback opaque value is
used. Both "keys" and "disam" should be given as
whitespace-separated lists of field names.
whitespace-separated lists of field names, while "bracket" is a
pair of characters to be used as brackets surrounding the
disambiguator or empty to have no brackets.
"""
# Fast paths: no album, no item or library, or memoized value.
if not self.item or not self.lib:
@ -1464,9 +1477,19 @@ class DefaultTemplateFunctions(object):
keys = keys or 'albumartist album'
disam = disam or 'albumtype year label catalognum albumdisambig'
if bracket is None:
bracket = '[]'
keys = keys.split()
disam = disam.split()
# Assign a left and right bracket or leave blank if argument is empty.
if len(bracket) == 2:
bracket_l = bracket[0]
bracket_r = bracket[1]
else:
bracket_l = u''
bracket_r = u''
album = self.lib.get_album(self.item)
if not album:
# Do nothing for singletons.
@ -1499,13 +1522,19 @@ class DefaultTemplateFunctions(object):
else:
# No disambiguator distinguished all fields.
res = u' {0}'.format(album.id)
res = u' {1}{0}{2}'.format(album.id, bracket_l, bracket_r)
self.lib._memotable[memokey] = res
return res
# Flatten disambiguation value into a string.
disam_value = album.formatted(True).get(disambiguator)
res = u' [{0}]'.format(disam_value)
# Return empty string if disambiguator is empty.
if disam_value:
res = u' {1}{0}{2}'.format(disam_value, bracket_l, bracket_r)
else:
res = u''
self.lib._memotable[memokey] = res
return res

View file

@ -36,15 +36,11 @@ data from the tags. In turn ``MediaField`` uses a number of
from __future__ import division, absolute_import, print_function
import mutagen
import mutagen.mp3
import mutagen.id3
import mutagen.oggopus
import mutagen.oggvorbis
import mutagen.mp4
import mutagen.flac
import mutagen.monkeysaudio
import mutagen.asf
import mutagen.aiff
import codecs
import datetime
import re
@ -77,6 +73,7 @@ TYPES = {
'mpc': 'Musepack',
'asf': 'Windows Media',
'aiff': 'AIFF',
'dsf': 'DSD Stream File',
}
PREFERRED_IMAGE_EXTENSIONS = {'jpeg': 'jpg'}
@ -732,7 +729,7 @@ class MP4ImageStorageStyle(MP4ListStorageStyle):
class MP3StorageStyle(StorageStyle):
"""Store data in ID3 frames.
"""
formats = ['MP3', 'AIFF']
formats = ['MP3', 'AIFF', 'DSF']
def __init__(self, key, id3_lang=None, **kwargs):
"""Create a new ID3 storage style. `id3_lang` is the value for
@ -1479,6 +1476,8 @@ class MediaFile(object):
self.type = 'asf'
elif type(self.mgfile).__name__ == 'AIFF':
self.type = 'aiff'
elif type(self.mgfile).__name__ == 'DSF':
self.type = 'dsf'
else:
raise FileTypeError(path, type(self.mgfile).__name__)
@ -1942,9 +1941,9 @@ class MediaFile(object):
u'replaygain_album_gain',
float_places=2, suffix=u' dB'
),
MP4SoundCheckStorageStyle(
'----:com.apple.iTunes:iTunNORM',
index=1
MP4StorageStyle(
'----:com.apple.iTunes:replaygain_album_gain',
float_places=2, suffix=' dB'
),
StorageStyle(
u'REPLAYGAIN_ALBUM_GAIN',

View file

@ -126,8 +126,7 @@ def print_(*strings, **kwargs):
Python 3.
The `end` keyword argument behaves similarly to the built-in `print`
(it defaults to a newline). The value should have the same string
type as the arguments.
(it defaults to a newline).
"""
if not strings:
strings = [u'']
@ -136,11 +135,23 @@ def print_(*strings, **kwargs):
txt = u' '.join(strings)
txt += kwargs.get('end', u'\n')
# Send bytes to the stdout stream on Python 2.
# Encode the string and write it to stdout.
if six.PY2:
txt = txt.encode(_out_encoding(), 'replace')
sys.stdout.write(txt)
# On Python 2, sys.stdout expects bytes.
out = txt.encode(_out_encoding(), 'replace')
sys.stdout.write(out)
else:
# On Python 3, sys.stdout expects text strings and uses the
# exception-throwing encoding error policy. To avoid throwing
# errors and use our configurable encoding override, we use the
# underlying bytes buffer instead.
if hasattr(sys.stdout, 'buffer'):
out = txt.encode(_out_encoding(), 'replace')
sys.stdout.buffer.write(out)
else:
# In our test harnesses (e.g., DummyOut), sys.stdout.buffer
# does not exist. We instead just record the text string.
sys.stdout.write(txt)
# Configuration wrappers.

View file

@ -941,6 +941,10 @@ import_cmd.parser.add_option(
u'-C', u'--nocopy', action='store_false', dest='copy',
help=u"don't copy tracks (opposite of -c)"
)
import_cmd.parser.add_option(
u'-m', u'--move', action='store_true', dest='move',
help=u"move tracks into the library (overrides -c)"
)
import_cmd.parser.add_option(
u'-w', u'--write', action='store_true', default=None,
help=u"write new metadata to files' tags (default)"

View file

@ -18,6 +18,7 @@
from __future__ import division, absolute_import, print_function
import os
import sys
import errno
import locale
import re
import shutil
@ -34,6 +35,7 @@ from unidecode import unidecode
MAX_FILENAME_LENGTH = 200
WINDOWS_MAGIC_PREFIX = u'\\\\?\\'
SNI_SUPPORTED = sys.version_info >= (2, 7, 9)
class HumanReadableException(Exception):
@ -476,16 +478,15 @@ def move(path, dest, replace=False):
def link(path, dest, replace=False):
"""Create a symbolic link from path to `dest`. Raises an OSError if
`dest` already exists, unless `replace` is True. Does nothing if
`path` == `dest`."""
if (samefile(path, dest)):
`path` == `dest`.
"""
if samefile(path, dest):
return
path = syspath(path)
dest = syspath(dest)
if os.path.exists(dest) and not replace:
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
os.symlink(path, dest)
os.symlink(syspath(path), syspath(dest))
except NotImplementedError:
# raised on python >= 3.2 and Windows versions before Vista
raise FilesystemError(u'OS does not support symbolic links.'
@ -499,6 +500,30 @@ def link(path, dest, replace=False):
traceback.format_exc())
def hardlink(path, dest, replace=False):
"""Create a hard link from path to `dest`. Raises an OSError if
`dest` already exists, unless `replace` is True. Does nothing if
`path` == `dest`.
"""
if samefile(path, dest):
return
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
os.link(syspath(path), syspath(dest))
except NotImplementedError:
raise FilesystemError(u'OS does not support hard links.'
'link', (path, dest), traceback.format_exc())
except OSError as exc:
if exc.errno == errno.EXDEV:
raise FilesystemError(u'Cannot hard link across devices.'
'link', (path, dest), traceback.format_exc())
else:
raise FilesystemError(exc, '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

@ -32,7 +32,10 @@ PIL = 1
IMAGEMAGICK = 2
WEBPROXY = 3
PROXY_URL = 'http://images.weserv.nl/'
if util.SNI_SUPPORTED:
PROXY_URL = 'https://images.weserv.nl/'
else:
PROXY_URL = 'http://images.weserv.nl/'
log = logging.getLogger('beets')

View file

@ -24,7 +24,7 @@ import os
import subprocess
import tempfile
from distutils import spawn
from distutils.spawn import find_executable
import requests
from beets import plugins
@ -79,9 +79,10 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
# Extractor found, will exit with an error if not called with
# the correct amount of arguments.
pass
# Get the executable location on the system,
# needed to calculate the sha1 hash.
self.extractor = spawn.find_executable(self.extractor)
# Get the executable location on the system, which we need
# to calculate the SHA-1 hash.
self.extractor = find_executable(self.extractor)
# Calculate extractor hash.
self.extractor_sha = hashlib.sha1()

View file

@ -30,6 +30,23 @@ import sys
import six
class CheckerCommandException(Exception):
"""Raised when running a checker failed.
Attributes:
checker: Checker command name.
path: Path to the file being validated.
errno: Error number from the checker execution error.
msg: Message from the checker execution error.
"""
def __init__(self, cmd, oserror):
self.checker = cmd[0]
self.path = cmd[-1]
self.errno = oserror.errno
self.msg = str(oserror)
class BadFiles(BeetsPlugin):
def run_command(self, cmd):
self._log.debug(u"running command: {}",
@ -43,11 +60,7 @@ class BadFiles(BeetsPlugin):
errors = 1
status = e.returncode
except OSError as e:
if e.errno == errno.ENOENT:
ui.print_(u"command not found: {}".format(cmd[0]))
sys.exit(1)
else:
raise
raise CheckerCommandException(cmd, e)
output = output.decode(sys.getfilesystemencoding())
return status, errors, [line for line in output.split("\n") if line]
@ -93,16 +106,29 @@ class BadFiles(BeetsPlugin):
ui.colorize('text_error', dpath)))
# Run the checker against the file if one is found
ext = os.path.splitext(item.path)[1][1:]
ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore')
checker = self.get_checker(ext)
if not checker:
self._log.error(u"no checker specified in the config for {}",
ext)
continue
path = item.path
if not isinstance(path, six.text_type):
path = item.path.decode(sys.getfilesystemencoding())
status, errors, output = checker(path)
try:
status, errors, output = checker(path)
except CheckerCommandException as e:
if e.errno == errno.ENOENT:
self._log.error(
u"command not found: {} when validating file: {}",
e.checker,
e.path
)
else:
self._log.error(u"error invoking {}: {}", e.checker, e.msg)
continue
if status > 0:
ui.print_(u"{}: checker exited withs status {}"
ui.print_(u"{}: checker exited with status {}"
.format(ui.colorize('text_error', dpath), status))
for line in output:
ui.print_(u" {}".format(displayable_path(line)))
@ -111,11 +137,16 @@ class BadFiles(BeetsPlugin):
.format(ui.colorize('text_warning', dpath), errors))
for line in output:
ui.print_(u" {}".format(displayable_path(line)))
else:
elif opts.verbose:
ui.print_(u"{}: ok".format(ui.colorize('text_success', dpath)))
def commands(self):
bad_command = Subcommand('bad',
help=u'check for corrupt or missing files')
bad_command.parser.add_option(
u'-v', u'--verbose',
action='store_true', default=False, dest='verbose',
help=u'view results for both the bad and uncorrupted files'
)
bad_command.func = self.check_bad
return [bad_command]

View file

@ -161,7 +161,8 @@ class BeatportClient(object):
:returns: Tracks in the matching release
:rtype: list of :py:class:`BeatportTrack`
"""
response = self._get('/catalog/3/tracks', releaseId=beatport_id)
response = self._get('/catalog/3/tracks', releaseId=beatport_id,
perPage=100)
return [BeatportTrack(t) for t in response]
def get_track(self, beatport_id):

View file

@ -177,12 +177,12 @@ class GstPlayer(object):
posq = self.player.query_position(fmt)
if not posq[0]:
raise QueryError("query_position failed")
pos = posq[1] / (10 ** 9)
pos = posq[1] // (10 ** 9)
lengthq = self.player.query_duration(fmt)
if not lengthq[0]:
raise QueryError("query_duration failed")
length = lengthq[1] / (10 ** 9)
length = lengthq[1] // (10 ** 9)
self.cached_time = (pos, length)
return (pos, length)

View file

@ -54,9 +54,11 @@ class DiscogsPlugin(BeetsPlugin):
'apisecret': 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy',
'tokenfile': 'discogs_token.json',
'source_weight': 0.5,
'user_token': '',
})
self.config['apikey'].redact = True
self.config['apisecret'].redact = True
self.config['user_token'].redact = True
self.discogs_client = None
self.register_listener('import_begin', self.setup)
@ -66,6 +68,12 @@ class DiscogsPlugin(BeetsPlugin):
c_key = self.config['apikey'].as_str()
c_secret = self.config['apisecret'].as_str()
# Try using a configured user token (bypassing OAuth login).
user_token = self.config['user_token'].as_str()
if user_token:
self.discogs_client = Client(USER_AGENT, user_token=user_token)
return
# Get the OAuth token from a file or log in.
try:
with open(self._tokenfile()) as f:
@ -314,7 +322,9 @@ class DiscogsPlugin(BeetsPlugin):
# Only real tracks have `position`. Otherwise, it's an index track.
if track['position']:
index += 1
tracks.append(self.get_track_info(track, index))
track_info = self.get_track_info(track, index)
track_info.track_alt = track['position']
tracks.append(track_info)
else:
index_tracks[index + 1] = track['title']

View file

@ -21,7 +21,8 @@ import shlex
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_, Subcommand, UserError
from beets.util import command_output, displayable_path, subprocess
from beets.util import command_output, displayable_path, subprocess, \
bytestring_path
from beets.library import Item, Album
import six
@ -112,14 +113,14 @@ class DuplicatesPlugin(BeetsPlugin):
self.config.set_args(opts)
album = self.config['album'].get(bool)
checksum = self.config['checksum'].get(str)
copy = self.config['copy'].get(str)
copy = bytestring_path(self.config['copy'].as_str())
count = self.config['count'].get(bool)
delete = self.config['delete'].get(bool)
fmt = self.config['format'].get(str)
full = self.config['full'].get(bool)
keys = self.config['keys'].as_str_seq()
merge = self.config['merge'].get(bool)
move = self.config['move'].get(str)
move = bytestring_path(self.config['move'].as_str())
path = self.config['path'].get(bool)
tiebreak = self.config['tiebreak'].get(dict)
strict = self.config['strict'].get(bool)

View file

@ -20,13 +20,35 @@ import os.path
from beets.plugins import BeetsPlugin
from beets import ui
from beets.ui import decargs
from beets.ui import print_, decargs
from beets.util import syspath, normpath, displayable_path, bytestring_path
from beets.util.artresizer import ArtResizer
from beets import config
from beets import art
def _confirm(objs, album):
"""Show the list of affected objects (items or albums) and confirm
that the user wants to modify their artwork.
`album` is a Boolean indicating whether these are albums (as opposed
to items).
"""
noun = u'album' if album else u'file'
prompt = u'Modify artwork for {} {}{} (Y/n)?'.format(
len(objs),
noun,
u's' if len(objs) > 1 else u''
)
# Show all the items or albums.
for obj in objs:
print_(format(obj))
# Confirm with user.
return ui.input_yn(prompt)
class EmbedCoverArtPlugin(BeetsPlugin):
"""Allows albumart to be embedded into the actual files.
"""
@ -60,6 +82,9 @@ class EmbedCoverArtPlugin(BeetsPlugin):
embed_cmd.parser.add_option(
u'-f', u'--file', metavar='PATH', help=u'the image file to embed'
)
embed_cmd.parser.add_option(
u"-y", u"--yes", action="store_true", help=u"skip confirmation"
)
maxwidth = self.config['maxwidth'].get(int)
compare_threshold = self.config['compare_threshold'].get(int)
ifempty = self.config['ifempty'].get(bool)
@ -71,11 +96,24 @@ class EmbedCoverArtPlugin(BeetsPlugin):
raise ui.UserError(u'image file {0} not found'.format(
displayable_path(imagepath)
))
for item in lib.items(decargs(args)):
items = lib.items(decargs(args))
# Confirm with user.
if not opts.yes and not _confirm(items, not opts.file):
return
for item in items:
art.embed_item(self._log, item, imagepath, maxwidth, None,
compare_threshold, ifempty)
else:
for album in lib.albums(decargs(args)):
albums = lib.albums(decargs(args))
# Confirm with user.
if not opts.yes and not _confirm(albums, not opts.file):
return
for album in albums:
art.embed_album(self._log, album, maxwidth, False,
compare_threshold, ifempty)
self.remove_artfile(album)

View file

@ -6,6 +6,7 @@
host: localhost
port: 8096
username: user
apikey: apikey
password: password
"""
from __future__ import division, absolute_import, print_function
@ -150,7 +151,9 @@ class EmbyUpdate(BeetsPlugin):
# Adding defaults.
config['emby'].add({
u'host': u'http://localhost',
u'port': 8096
u'port': 8096,
u'apikey': None,
u'password': None,
})
self.register_listener('database_change', self.listen_for_db_change)
@ -171,6 +174,11 @@ class EmbyUpdate(BeetsPlugin):
password = config['emby']['password'].get()
token = config['emby']['apikey'].get()
# Check if at least a apikey or password is given.
if not any([password, token]):
self._log.warning(u'Provide at least Emby password or apikey.')
return
# Get user information from the Emby API.
user = get_user(host, port, username)
if not user:

View file

@ -69,7 +69,7 @@ class Candidate(object):
self.match = match
self.size = size
def _validate(self, extra):
def _validate(self, plugin):
"""Determine whether the candidate artwork is valid based on
its dimensions (width and ratio).
@ -80,9 +80,7 @@ class Candidate(object):
if not self.path:
return self.CANDIDATE_BAD
if not (extra['enforce_ratio'] or
extra['minwidth'] or
extra['maxwidth']):
if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth):
return self.CANDIDATE_EXACT
# get_size returns None if no local imaging backend is available
@ -101,22 +99,22 @@ class Candidate(object):
long_edge = max(self.size)
# Check minimum size.
if extra['minwidth'] and self.size[0] < extra['minwidth']:
if plugin.minwidth and self.size[0] < plugin.minwidth:
self._log.debug(u'image too small ({} < {})',
self.size[0], extra['minwidth'])
self.size[0], plugin.minwidth)
return self.CANDIDATE_BAD
# Check aspect ratio.
edge_diff = long_edge - short_edge
if extra['enforce_ratio']:
if extra['margin_px']:
if edge_diff > extra['margin_px']:
if plugin.enforce_ratio:
if plugin.margin_px:
if edge_diff > plugin.margin_px:
self._log.debug(u'image is not close enough to being '
u'square, ({} - {} > {})',
long_edge, short_edge, extra['margin_px'])
long_edge, short_edge, plugin.margin_px)
return self.CANDIDATE_BAD
elif extra['margin_percent']:
margin_px = extra['margin_percent'] * long_edge
elif plugin.margin_percent:
margin_px = plugin.margin_percent * long_edge
if edge_diff > margin_px:
self._log.debug(u'image is not close enough to being '
u'square, ({} - {} > {})',
@ -129,20 +127,20 @@ class Candidate(object):
return self.CANDIDATE_BAD
# Check maximum size.
if extra['maxwidth'] and self.size[0] > extra['maxwidth']:
if plugin.maxwidth and self.size[0] > plugin.maxwidth:
self._log.debug(u'image needs resizing ({} > {})',
self.size[0], extra['maxwidth'])
self.size[0], plugin.maxwidth)
return self.CANDIDATE_DOWNSCALE
return self.CANDIDATE_EXACT
def validate(self, extra):
self.check = self._validate(extra)
def validate(self, plugin):
self.check = self._validate(plugin)
return self.check
def resize(self, extra):
if extra['maxwidth'] and self.check == self.CANDIDATE_DOWNSCALE:
self.path = ArtResizer.shared.resize(extra['maxwidth'], self.path)
def resize(self, plugin):
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path)
def _logged_get(log, *args, **kwargs):
@ -198,13 +196,13 @@ class ArtSource(RequestMixin):
self._log = log
self._config = config
def get(self, album, extra):
def get(self, album, plugin, paths):
raise NotImplementedError()
def _candidate(self, **kwargs):
return Candidate(source=self, log=self._log, **kwargs)
def fetch_image(self, candidate, extra):
def fetch_image(self, candidate, plugin):
raise NotImplementedError()
@ -212,7 +210,7 @@ class LocalArtSource(ArtSource):
IS_LOCAL = True
LOC_STR = u'local'
def fetch_image(self, candidate, extra):
def fetch_image(self, candidate, plugin):
pass
@ -220,13 +218,13 @@ class RemoteArtSource(ArtSource):
IS_LOCAL = False
LOC_STR = u'remote'
def fetch_image(self, candidate, extra):
def fetch_image(self, candidate, plugin):
"""Downloads an image from a URL and checks whether it seems to
actually be an image. If so, returns a path to the downloaded image.
Otherwise, returns None.
"""
if extra['maxwidth']:
candidate.url = ArtResizer.shared.proxy_url(extra['maxwidth'],
if plugin.maxwidth:
candidate.url = ArtResizer.shared.proxy_url(plugin.maxwidth,
candidate.url)
try:
with closing(self.request(candidate.url, stream=True,
@ -292,10 +290,14 @@ class RemoteArtSource(ArtSource):
class CoverArtArchive(RemoteArtSource):
NAME = u"Cover Art Archive"
URL = 'http://coverartarchive.org/release/{mbid}/front'
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
if util.SNI_SUPPORTED:
URL = 'https://coverartarchive.org/release/{mbid}/front'
GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front'
else:
URL = 'http://coverartarchive.org/release/{mbid}/front'
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
def get(self, album, extra):
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.
"""
@ -313,7 +315,7 @@ class Amazon(RemoteArtSource):
URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
INDICES = (1, 2)
def get(self, album, extra):
def get(self, album, plugin, paths):
"""Generate URLs using Amazon ID (ASIN) string.
"""
if album.asin:
@ -327,7 +329,7 @@ class AlbumArtOrg(RemoteArtSource):
URL = 'http://www.albumart.org/index_detail.php'
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
def get(self, album, extra):
def get(self, album, plugin, paths):
"""Return art URL from AlbumArt.org using album ASIN.
"""
if not album.asin:
@ -358,7 +360,7 @@ class GoogleImages(RemoteArtSource):
self.key = self._config['google_key'].get(),
self.cx = self._config['google_engine'].get(),
def get(self, album, extra):
def get(self, album, plugin, paths):
"""Return art URL from google custom search engine
given an album title and interpreter.
"""
@ -394,8 +396,7 @@ class GoogleImages(RemoteArtSource):
class FanartTV(RemoteArtSource):
"""Art from fanart.tv requested using their API"""
NAME = u"fanart.tv"
API_URL = 'http://webservice.fanart.tv/v3/'
API_URL = 'https://webservice.fanart.tv/v3/'
API_ALBUMS = API_URL + 'music/albums/'
PROJECT_KEY = '61a7d0ab4e67162b7a0c7c35915cd48e'
@ -403,7 +404,7 @@ class FanartTV(RemoteArtSource):
super(FanartTV, self).__init__(*args, **kwargs)
self.client_key = self._config['fanarttv_key'].get()
def get(self, album, extra):
def get(self, album, plugin, paths):
if not album.mb_releasegroupid:
return
@ -454,7 +455,7 @@ class FanartTV(RemoteArtSource):
class ITunesStore(RemoteArtSource):
NAME = u"iTunes Store"
def get(self, album, extra):
def get(self, album, plugin, paths):
"""Return art URL from iTunes Store given an album title.
"""
if not (album.albumartist and album.album):
@ -488,8 +489,8 @@ class ITunesStore(RemoteArtSource):
class Wikipedia(RemoteArtSource):
NAME = u"Wikipedia (queried through DBpedia)"
DBPEDIA_URL = 'http://dbpedia.org/sparql'
WIKIPEDIA_URL = 'http://en.wikipedia.org/w/api.php'
DBPEDIA_URL = 'https://dbpedia.org/sparql'
WIKIPEDIA_URL = 'https://en.wikipedia.org/w/api.php'
SPARQL_QUERY = u'''PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX dbpprop: <http://dbpedia.org/property/>
PREFIX owl: <http://dbpedia.org/ontology/>
@ -512,7 +513,7 @@ class Wikipedia(RemoteArtSource):
}}
Limit 1'''
def get(self, album, extra):
def get(self, album, plugin, paths):
if not (album.albumartist and album.album):
return
@ -624,16 +625,14 @@ class FileSystem(LocalArtSource):
"""
return [idx for (idx, x) in enumerate(cover_names) if x in filename]
def get(self, album, extra):
def get(self, album, plugin, paths):
"""Look for album art files in the specified directories.
"""
paths = extra['paths']
if not paths:
return
cover_names = list(map(util.bytestring_path, extra['cover_names']))
cover_names = list(map(util.bytestring_path, plugin.cover_names))
cover_names_str = b'|'.join(cover_names)
cover_pat = br''.join([br"(\b|_)(", cover_names_str, br")(\b|_)"])
cautious = extra['cautious']
for path in paths:
if not os.path.isdir(syspath(path)):
@ -663,7 +662,7 @@ class FileSystem(LocalArtSource):
remaining.append(fn)
# Fall back to any image in the folder.
if remaining and not cautious:
if remaining and not plugin.cautious:
self._log.debug(u'using fallback art file {0}',
util.displayable_path(remaining[0]))
yield self._candidate(path=os.path.join(path, remaining[0]),
@ -842,16 +841,6 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
"""
out = None
# all the information any of the sources might need
extra = {'paths': paths,
'cover_names': self.cover_names,
'cautious': self.cautious,
'enforce_ratio': self.enforce_ratio,
'margin_px': self.margin_px,
'margin_percent': self.margin_percent,
'minwidth': self.minwidth,
'maxwidth': self.maxwidth}
for source in self.sources:
if source.IS_LOCAL or not local_only:
self._log.debug(
@ -861,9 +850,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
)
# URLs might be invalid at this point, or the image may not
# fulfill the requirements
for candidate in source.get(album, extra):
source.fetch_image(candidate, extra)
if candidate.validate(extra):
for candidate in source.get(album, self, paths):
source.fetch_image(candidate, self)
if candidate.validate(self):
out = candidate
self._log.debug(
u'using {0.LOC_STR} image {1}'.format(
@ -873,7 +862,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
break
if out:
out.resize(extra)
out.resize(self)
return out

View file

@ -36,6 +36,7 @@ class ImportAddedPlugin(BeetsPlugin):
register('before_item_moved', self.record_import_mtime)
register('item_copied', self.record_import_mtime)
register('item_linked', self.record_import_mtime)
register('item_hardlinked', self.record_import_mtime)
register('album_imported', self.update_album_times)
register('item_imported', self.update_item_times)
register('after_write', self.update_after_write_time)
@ -51,7 +52,7 @@ class ImportAddedPlugin(BeetsPlugin):
def record_if_inplace(self, task, session):
if not (session.config['copy'] or session.config['move'] or
session.config['link']):
session.config['link'] or session.config['hardlink']):
self._log.debug(u"In place import detected, recording mtimes from "
u"source paths")
items = [task.item] \

87
beetsplug/kodiupdate.py Normal file
View file

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Pauli Kettunen.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Updates a Kodi library whenever the beets library is changed.
This is based on the Plex Update plugin.
Put something like the following in your config.yaml to configure:
kodi:
host: localhost
port: 8080
user: user
pwd: secret
"""
from __future__ import division, absolute_import, print_function
import requests
from beets import config
from beets.plugins import BeetsPlugin
def update_kodi(host, port, user, password):
"""Sends request to the Kodi api to start a library refresh.
"""
url = "http://{0}:{1}/jsonrpc/".format(host, port)
"""Content-Type: application/json is mandatory
according to the kodi jsonrpc documentation"""
headers = {'Content-Type': 'application/json'}
# Create the payload. Id seems to be mandatory.
payload = {'jsonrpc': '2.0', 'method': 'AudioLibrary.Scan', 'id': 1}
r = requests.post(
url,
auth=(user, password),
json=payload,
headers=headers)
return r
class KodiUpdate(BeetsPlugin):
def __init__(self):
super(KodiUpdate, self).__init__()
# Adding defaults.
config['kodi'].add({
u'host': u'localhost',
u'port': 8080,
u'user': u'kodi',
u'pwd': u'kodi'})
config['kodi']['pwd'].redact = True
self.register_listener('database_change', self.listen_for_db_change)
def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update"""
self.register_listener('cli_exit', self.update)
def update(self, lib):
"""When the client exists try to send refresh request to Kodi server.
"""
self._log.info(u'Updating Kodi library...')
# Try to send update request.
try:
update_kodi(
config['kodi']['host'].get(),
config['kodi']['port'].get(),
config['kodi']['user'].get(),
config['kodi']['pwd'].get())
self._log.info(u'... started.')
except requests.exceptions.RequestException:
self._log.warning(u'Update failed.')

View file

@ -23,7 +23,7 @@ from beets import config
from beets import plugins
from beets.dbcore import types
API_URL = 'http://ws.audioscrobbler.com/2.0/'
API_URL = 'https://ws.audioscrobbler.com/2.0/'
class LastImportPlugin(plugins.BeetsPlugin):

View file

@ -563,12 +563,18 @@ class Google(Backend):
% (self.api_key, self.engine_id,
urllib.parse.quote(query.encode('utf-8')))
data = urllib.request.urlopen(url)
data = json.load(data)
data = self.fetch_url(url)
if not data:
self._log.debug(u'google backend returned no data')
return None
try:
data = json.loads(data)
except ValueError as exc:
self._log.debug(u'google backend returned malformed JSON: {}', exc)
if 'error' in data:
reason = data['error']['errors'][0]['reason']
self._log.debug(u'google lyrics backend error: {0}', reason)
return
self._log.debug(u'google backend error: {0}', reason)
return None
if 'items' in data.keys():
for item in data['items']:
@ -655,7 +661,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
params = {
'client_id': 'beets',
'client_secret': self.config['bing_client_secret'],
'scope': 'http://api.microsofttranslator.com',
'scope': "https://api.microsofttranslator.com",
'grant_type': 'client_credentials',
}
@ -762,7 +768,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
if self.bing_auth_token:
# Extract unique lines to limit API request size per song
text_lines = set(text.split('\n'))
url = ('http://api.microsofttranslator.com/v2/Http.svc/'
url = ('https://api.microsofttranslator.com/v2/Http.svc/'
'Translate?text=%s&to=%s' % ('|'.join(text_lines), to_lang))
r = requests.get(url,
headers={"Authorization ": self.bing_auth_token})

View file

@ -1,17 +1,17 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2011, Jeffrey Aylesworth <jeffrey@aylesworth.ca>
# This file is part of beets.
# Copyright (c) 2011, Jeffrey Aylesworth <mail@jeffrey.red>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function

View file

@ -56,5 +56,5 @@ class MBSubmitPlugin(BeetsPlugin):
return [PromptChoice(u'p', u'Print tracks', self.print_tracks)]
def print_tracks(self, session, task):
for i in task.items:
for i in sorted(task.items, key=lambda i: i.track):
print_data(None, i, self.config['format'].as_str())

View file

@ -27,7 +27,6 @@ from beets import plugins
from beets import library
from beets.util import displayable_path
from beets.dbcore import types
import six
# If we lose the connection, how many times do we want to retry and how
# much time should we wait between retries?
@ -46,20 +45,6 @@ def is_url(path):
return path.split('://', 1)[0] in ['http', 'https']
# Use the MPDClient internals to get unicode.
# see http://www.tarmack.eu/code/mpdunicode.py for the general idea
class MPDClient(mpd.MPDClient):
def _write_command(self, command, args=[]):
args = [six.text_type(arg).encode('utf-8') for arg in args]
super(MPDClient, self)._write_command(command, args)
def _read_line(self):
line = super(MPDClient, self)._read_line()
if line is not None:
return line.decode('utf-8')
return None
class MPDClientWrapper(object):
def __init__(self, log):
self._log = log
@ -67,7 +52,7 @@ class MPDClientWrapper(object):
self.music_directory = (
mpd_config['music_directory'].as_str())
self.client = MPDClient()
self.client = mpd.MPDClient(use_unicode=True)
def connect(self):
"""Connect to the MPD.
@ -278,25 +263,30 @@ class MPDStats(object):
played, duration = map(int, status['time'].split(':', 1))
remaining = duration - played
if self.now_playing and self.now_playing['path'] != path:
skipped = self.handle_song_change(self.now_playing)
# mpd responds twice on a natural new song start
going_to_happen_twice = not skipped
else:
going_to_happen_twice = False
if self.now_playing:
if self.now_playing['path'] != path:
self.handle_song_change(self.now_playing)
else:
# In case we got mpd play event with same song playing
# multiple times,
# assume low diff means redundant second play event
# after natural song start.
diff = abs(time.time() - self.now_playing['started'])
if not going_to_happen_twice:
self._log.info(u'playing {0}', displayable_path(path))
if diff <= self.time_threshold:
return
self.now_playing = {
'started': time.time(),
'remaining': remaining,
'path': path,
'beets_item': self.get_item(path),
}
self._log.info(u'playing {0}', displayable_path(path))
self.update_item(self.now_playing['beets_item'],
'last_played', value=int(time.time()))
self.now_playing = {
'started': time.time(),
'remaining': remaining,
'path': path,
'beets_item': self.get_item(path),
}
self.update_item(self.now_playing['beets_item'],
'last_played', value=int(time.time()))
def run(self):
self.mpd.connect()

View file

@ -35,14 +35,14 @@ import six
# easier.
class BufferedSocket(object):
"""Socket abstraction that allows reading by line."""
def __init__(self, host, port, sep='\n'):
def __init__(self, host, port, sep=b'\n'):
if host[0] in ['/', '~']:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(os.path.expanduser(host))
else:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((host, port))
self.buf = ''
self.buf = b''
self.sep = sep
def readline(self):
@ -51,11 +51,11 @@ class BufferedSocket(object):
if not data:
break
self.buf += data
if '\n' in self.buf:
if self.sep in self.buf:
res, self.buf = self.buf.split(self.sep, 1)
return res + self.sep
else:
return ''
return b''
def send(self, data):
self.sock.send(data)
@ -106,24 +106,24 @@ class MPDUpdatePlugin(BeetsPlugin):
return
resp = s.readline()
if 'OK MPD' not in resp:
if b'OK MPD' not in resp:
self._log.warning(u'MPD connection failed: {0!r}', resp)
return
if password:
s.send('password "%s"\n' % password)
s.send(b'password "%s"\n' % password.encode('utf8'))
resp = s.readline()
if 'OK' not in resp:
if b'OK' not in resp:
self._log.warning(u'Authentication failed: {0!r}', resp)
s.send('close\n')
s.send(b'close\n')
s.close()
return
s.send('update\n')
s.send(b'update\n')
resp = s.readline()
if 'updating_db' not in resp:
if b'updating_db' not in resp:
self._log.warning(u'Update failed: {0!r}', resp)
s.send('close\n')
s.send(b'close\n')
s.close()
self._log.info(u'Database updated.')

View file

@ -19,17 +19,41 @@ from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.ui.commands import PromptChoice
from beets import config
from beets import ui
from beets import util
from os.path import relpath
from tempfile import NamedTemporaryFile
import subprocess
# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.
ARGS_MARKER = '$args'
def play(command_str, selection, paths, open_args, log, item_type='track',
keep_open=False):
"""Play items in paths with command_str and optional arguments. If
keep_open, return to beets, otherwise exit once command runs.
"""
# Print number of tracks or albums to be played, log command to be run.
item_type += 's' if len(selection) > 1 else ''
ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
log.debug(u'executing command: {} {!r}', command_str, open_args)
try:
if keep_open:
command = util.shlex_split(command_str)
command = command + open_args
subprocess.call(command)
else:
util.interactive_open(open_args, command_str)
except OSError as exc:
raise ui.UserError(
"Could not play the query: {0}".format(exc))
class PlayPlugin(BeetsPlugin):
def __init__(self):
@ -40,11 +64,12 @@ class PlayPlugin(BeetsPlugin):
'use_folders': False,
'relative_to': None,
'raw': False,
# Backwards compatibility. See #1803 and line 74
'warning_threshold': -2,
'warning_treshold': 100,
'warning_threshold': 100,
})
self.register_listener('before_choose_candidate',
self.before_choose_candidate_listener)
def commands(self):
play_command = Subcommand(
'play',
@ -56,44 +81,17 @@ class PlayPlugin(BeetsPlugin):
action='store',
help=u'add additional arguments to the command',
)
play_command.func = self.play_music
play_command.func = self._play_command
return [play_command]
def play_music(self, lib, opts, args):
"""Execute query, create temporary playlist and execute player
command passing that playlist, at request insert optional arguments.
def _play_command(self, lib, opts, args):
"""The CLI command function for `beet play`. Create a list of paths
from query, determine if tracks or albums are to be played.
"""
command_str = config['play']['command'].get()
if not command_str:
command_str = util.open_anything()
use_folders = config['play']['use_folders'].get(bool)
relative_to = config['play']['relative_to'].get()
raw = config['play']['raw'].get(bool)
warning_threshold = config['play']['warning_threshold'].get(int)
# We use -2 as a default value for warning_threshold to detect if it is
# set or not. We can't use a falsey value because it would have an
# actual meaning in the configuration of this plugin, and we do not use
# -1 because some people might use it as a value to obtain no warning,
# which wouldn't be that bad of a practice.
if warning_threshold == -2:
# if warning_threshold has not been set by user, look for
# warning_treshold, to preserve backwards compatibility. See #1803.
# warning_treshold has the correct default value of 100.
warning_threshold = config['play']['warning_treshold'].get(int)
if relative_to:
relative_to = util.normpath(relative_to)
# Add optional arguments to the player command.
if opts.args:
if ARGS_MARKER in command_str:
command_str = command_str.replace(ARGS_MARKER, opts.args)
else:
command_str = u"{} {}".format(command_str, opts.args)
else:
# Don't include the marker in the command.
command_str = command_str.replace(" " + ARGS_MARKER, "")
# Perform search by album and add folders rather than tracks to
# playlist.
if opts.album:
@ -117,13 +115,52 @@ class PlayPlugin(BeetsPlugin):
paths = [relpath(path, relative_to) for path in paths]
item_type = 'track'
item_type += 's' if len(selection) > 1 else ''
if not selection:
ui.print_(ui.colorize('text_warning',
u'No {0} to play.'.format(item_type)))
return
open_args = self._playlist_or_paths(paths)
command_str = self._command_str(opts.args)
# Check if the selection exceeds configured threshold. If True,
# cancel, otherwise proceed with play command.
if not self._exceeds_threshold(selection, command_str, open_args,
item_type):
play(command_str, selection, paths, open_args, self._log,
item_type)
def _command_str(self, args=None):
"""Create a command string from the config command and optional args.
"""
command_str = config['play']['command'].get()
if not command_str:
return util.open_anything()
# Add optional arguments to the player command.
if args:
if ARGS_MARKER in command_str:
return command_str.replace(ARGS_MARKER, args)
else:
return u"{} {}".format(command_str, args)
else:
# Don't include the marker in the command.
return command_str.replace(" " + ARGS_MARKER, "")
def _playlist_or_paths(self, paths):
"""Return either the raw paths of items or a playlist of the items.
"""
if config['play']['raw']:
return paths
else:
return [self._create_tmp_playlist(paths)]
def _exceeds_threshold(self, selection, command_str, open_args,
item_type='track'):
"""Prompt user whether to abort if playlist exceeds threshold. If
True, cancel playback. If False, execute play command.
"""
warning_threshold = config['play']['warning_threshold'].get(int)
# Warn user before playing any huge playlists.
if warning_threshold and len(selection) > warning_threshold:
ui.print_(ui.colorize(
@ -132,20 +169,9 @@ class PlayPlugin(BeetsPlugin):
len(selection), item_type)))
if ui.input_options((u'Continue', u'Abort')) == 'a':
return
return True
ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
if raw:
open_args = paths
else:
open_args = [self._create_tmp_playlist(paths)]
self._log.debug(u'executing command: {} {!r}', command_str, open_args)
try:
util.interactive_open(open_args, command_str)
except OSError as exc:
raise ui.UserError(
"Could not play the query: {0}".format(exc))
return False
def _create_tmp_playlist(self, paths_list):
"""Create a temporary .m3u file. Return the filename.
@ -155,3 +181,21 @@ class PlayPlugin(BeetsPlugin):
m3u.write(item + b'\n')
m3u.close()
return m3u.name
def before_choose_candidate_listener(self, session, task):
"""Append a "Play" choice to the interactive importer prompt.
"""
return [PromptChoice('y', 'plaY', self.importer_play)]
def importer_play(self, session, task):
"""Get items from current import task and send to play function.
"""
selection = task.items
paths = [item.path for item in selection]
open_args = self._playlist_or_paths(paths)
command_str = self._command_str()
if not self._exceeds_threshold(selection, command_str, open_args):
play(command_str, selection, paths, open_args, self._log,
keep_open=True)

View file

@ -23,7 +23,6 @@ import warnings
import re
from six.moves import zip
from beets import logging
from beets import ui
from beets.plugins import BeetsPlugin
from beets.util import syspath, command_output, displayable_path, py3_path
@ -194,8 +193,8 @@ class Bs1770gainBackend(Backend):
"""
# Construct shell command.
cmd = [self.command]
cmd = cmd + [self.method]
cmd = cmd + ['-p']
cmd += [self.method]
cmd += ['-p']
# Workaround for Windows: the underlying tool fails on paths
# with the \\?\ prefix, so we don't use it here. This
@ -227,7 +226,7 @@ class Bs1770gainBackend(Backend):
':|done\\.\\s)', re.DOTALL | re.UNICODE)
results = re.findall(regex, data)
for parts in results[0:num_lines]:
part = parts.split(b'\n')
part = parts.split(u'\n')
if len(part) == 0:
self._log.debug(u'bad tool output: {0!r}', text)
raise ReplayGainError(u'bs1770gain failed')
@ -794,7 +793,7 @@ class ReplayGainPlugin(BeetsPlugin):
"command": CommandBackend,
"gstreamer": GStreamerBackend,
"audiotools": AudioToolsBackend,
"bs1770gain": Bs1770gainBackend
"bs1770gain": Bs1770gainBackend,
}
def __init__(self):
@ -934,8 +933,6 @@ class ReplayGainPlugin(BeetsPlugin):
"""Return the "replaygain" ui subcommand.
"""
def func(lib, opts, args):
self._log.setLevel(logging.INFO)
write = ui.should_write()
if opts.album:

View file

@ -172,6 +172,9 @@ class SmartPlaylistPlugin(BeetsPlugin):
if relative_to:
relative_to = normpath(relative_to)
# Maps playlist filenames to lists of track filenames.
m3us = {}
for playlist in self._matched_playlists:
name, (query, q_sort), (album_query, a_q_sort) = playlist
self._log.debug(u"Creating playlist {0}", name)
@ -183,7 +186,6 @@ class SmartPlaylistPlugin(BeetsPlugin):
for album in lib.albums(album_query, a_q_sort):
items.extend(album.items())
m3us = {}
# As we allow tags in the m3u names, we'll need to iterate through
# the items and generate the correct m3u file names.
for item in items:
@ -196,13 +198,14 @@ class SmartPlaylistPlugin(BeetsPlugin):
item_path = os.path.relpath(item.path, relative_to)
if item_path not in m3us[m3u_name]:
m3us[m3u_name].append(item_path)
# Now iterate through the m3us that we need to generate
for m3u in m3us:
m3u_path = normpath(os.path.join(playlist_dir,
bytestring_path(m3u)))
mkdirall(m3u_path)
with open(syspath(m3u_path), 'wb') as f:
for path in m3us[m3u]:
f.write(path + b'\n')
# Write all of the accumulated track lists to files.
for m3u in m3us:
m3u_path = normpath(os.path.join(playlist_dir,
bytestring_path(m3u)))
mkdirall(m3u_path)
with open(syspath(m3u_path), 'wb') as f:
for path in m3us[m3u]:
f.write(path + b'\n')
self._log.info(u"{0} playlists updated", len(self._matched_playlists))

View file

@ -289,4 +289,10 @@ class GioURI(URIGetter):
raise
finally:
self.libgio.g_free(uri_ptr)
return uri
try:
return uri.decode(util._fsencoding())
except UnicodeDecodeError:
raise RuntimeError(
"Could not decode filename from GIO: {!r}".format(uri)
)

View file

@ -37,7 +37,10 @@ def _rep(obj, expand=False):
out = dict(obj)
if isinstance(obj, beets.library.Item):
del out['path']
if app.config.get('INCLUDE_PATHS', False):
out['path'] = util.displayable_path(out['path'])
else:
del out['path']
# Get the size (in bytes) of the backing file. This is useful
# for the Tomahawk resolver API.
@ -173,11 +176,16 @@ class QueryConverter(PathConverter):
return ','.join(value)
class EverythingConverter(PathConverter):
regex = '.*?'
# Flask setup.
app = flask.Flask(__name__)
app.url_map.converters['idlist'] = IdListConverter
app.url_map.converters['query'] = QueryConverter
app.url_map.converters['everything'] = EverythingConverter
@app.before_request
@ -218,6 +226,16 @@ def item_query(queries):
return g.lib.items(queries)
@app.route('/item/path/<everything:path>')
def item_at_path(path):
query = beets.library.PathQuery('path', path.encode('utf-8'))
item = g.lib.items(query).get()
if item:
return flask.jsonify(_rep(item))
else:
return flask.abort(404)
@app.route('/item/values/<string:key>')
def item_unique_field_values(key):
sort_key = flask.request.args.get('sort_key', key)
@ -309,6 +327,7 @@ class WebPlugin(BeetsPlugin):
'host': u'127.0.0.1',
'port': 8337,
'cors': '',
'include_paths': False,
})
def commands(self):
@ -327,6 +346,8 @@ class WebPlugin(BeetsPlugin):
# Normalizes json output
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
app.config['INCLUDE_PATHS'] = self.config['include_paths']
# Enable CORS if required.
if self.config['cors']:
self._log.info(u'Enabling CORS with origin: {0}',

View file

@ -1,10 +1,91 @@
Changelog
=========
1.4.3 (in development)
1.4.4 (in development)
----------------------
Features:
New features:
* Added support for DSF files, once a future version of Mutagen is released
that supports them. Thanks to :user:`docbobo`. :bug:`459` :bug:`2379`
* The MusicBrainz backend and :doc:`/plugins/discogs` now both provide a new
attribute called ``track_alt`` that stores more nuanced, possibly
non-numeric track index data. For example, some vinyl or tape media will
report the side of the record using a letter instead of a number in that
field. :bug:`1831` :bug:`2363`
* The :doc:`/plugins/web` has a new endpoint, ``/item/path/foo``, which will
return the item info for the file at the given path, or 404.
* The :doc:`/plugins/web` also has a new config option, ``include_paths``,
which will cause paths to be included in item API responses if set to true.
* The ``%aunique`` template function for :ref:`aunique` now takes a third
argument that specifies which brackets to use around the disambiguator
value. The argument can be any two characters that represent the left and
right brackets. It defaults to `[]` and can also be blank to turn off
bracketing. :bug:`2397` :bug:`2399`
* Added a ``--move`` or ``-m`` option to the importer so that the files can be
moved to the library instead of being copied or added "in place".
:bug:`2252` :bug:`2429`
* :doc:`/plugins/badfiles`: Added a ``--verbose`` or ``-v`` option. Results are
now displayed only for corrupted files by default and for all the files when
the verbose option is set. :bug:`1654` :bug:`2434`
* A new :ref:`hardlink` config option instructs the importer to create hard
links on filesystems that support them. Thanks to :user:`jacobwgillespie`.
:bug:`2445`
* :doc:`/plugins/embedart`: The explicit ``embedart`` command now asks for
confirmation before embedding art into music files. Thanks to
:user:`Stunner`. :bug:`1999`
* You can now run beets by typing `python -m beets`. :bug:`2453`
* A new :doc:`/plugins/kodiupdate` lets you keep your Kodi library in sync
with beets. Thanks to :user:`Pauligrinder`. :bug:`2411`
* :doc:`/plugins/smartplaylist`: Different playlist specifications that
generate identically-named playlist files no longer conflict; instead, the
resulting lists of tracks are concatenated. :bug:`2468`
Fixes:
* :doc:`/plugins/mpdupdate`: Fix Python 3 compatibility. :bug:`2381`
* :doc:`/plugins/replaygain`: Fix Python 3 compatibility in the ``bs1770gain``
backend. :bug:`2382`
* :doc:`/plugins/bpd`: Report playback times as integer. :bug:`2394`
* :doc:`/plugins/mpdstats`: Fix Python 3 compatibility. The plugin also now
requires version 0.4.2 or later of the ``python-mpd2`` library. :bug:`2405`
* :doc:`/plugins/mpdstats`: Improve handling of mpd status queries.
* :doc:`/plugins/badfiles`: Fix Python 3 compatibility.
* Fix some cases where album-level ReplayGain/SoundCheck metadata would be
written to files incorrectly. :bug:`2426`
* :doc:`/plugins/badfiles`: The command no longer bails out if validator
command is not found or exists with an error. :bug:`2430` :bug:`2433`
* :doc:`/plugins/lyrics`: The Google search backend no longer crashes when the
server responds with an error. :bug:`2437`
* :doc:`/plugins/discogs`: You can now authenticate with Discogs using a
personal access token. :bug:`2447`
* Fix Python 3 compatibility when extracting rar archives in the importer.
Thanks to :user:`Lompik`. :bug:`2443` :bug:`2448`
* :doc:`/plugins/duplicates`: Fix Python 3 compatibility when using the
``copy`` and ``move`` options. :bug:`2444`
* :doc:`/plugins/mbsubmit`: The tracks are now sorted. Thanks to
:user:`awesomer`. :bug:`2457`
* :doc:`/plugins/thumbnails`: Fix a string-related crash on Python 3.
:bug:`2466`
* :doc:`/plugins/beatport`: More than just 10 songs are now fetched per album.
:bug:`2469`
* On Python 3, the :ref:`terminal_encoding` setting is respected again for
output and printing will no longer crash on systems configured with a
limited encoding.
1.4.3 (January 9, 2017)
-----------------------
Happy new year! This new version includes a cornucopia of new features from
contributors, including new tags related to classical music and a new
:doc:`/plugins/absubmit` for performing acoustic analysis on your music. The
:doc:`/plugins/random` has a new mode that lets you generate time-limited
music---for example, you might generate a random playlist that lasts the
perfect length for your walk to work. We also access as many Web services as
possible over secure connections now---HTTPS everywhere!
The most visible new features are:
* We now support the composer, lyricist, and arranger tags. The MusicBrainz
data source will fetch data for these fields when the next version of
@ -13,19 +94,28 @@ Features:
* A new :doc:`/plugins/absubmit` lets you run acoustic analysis software and
upload the results for others to use. Thanks to :user:`inytar`. :bug:`2253`
:bug:`2342`
* :doc:`/plugins/play`: The plugin now provides an importer prompt choice to
play the music you're about to import. Thanks to :user:`diomekes`.
:bug:`2008` :bug:`2360`
* We now use SSL to access Web services whenever possible. That includes
MusicBrainz itself, several album art sources, some lyrics sources, and
other servers. Thanks to :user:`tigranl`. :bug:`2307`
* :doc:`/plugins/random`: A new ``--time`` option lets you generate a random
playlist that takes a given amount of time. Thanks to :user:`diomekes`.
:bug:`2305` :bug:`2322`
* :doc:`/plugins/zero`: Added ``zero`` command to manually trigger the zero
Some smaller new features:
* :doc:`/plugins/zero`: A new ``zero`` command manually triggers the zero
plugin. Thanks to :user:`SJoshBrown`. :bug:`2274` :bug:`2329`
* :doc:`/plugins/acousticbrainz`: The plugin will avoid re-downloading data
for files that already have it by default. You can override this behavior
using a new ``force`` option. Thanks to :user:`SusannaMaria`. :bug:`2347`
:bug:`2349`
* :doc:`/plugins/bpm`: Now uses the ``import.write`` configuration option to
decide whether or not to write tracks after updating their BPM. :bug:`1992`
* :doc:`/plugins/bpm`: The ``import.write`` configuration option now
decides whether or not to write tracks after updating their BPM. :bug:`1992`
Fixes:
And the fixes:
* :doc:`/plugins/bpd`: Fix a crash on non-ASCII MPD commands. :bug:`2332`
* :doc:`/plugins/scrub`: Avoid a crash when files cannot be read or written.
@ -36,13 +126,21 @@ Fixes:
filesystem. :bug:`2353`
* :doc:`/plugins/discogs`: Improve the handling of releases that contain
subtracks. :bug:`2318`
* :doc:`/plugins/discogs`: Fix a crash when a release did not contain Format
information, and increased robustness when other fields are missing.
* :doc:`/plugins/discogs`: Fix a crash when a release does not contain format
information, and increase robustness when other fields are missing.
:bug:`2302`
* :doc:`/plugins/lyrics`: The plugin now reports a beets-specific User-Agent
header when requesting lyrics. :bug:`2357`
* :doc:`/plugins/embyupdate`: The plugin now checks whether an API key or a
password is provided in the configuration.
* :doc:`/plugins/play`: The misspelled configuration option
``warning_treshold`` is no longer supported.
For plugin developers: new importer prompt choices (see :ref:`append_prompt_choices`), you can now provide new candidates for the user to consider.
For plugin developers: when providing new importer prompt choices (see
:ref:`append_prompt_choices`), you can now provide new candidates for the user
to consider. For example, you might provide an alternative strategy for
picking between the available alternatives or for looking up a release on
MusicBrainz.
1.4.2 (December 16, 2016)

View file

@ -16,7 +16,7 @@ project = u'beets'
copyright = u'2016, Adrian Sampson'
version = '1.4'
release = '1.4.3'
release = '1.4.4'
pygments_style = 'sphinx'

View file

@ -161,6 +161,10 @@ The events currently available are:
for a file.
Parameters: ``item``, ``source`` path, ``destination`` path
* `item_hardlinked`: called with an ``Item`` object whenever a hardlink 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).

View file

@ -82,7 +82,7 @@ into this if you've installed Python yourself with `Homebrew`_ or otherwise.)
If this happens, you can install beets for the current user only (sans
``sudo``) by typing ``pip install --user beets``. If you do that, you might want
to add ``~/Library/Python/2.7/bin`` to your ``$PATH``.
to add ``~/Library/Python/3.6/bin`` to your ``$PATH``.
.. _System Integrity Protection: https://support.apple.com/en-us/HT204899
.. _Homebrew: http://brew.sh
@ -93,28 +93,28 @@ Installing on Windows
Installing beets on Windows can be tricky. Following these steps might help you
get it right:
1. If you don't have it, `install Python`_ (you want Python 2.7).
1. If you don't have it, `install Python`_ (you want Python 3.6). The
installer should give you the option to "add Python to PATH." Check this
box. If you do that, you can skip the next step.
2. If you haven't done so already, set your ``PATH`` environment variable to
include Python and its scripts. To do so, you have to get the "Properties"
window for "My Computer", then choose the "Advanced" tab, then hit the
"Environment Variables" button, and then look for the ``PATH`` variable in
the table. Add the following to the end of the variable's value:
``;C:\Python27;C:\Python27\Scripts``.
``;C:\Python36;C:\Python36\Scripts``. You may need to adjust these paths to
point to your Python installation.
3. Next, `install pip`_ (if you don't have it already) by downloading and
running the `get-pip.py`_ script.
3. Now install beets by running: ``pip install beets``
4. Now install beets by running: ``pip install beets``
5. You're all set! Type ``beet`` at the command prompt to make sure everything's
4. You're all set! Type ``beet`` at the command prompt to make sure everything's
in order.
Windows users may also want to install a context menu item for importing files
into beets. Just download and open `beets.reg`_ to add the necessary keys to the
registry. You can then right-click a directory and choose "Import with beets".
If Python is in a nonstandard location on your system, you may have to edit the
command path manually.
into beets. Download the `beets.reg`_ file and open it in a text file to make
sure the paths to Python match your system. Then double-click the file add the
necessary keys to your registry. You can then right-click a directory and
choose "Import with beets".
Because I don't use Windows myself, I may have missed something. If you have
trouble or you have more detail to contribute here, please direct it to
@ -142,8 +142,8 @@ place to start::
Change that first path to a directory where you'd like to keep your music. Then,
for ``library``, choose a good place to keep a database file that keeps an index
of your music. (The config's format is `YAML`_. You'll want to configure your
text editor to use spaces, not real tabs, for indentation.)
text editor to use spaces, not real tabs, for indentation. Also, ``~`` means
your home directory in these paths, even on Windows.)
The default configuration assumes you want to start a new organized music folder
(that ``directory`` above) and that you'll *copy* cleaned-up music into that
@ -238,21 +238,30 @@ songs. Thus::
$ beet ls album:bird
The Mae Shi - Terrorbird - Revelation Six
As you can see, search terms by default search all attributes of songs. (They're
By default, a search term will match any of a handful of :ref:`common
attributes <keywordquery>` of songs.
(They're
also implicitly joined by ANDs: a track must match *all* criteria in order to
match the query.) To narrow a search term to a particular metadata field, just
put the field before the term, separated by a : character. So ``album:bird``
only looks for ``bird`` in the "album" field of your songs. (Need to know more?
:doc:`/reference/query/` will answer all your questions.)
The ``beet list`` command has another useful option worth mentioning, ``-a``,
which searches for albums instead of songs::
The ``beet list`` command also has an ``-a`` option, which searches for albums instead of songs::
$ beet ls -a forever
Bon Iver - For Emma, Forever Ago
Freezepop - Freezepop Forever
So handy!
There's also an ``-f`` option (for *format*) that lets you specify what gets displayed in the results of a search::
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
In the format option, field references like `$format` and `$year` are filled
in with data from each result. You can see a full list of available fields by
running ``beet fields``.
Beets also has a ``stats`` command, just in case you want to see how much music
you have::

View file

@ -95,6 +95,9 @@ command-line options you should know:
* ``beet import -C``: don't copy imported files to your music directory; leave
them where they are
* ``beet import -m``: move imported files to your music directory (overrides
the ``-c`` option)
* ``beet import -l LOGFILE``: write a message to ``LOGFILE`` every time you skip
an album or choose to take its tags "as-is" (see below) or the album is
skipped as a duplicate; this lets you come back later and reexamine albums

View file

@ -22,8 +22,14 @@ Type::
beet absubmit [QUERY]
to run the analysis program and upload its results. This will work on any
music with a MusicBrainz track ID attached.
to run the analysis program and upload its results.
The plugin works on music with a MusicBrainz track ID attached. The plugin
will also skip music that the analysis tool doesn't support.
`streaming_extractor_music`_ currently supports files with the extensions
``mp3``, ``ogg``, ``oga``, ``flac``, ``mp4``, ``m4a``, ``m4r``, ``m4b``,
``m4p``, ``aac``, ``wma``, ``asf``, ``mpc``, ``wv``, ``spx``, ``tta``,
``3g2``, ``aif``, ``aiff`` and ``ape``.
Configuration
-------------

View file

@ -52,3 +52,7 @@ Note that the default `mp3val` checker is a bit verbose and can output a lot
of "stream error" messages, even for files that play perfectly well.
Generally, if more than one stream error happens, or if a stream error happens
in the middle of a file, this is a bad sign.
By default, only errors for the bad files will be shown. In order for the
results for all of the checked files to be seen, including the uncorrupted
ones, use the ``-v`` or ``--verbose`` option.

View file

@ -87,7 +87,14 @@ file. The available options are:
By default, the plugin will detect the number of processors available and use
them all.
You can also configure the format to use for transcoding.
You can also configure the format to use for transcoding (see the next
section):
- **format**: The name of the format to transcode to when none is specified on
the command line.
Default: ``mp3``.
- **formats**: A set of formats and associated command lines for transcoding
each.
.. _convert-format-config:

View file

@ -14,10 +14,9 @@ To use the ``discogs`` plugin, first enable it in your configuration (see
pip install discogs-client
You will also need to register for a `Discogs`_ account. The first time you
run the :ref:`import-cmd` command after enabling the plugin, it will ask you
to authorize with Discogs by visiting the site in a browser. Subsequent runs
will not require re-authorization.
You will also need to register for a `Discogs`_ account, and provide
authentication credentials via a personal access token or an OAuth2
authorization.
Matches from Discogs will now show up during import alongside matches from
MusicBrainz.
@ -25,6 +24,25 @@ MusicBrainz.
If you have a Discogs ID for an album you want to tag, you can also enter it
at the "enter Id" prompt in the importer.
OAuth Authorization
```````````````````
The first time you run the :ref:`import-cmd` command after enabling the plugin,
it will ask you to authorize with Discogs by visiting the site in a browser.
Subsequent runs will not require re-authorization.
Authentication via Personal Access Token
````````````````````````````````````````
As an alternative to OAuth, you can get a token from Discogs and add it to
your configuration.
To get a personal access token (called a "user token" in the `discogs-client`_
documentation), login to `Discogs`_, and visit the
`Developer settings page
<https://www.discogs.com/settings/developers>`_. Press the ``Generate new
token`` button, and place the generated token in your configuration, as the
``user_token`` config option in the ``discogs`` section.
Troubleshooting
---------------

View file

@ -81,7 +81,8 @@ embedded album art:
* ``beet embedart [-f IMAGE] QUERY``: embed images into the every track on the
albums matching the query. If the ``-f`` (``--file``) option is given, then
use a specific image file from the filesystem; otherwise, each album embeds
its own currently associated album art.
its own currently associated album art. The command prompts for confirmation
before making the change unless you specify the ``-y`` (``--yes``) option.
* ``beet extractart [-a] [-n FILE] QUERY``: extracts the images for all albums
matching the query. The images are placed inside the album folder. You can

View file

@ -3,7 +3,7 @@ EmbyUpdate Plugin
``embyupdate`` is a plugin that lets you automatically update `Emby`_'s library whenever you change your beets library.
To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll probably want to configure the specifics of your Emby server. You can do that using an ``emby:`` section in your ``config.yaml``, which looks like this::
To use ``embyupdate`` plugin, enable it in your configuration (see :ref:`using-plugins`). Then, you'll want to configure the specifics of your Emby server. You can do that using an ``emby:`` section in your ``config.yaml``, which looks like this::
emby:
host: localhost

View file

@ -66,6 +66,7 @@ like this::
inline
ipfs
keyfinder
kodiupdate
lastgenre
lastimport
lyrics
@ -148,6 +149,8 @@ Interoperability
* :doc:`embyupdate`: Automatically notifies `Emby`_ whenever the beets library changes.
* :doc:`importfeeds`: Keep track of imported files via ``.m3u`` playlist file(s) or symlinks.
* :doc:`ipfs`: Import libraries from friends and get albums from them via ipfs.
* :doc:`kodiupdate`: Automatically notifies `Kodi`_ whenever the beets library
changes.
* :doc:`mpdupdate`: Automatically notifies `MPD`_ whenever the beets library
changes.
* :doc:`play`: Play beets queries in your music player.
@ -160,6 +163,7 @@ Interoperability
.. _Emby: http://emby.media
.. _Plex: http://plex.tv
.. _Kodi: http://kodi.tv
Miscellaneous
-------------
@ -236,6 +240,8 @@ Here are a few of the plugins written by the beets community:
* `beets-usertag`_ lets you use keywords to tag and organize your music.
* `beets-popularity`_ fetches popularity values from Spotify.
.. _beets-check: https://github.com/geigerzaehler/beets-check
.. _copyartifacts: https://github.com/sbarakat/beets-copyartifacts
.. _dsedivec: https://github.com/dsedivec/beets-plugins
@ -253,3 +259,5 @@ Here are a few of the plugins written by the beets community:
.. _beets-noimport: https://gitlab.com/tiago.dias/beets-noimport
.. _whatlastgenre: https://github.com/YetAnotherNerd/whatlastgenre/tree/master/plugin/beets
.. _beets-usertag: https://github.com/igordertigor/beets-usertag
.. _beets-popularity: https://github.com/abba23/beets-popularity

View file

@ -0,0 +1,44 @@
KodiUpdate Plugin
=================
The ``kodiupdate`` plugin lets you automatically update `Kodi`_'s music
library whenever you change your beets library.
To use ``kodiupdate`` plugin, enable it in your configuration
(see :ref:`using-plugins`).
Then, you'll want to configure the specifics of your Kodi host.
You can do that using a ``kodi:`` section in your ``config.yaml``,
which looks like this::
kodi:
host: localhost
port: 8080
user: kodi
pwd: kodi
To use the ``kodiupdate`` plugin you need to install the `requests`_ library with::
pip install requests
You'll also need to enable JSON-RPC in Kodi in order the use the plugin.
In Kodi's interface, navigate to System/Settings/Network/Services and choose "Allow control of Kodi via HTTP."
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: http://kodi.tv/
.. _requests: http://docs.python-requests.org/en/latest/
Configuration
-------------
The available options under the ``kodi:`` section are:
- **host**: The Kodi host name.
Default: ``localhost``
- **port**: The Kodi host port.
Default: 8080
- **user**: The Kodi host user.
Default: ``kodi``
- **pwd**: The Kodi host password.
Default: ``kodi``

View file

@ -4,8 +4,8 @@ Play Plugin
The ``play`` plugin allows you to pass the results of a query to a music
player in the form of an m3u playlist or paths on the command line.
Usage
-----
Command Line Usage
------------------
To use the ``play`` plugin, enable it in your configuration (see
:ref:`using-plugins`). Then use it by invoking the ``beet play`` command with
@ -29,6 +29,18 @@ would on the command-line)::
While playing you'll be able to interact with the player if it is a
command-line oriented, and you'll get its output in real time.
Interactive Usage
-----------------
The `play` plugin can also be invoked during an import. If enabled, the plugin
adds a `plaY` option to the prompt, so pressing `y` will execute the configured
command and play the items currently being imported.
Once the configured command exits, you will be returned to the import
decision prompt. If your player is configured to run in the background (in a
client/server setup), the music will play until you choose to stop it, and the
import operation continues immediately.
Configuration
-------------

View file

@ -63,6 +63,8 @@ configuration file. The available options are:
Default: 8337.
- **cors**: The CORS allowed origin (see :ref:`web-cors`, below).
Default: CORS is disabled.
- **include_paths**: If true, includes paths in item objects.
Default: false.
Implementation
--------------
@ -160,6 +162,16 @@ response includes all the items requested. If a track is not found it is silentl
dropped from the response.
``GET /item/path/...``
++++++++++++++++++++++
Look for an item at the given absolute path on the server. If it corresponds to
a track, return the track in the same format as ``/item/*``.
If the server runs UNIX, you'll need to include an extra leading slash:
``http://localhost:8337/item/path//Users/beets/Music/Foo/Bar/Baz.mp3``
``GET /item/query/querystring``
+++++++++++++++++++++++++++++++

View file

@ -72,7 +72,8 @@ box. To extract `rar` files, install the `rarfile`_ package and the
Optional command flags:
* By default, the command copies files your the library directory and
updates the ID3 tags on your music. If you'd like to leave your music
updates the ID3 tags on your music. In order to move the files, instead of
copying, use the ``-m`` (move) option. If you'd like to leave your music
files untouched, try the ``-C`` (don't copy) and ``-W`` (don't write tags)
options. You can also disable this behavior by default in the
configuration file (below).

View file

@ -229,7 +229,7 @@ Default sort order to use when fetching items from the database. Defaults to
sort_album
~~~~~~~~~~
Default sort order to use when fetching items from the database. Defaults to
Default sort order to use when fetching albums from the database. Defaults to
``albumartist+ album+``. Explicit sort orders override this default.
.. _sort_case_insensitive:
@ -433,8 +433,8 @@ link
~~~~
Either ``yes`` or ``no``, indicating whether to use symbolic links instead of
moving or copying files. (It conflicts with the ``move`` and ``copy``
options.) Defaults to ``no``.
moving or copying files. (It conflicts with the ``move``, ``copy`` and
``hardlink`` options.) Defaults to ``no``.
This option only works on platforms that support symbolic links: i.e., Unixes.
It will fail on Windows.
@ -442,6 +442,19 @@ It will fail on Windows.
It's likely that you'll also want to set ``write`` to ``no`` if you use this
option to preserve the metadata on the linked files.
.. _hardlink:
hardlink
~~~~~~~~
Either ``yes`` or ``no``, indicating whether to use hard links instead of
moving or 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.
resume
~~~~~~
@ -620,7 +633,7 @@ automatically accept any matches above 90% similarity, use::
The default strong recommendation threshold is 0.04.
The ``medium_rec_thresh`` and ``rec_gap_thresh`` options work similarly. When a
match is above the *medium* recommendation threshold or the distance between it
match is below the *medium* recommendation threshold or the distance between it
and the next-best match is above the *gap* threshold, the importer will suggest
that match but not automatically confirm it. Otherwise, you'll see a list of
options to choose from.

View file

@ -71,8 +71,8 @@ These functions are built in to beets:
For example, "café" becomes "cafe". Uses the mapping provided by the
`unidecode module`_. See the :ref:`asciify-paths` configuration
option.
* ``%aunique{identifiers,disambiguators}``: Provides a unique string to
disambiguate similar albums in the database. See :ref:`aunique`, below.
* ``%aunique{identifiers,disambiguators,brackets}``: Provides a unique string
to disambiguate similar albums in the database. See :ref:`aunique`, below.
* ``%time{date_time,format}``: Return the date and time in any format accepted
by `strftime`_. For example, to get the year some music was added to your
library, use ``%time{$added,%Y}``.
@ -112,14 +112,16 @@ will expand to "[2008]" for one album and "[2010]" for the other. The
function detects that you have two albums with the same artist and title but
that they have different release years.
For full flexibility, the ``%aunique`` function takes two arguments, each of
which are whitespace-separated lists of album field names: a set of
*identifiers* and a set of *disambiguators*. Any group of albums with identical
values for all the identifiers will be considered "duplicates". Then, the
function tries each disambiguator field, looking for one that distinguishes each
of the duplicate albums from each other. The first such field is used as the
result for ``%aunique``. If no field suffices, an arbitrary number is used to
distinguish the two albums.
For full flexibility, the ``%aunique`` function takes three arguments. The
first two are whitespace-separated lists of album field names: a set of
*identifiers* and a set of *disambiguators*. The third argument is a pair of
characters used to surround the disambiguator.
Any group of albums with identical values for all the identifiers will be
considered "duplicates". Then, the function tries each disambiguator field,
looking for one that distinguishes each of the duplicate albums from each
other. The first such field is used as the result for ``%aunique``. If no field
suffices, an arbitrary number is used to distinguish the two albums.
The default identifiers are ``albumartist album`` and the default disambiguators
are ``albumtype year label catalognum albumdisambig``. So you can get reasonable
@ -127,6 +129,10 @@ disambiguation behavior if you just use ``%aunique{}`` with no parameters in
your path forms (as in the default path formats), but you can customize the
disambiguation if, for example, you include the year by default in path formats.
The default characters used as brackets are ``[]``. To change this, provide a
third argument to the ``%aunique`` function consisting of two characters: the left
and right brackets. Or, to turn off bracketing entirely, leave argument blank.
One caveat: When you import an album that is named identically to one already in
your library, the *first* album—the one already in your library— will not
consider itself a duplicate at import time. This means that ``%aunique{}`` will

View file

@ -6,6 +6,8 @@ searches that select tracks and albums from your library. This page explains the
query string syntax, which is meant to vaguely resemble the syntax used by Web
search engines.
.. _keywordquery:
Keyword
-------

Binary file not shown.

View file

@ -56,7 +56,7 @@ if 'sdist' in sys.argv:
setup(
name='beets',
version='1.4.3',
version='1.4.4',
description='music tagger and library organizer',
author='Adrian Sampson',
author_email='adrian@radbox.org',
@ -114,10 +114,10 @@ setup(
'absubmit': ['requests'],
'fetchart': ['requests'],
'chroma': ['pyacoustid'],
'discogs': ['discogs-client>=2.1.0'],
'discogs': ['discogs-client>=2.2.1'],
'beatport': ['requests-oauthlib>=0.6.1'],
'lastgenre': ['pylast'],
'mpdstats': ['python-mpd2'],
'mpdstats': ['python-mpd2>=0.4.2'],
'web': ['flask', 'flask-cors'],
'import': ['rarfile'],
'thumbnails': ['pyxdg'] +

View file

@ -54,6 +54,7 @@ _item_ident = 0
# OS feature test.
HAVE_SYMLINK = sys.platform != 'win32'
HAVE_HARDLINK = sys.platform != 'win32'
def item(lib=None):

View file

@ -6,15 +6,19 @@ a specified text tag.
"""
from __future__ import division, absolute_import, print_function
from os.path import dirname, abspath
import six
import sys
import platform
import locale
beets_src = dirname(dirname(dirname(abspath(__file__))))
sys.path.insert(0, beets_src)
PY2 = sys.version_info[0] == 2
from beets.util import arg_encoding # noqa: E402
# From `beets.util`.
def arg_encoding():
try:
return locale.getdefaultlocale()[1] or 'utf-8'
except ValueError:
return 'utf-8'
def convert(in_file, out_file, tag):
@ -27,7 +31,7 @@ def convert(in_file, out_file, tag):
# On Windows, use Unicode paths. (The test harness gives them to us
# as UTF-8 bytes.)
if platform.system() == 'Windows':
if not six.PY2:
if not PY2:
in_file = in_file.encode(arg_encoding())
out_file = out_file.encode(arg_encoding())
in_file = in_file.decode('utf-8')

BIN
test/rsrc/empty.dsf Normal file

Binary file not shown.

BIN
test/rsrc/full.dsf Normal file

Binary file not shown.

BIN
test/rsrc/unparseable.dsf Normal file

Binary file not shown.

View file

@ -39,6 +39,15 @@ from beets.util import confit
logger = logging.getLogger('beets.test_art')
class Settings():
"""Used to pass settings to the ArtSources when the plugin isn't fully
instantiated.
"""
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
class UseThePlugin(_common.TestCase):
def setUp(self):
super(UseThePlugin, self).setUp()
@ -73,28 +82,28 @@ class FetchImageTest(FetchImageHelper, UseThePlugin):
super(FetchImageTest, self).setUp()
self.dpath = os.path.join(self.temp_dir, b'arttest')
self.source = fetchart.RemoteArtSource(logger, self.plugin.config)
self.extra = {'maxwidth': 0}
self.settings = Settings(maxwidth=0)
self.candidate = fetchart.Candidate(logger, url=self.URL)
def test_invalid_type_returns_none(self):
self.mock_response(self.URL, 'image/watercolour')
self.source.fetch_image(self.candidate, self.extra)
self.source.fetch_image(self.candidate, self.settings)
self.assertEqual(self.candidate.path, None)
def test_jpeg_type_returns_path(self):
self.mock_response(self.URL, 'image/jpeg')
self.source.fetch_image(self.candidate, self.extra)
self.source.fetch_image(self.candidate, self.settings)
self.assertNotEqual(self.candidate.path, None)
def test_extension_set_by_content_type(self):
self.mock_response(self.URL, 'image/png')
self.source.fetch_image(self.candidate, self.extra)
self.source.fetch_image(self.candidate, self.settings)
self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png')
self.assertExists(self.candidate.path)
def test_does_not_rely_on_server_content_type(self):
self.mock_response(self.URL, 'image/jpeg', 'image/png')
self.source.fetch_image(self.candidate, self.extra)
self.source.fetch_image(self.candidate, self.settings)
self.assertEqual(os.path.splitext(self.candidate.path)[1], b'.png')
self.assertExists(self.candidate.path)
@ -106,44 +115,43 @@ class FSArtTest(UseThePlugin):
os.mkdir(self.dpath)
self.source = fetchart.FileSystem(logger, self.plugin.config)
self.extra = {'cautious': False,
'cover_names': ('art',),
'paths': [self.dpath]}
self.settings = Settings(cautious=False,
cover_names=('art',))
def test_finds_jpg_in_directory(self):
_common.touch(os.path.join(self.dpath, b'a.jpg'))
candidate = next(self.source.get(None, self.extra))
candidate = next(self.source.get(None, self.settings, [self.dpath]))
self.assertEqual(candidate.path, os.path.join(self.dpath, b'a.jpg'))
def test_appropriately_named_file_takes_precedence(self):
_common.touch(os.path.join(self.dpath, b'a.jpg'))
_common.touch(os.path.join(self.dpath, b'art.jpg'))
candidate = next(self.source.get(None, self.extra))
candidate = next(self.source.get(None, self.settings, [self.dpath]))
self.assertEqual(candidate.path, os.path.join(self.dpath, b'art.jpg'))
def test_non_image_file_not_identified(self):
_common.touch(os.path.join(self.dpath, b'a.txt'))
with self.assertRaises(StopIteration):
next(self.source.get(None, self.extra))
next(self.source.get(None, self.settings, [self.dpath]))
def test_cautious_skips_fallback(self):
_common.touch(os.path.join(self.dpath, b'a.jpg'))
self.extra['cautious'] = True
self.settings.cautious = True
with self.assertRaises(StopIteration):
next(self.source.get(None, self.extra))
next(self.source.get(None, self.settings, [self.dpath]))
def test_empty_dir(self):
with self.assertRaises(StopIteration):
next(self.source.get(None, self.extra))
next(self.source.get(None, self.settings, [self.dpath]))
def test_precedence_amongst_correct_files(self):
images = [b'front-cover.jpg', b'front.jpg', b'back.jpg']
paths = [os.path.join(self.dpath, i) for i in images]
for p in paths:
_common.touch(p)
self.extra['cover_names'] = ['cover', 'front', 'back']
self.settings.cover_names = ['cover', 'front', 'back']
candidates = [candidate.path for candidate in
self.source.get(None, self.extra)]
self.source.get(None, self.settings, [self.dpath])]
self.assertEqual(candidates, paths)
@ -154,7 +162,7 @@ class CombinedTest(FetchImageHelper, UseThePlugin):
.format(ASIN)
AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}' \
.format(ASIN)
CAA_URL = 'http://coverartarchive.org/release/{0}/front' \
CAA_URL = 'coverartarchive.org/release/{0}/front' \
.format(MBID)
def setUp(self):
@ -202,12 +210,17 @@ 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(self.CAA_URL)
self.mock_response("http://" + self.CAA_URL)
self.mock_response("https://" + self.CAA_URL)
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
candidate = self.plugin.art_for_album(album, None)
self.assertIsNotNone(candidate)
self.assertEqual(len(responses.calls), 1)
self.assertEqual(responses.calls[0].request.url, self.CAA_URL)
if util.SNI_SUPPORTED:
url = "https://" + self.CAA_URL
else:
url = "http://" + self.CAA_URL
self.assertEqual(responses.calls[0].request.url, url)
def test_local_only_does_not_access_network(self):
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
@ -231,7 +244,7 @@ class AAOTest(UseThePlugin):
def setUp(self):
super(AAOTest, self).setUp()
self.source = fetchart.AlbumArtOrg(logger, self.plugin.config)
self.extra = dict()
self.settings = Settings()
@responses.activate
def run(self, *args, **kwargs):
@ -251,21 +264,21 @@ class AAOTest(UseThePlugin):
"""
self.mock_response(self.AAO_URL, body)
album = _common.Bag(asin=self.ASIN)
candidate = next(self.source.get(album, self.extra))
candidate = next(self.source.get(album, self.settings, []))
self.assertEqual(candidate.url, 'TARGET_URL')
def test_aao_scraper_returns_no_result_when_no_image_present(self):
self.mock_response(self.AAO_URL, 'blah blah')
album = _common.Bag(asin=self.ASIN)
with self.assertRaises(StopIteration):
next(self.source.get(album, self.extra))
next(self.source.get(album, self.settings, []))
class GoogleImageTest(UseThePlugin):
def setUp(self):
super(GoogleImageTest, self).setUp()
self.source = fetchart.GoogleImages(logger, self.plugin.config)
self.extra = dict()
self.settings = Settings()
@responses.activate
def run(self, *args, **kwargs):
@ -279,7 +292,7 @@ class GoogleImageTest(UseThePlugin):
album = _common.Bag(albumartist="some artist", album="some album")
json = '{"items": [{"link": "url_to_the_image"}]}'
self.mock_response(fetchart.GoogleImages.URL, json)
candidate = next(self.source.get(album, self.extra))
candidate = next(self.source.get(album, self.settings, []))
self.assertEqual(candidate.url, 'url_to_the_image')
def test_google_art_returns_no_result_when_error_received(self):
@ -287,14 +300,14 @@ class GoogleImageTest(UseThePlugin):
json = '{"error": {"errors": [{"reason": "some reason"}]}}'
self.mock_response(fetchart.GoogleImages.URL, json)
with self.assertRaises(StopIteration):
next(self.source.get(album, self.extra))
next(self.source.get(album, self.settings, []))
def test_google_art_returns_no_result_with_malformed_response(self):
album = _common.Bag(albumartist="some artist", album="some album")
json = """bla blup"""
self.mock_response(fetchart.GoogleImages.URL, json)
with self.assertRaises(StopIteration):
next(self.source.get(album, self.extra))
next(self.source.get(album, self.settings, []))
class FanartTVTest(UseThePlugin):
@ -358,7 +371,7 @@ class FanartTVTest(UseThePlugin):
def setUp(self):
super(FanartTVTest, self).setUp()
self.source = fetchart.FanartTV(logger, self.plugin.config)
self.extra = dict()
self.settings = Settings()
@responses.activate
def run(self, *args, **kwargs):
@ -372,7 +385,7 @@ class FanartTVTest(UseThePlugin):
album = _common.Bag(mb_releasegroupid=u'thereleasegroupid')
self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid',
self.RESPONSE_MULTIPLE)
candidate = next(self.source.get(album, self.extra))
candidate = next(self.source.get(album, self.settings, []))
self.assertEqual(candidate.url, 'http://example.com/1.jpg')
def test_fanarttv_returns_no_result_when_error_received(self):
@ -380,14 +393,14 @@ class FanartTVTest(UseThePlugin):
self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid',
self.RESPONSE_ERROR)
with self.assertRaises(StopIteration):
next(self.source.get(album, self.extra))
next(self.source.get(album, self.settings, []))
def test_fanarttv_returns_no_result_with_malformed_response(self):
album = _common.Bag(mb_releasegroupid=u'thereleasegroupid')
self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid',
self.RESPONSE_MALFORMED)
with self.assertRaises(StopIteration):
next(self.source.get(album, self.extra))
next(self.source.get(album, self.settings, []))
def test_fanarttv_only_other_images(self):
# The source used to fail when there were images present, but no cover
@ -395,7 +408,7 @@ class FanartTVTest(UseThePlugin):
self.mock_response(fetchart.FanartTV.API_ALBUMS + u'thereleasegroupid',
self.RESPONSE_NO_ART)
with self.assertRaises(StopIteration):
next(self.source.get(album, self.extra))
next(self.source.get(album, self.settings, []))
@_common.slow_test()
@ -523,8 +536,8 @@ class ArtForAlbumTest(UseThePlugin):
self.old_fs_source_get = fetchart.FileSystem.get
def fs_source_get(_self, album, extra):
if extra['paths']:
def fs_source_get(_self, album, settings, paths):
if paths:
yield fetchart.Candidate(logger, path=self.image_file)
fetchart.FileSystem.get = fs_source_get
@ -652,5 +665,6 @@ class EnforceRatioConfigTest(_common.TestCase):
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')

View file

@ -51,6 +51,8 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
abbey_differentpath = os.path.join(_common.RSRC, b'abbey-different.jpg')
def setUp(self):
super(EmbedartCliTest, self).setUp()
self.io.install()
self.setup_beets() # Converter is threaded
self.load_plugins('embedart')
@ -64,11 +66,30 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
self.unload_plugins()
self.teardown_beets()
def test_embed_art_from_file_with_yes_input(self):
self._setup_data()
album = self.add_album_fixture()
item = album.items()[0]
self.io.addinput('y')
self.run_command('embedart', '-f', self.small_artpath)
mediafile = MediaFile(syspath(item.path))
self.assertEqual(mediafile.images[0].data, self.image_data)
def test_embed_art_from_file_with_no_input(self):
self._setup_data()
album = self.add_album_fixture()
item = album.items()[0]
self.io.addinput('n')
self.run_command('embedart', '-f', self.small_artpath)
mediafile = MediaFile(syspath(item.path))
# make sure that images array is empty (nothing embedded)
self.assertEqual(len(mediafile.images), 0)
def test_embed_art_from_file(self):
self._setup_data()
album = self.add_album_fixture()
item = album.items()[0]
self.run_command('embedart', '-f', self.small_artpath)
self.run_command('embedart', '-y', '-f', self.small_artpath)
mediafile = MediaFile(syspath(item.path))
self.assertEqual(mediafile.images[0].data, self.image_data)
@ -78,7 +99,7 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
item = album.items()[0]
album.artpath = self.small_artpath
album.store()
self.run_command('embedart')
self.run_command('embedart', '-y')
mediafile = MediaFile(syspath(item.path))
self.assertEqual(mediafile.images[0].data, self.image_data)
@ -96,7 +117,7 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
album.store()
config['embedart']['remove_art_file'] = True
self.run_command('embedart')
self.run_command('embedart', '-y')
if os.path.isfile(tmp_path):
os.remove(tmp_path)
@ -106,7 +127,7 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
self.add_album_fixture()
logging.getLogger('beets.embedart').setLevel(logging.DEBUG)
with self.assertRaises(ui.UserError):
self.run_command('embedart', '-f', '/doesnotexist')
self.run_command('embedart', '-y', '-f', '/doesnotexist')
def test_embed_non_image_file(self):
album = self.add_album_fixture()
@ -117,7 +138,7 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
os.close(handle)
try:
self.run_command('embedart', '-f', tmp_path)
self.run_command('embedart', '-y', '-f', tmp_path)
finally:
os.remove(tmp_path)
@ -129,9 +150,9 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
self._setup_data(self.abbey_artpath)
album = self.add_album_fixture()
item = album.items()[0]
self.run_command('embedart', '-f', self.abbey_artpath)
self.run_command('embedart', '-y', '-f', self.abbey_artpath)
config['embedart']['compare_threshold'] = 20
self.run_command('embedart', '-f', self.abbey_differentpath)
self.run_command('embedart', '-y', '-f', self.abbey_differentpath)
mediafile = MediaFile(syspath(item.path))
self.assertEqual(mediafile.images[0].data, self.image_data,
@ -143,9 +164,9 @@ class EmbedartCliTest(_common.TestCase, TestHelper):
self._setup_data(self.abbey_similarpath)
album = self.add_album_fixture()
item = album.items()[0]
self.run_command('embedart', '-f', self.abbey_artpath)
self.run_command('embedart', '-y', '-f', self.abbey_artpath)
config['embedart']['compare_threshold'] = 20
self.run_command('embedart', '-f', self.abbey_similarpath)
self.run_command('embedart', '-y', '-f', self.abbey_similarpath)
mediafile = MediaFile(syspath(item.path))
self.assertEqual(mediafile.images[0].data, self.image_data,

View file

@ -141,6 +141,27 @@ class MoveTest(_common.TestCase):
self.i.move(link=True)
self.assertEqual(self.i.path, util.normpath(self.dest))
@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_arrives(self):
self.i.move(hardlink=True)
self.assertExists(self.dest)
s1 = os.stat(self.path)
s2 = os.stat(self.dest)
self.assertTrue(
(s1[stat.ST_INO], s1[stat.ST_DEV]) ==
(s2[stat.ST_INO], s2[stat.ST_DEV])
)
@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_does_not_depart(self):
self.i.move(hardlink=True)
self.assertExists(self.path)
@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_hardlink_changes_path(self):
self.i.move(hardlink=True)
self.assertEqual(self.i.path, util.normpath(self.dest))
class HelperTest(_common.TestCase):
def test_ancestry_works_on_file(self):

View file

@ -93,6 +93,7 @@ class ImportAddedTest(unittest.TestCase, ImportHelper):
self.config['import']['copy'] = False
self.config['import']['move'] = False
self.config['import']['link'] = False
self.config['import']['hardlink'] = False
self.assertAlbumImport()
def test_import_album_with_preserved_mtimes(self):

View file

@ -22,6 +22,7 @@ import re
import shutil
import unicodedata
import sys
import stat
from six import StringIO
from tempfile import mkstemp
from zipfile import ZipFile
@ -209,7 +210,8 @@ class ImportHelper(TestHelper):
def _setup_import_session(self, import_dir=None, delete=False,
threaded=False, copy=True, singletons=False,
move=False, autotag=True, link=False):
move=False, autotag=True, link=False,
hardlink=False):
config['import']['copy'] = copy
config['import']['delete'] = delete
config['import']['timid'] = True
@ -219,6 +221,7 @@ class ImportHelper(TestHelper):
config['import']['autotag'] = autotag
config['import']['resume'] = False
config['import']['link'] = link
config['import']['hardlink'] = hardlink
self.importer = TestImportSession(
self.lib, loghandler=None, query=None,
@ -353,6 +356,24 @@ class NonAutotaggedImportTest(_common.TestCase, ImportHelper):
mediafile.path
)
@unittest.skipUnless(_common.HAVE_HARDLINK, "need hardlinks")
def test_import_hardlink_arrives(self):
config['import']['hardlink'] = True
self.importer.run()
for mediafile in self.import_media:
filename = os.path.join(
self.libdir,
b'Tag Artist', b'Tag Album',
util.bytestring_path('{0}.mp3'.format(mediafile.title))
)
self.assertExists(filename)
s1 = os.stat(mediafile.path)
s2 = os.stat(filename)
self.assertTrue(
(s1[stat.ST_INO], s1[stat.ST_DEV]) ==
(s2[stat.ST_INO], s2[stat.ST_DEV])
)
def create_archive(session):
(handle, path) = mkstemp(dir=py3_path(session.temp_dir))
@ -1723,6 +1744,7 @@ def mocked_get_release_by_id(id_, includes=[], release_status=[],
'length': 59,
},
'position': 9,
'number': 'A2'
}],
'position': 5,
}],

View file

@ -713,8 +713,8 @@ class DisambiguationTest(_common.TestCase, PathFormattingMixin):
album2.year = 2001
album2.store()
self._assert_dest(b'/base/foo 1/the title', self.i1)
self._assert_dest(b'/base/foo 2/the title', self.i2)
self._assert_dest(b'/base/foo [1]/the title', self.i1)
self._assert_dest(b'/base/foo [2]/the title', self.i2)
def test_unique_falls_back_to_second_distinguishing_field(self):
self._setf(u'foo%aunique{albumartist album,month year}/$title')
@ -730,6 +730,24 @@ class DisambiguationTest(_common.TestCase, PathFormattingMixin):
self._setf(u'foo%aunique{albumartist album,albumtype}/$title')
self._assert_dest(b'/base/foo [foo_bar]/the title', self.i1)
def test_drop_empty_disambig_string(self):
album1 = self.lib.get_album(self.i1)
album1.albumdisambig = None
album2 = self.lib.get_album(self.i2)
album2.albumdisambig = u'foo'
album1.store()
album2.store()
self._setf(u'foo%aunique{albumartist album,albumdisambig}/$title')
self._assert_dest(b'/base/foo/the title', self.i1)
def test_change_brackets(self):
self._setf(u'foo%aunique{albumartist album,year,()}/$title')
self._assert_dest(b'/base/foo (2001)/the title', self.i1)
def test_remove_brackets(self):
self._setf(u'foo%aunique{albumartist album,year,}/$title')
self._assert_dest(b'/base/foo 2001/the title', self.i1)
class PluginDestinationTest(_common.TestCase):
def setUp(self):

View file

@ -68,6 +68,7 @@ class MBAlbumInfoTest(_common.TestCase):
track = {
'recording': recording,
'position': i + 1,
'number': 'A1',
}
if track_length:
# Track lengths are distinct from recording lengths.
@ -182,6 +183,7 @@ class MBAlbumInfoTest(_common.TestCase):
second_track_list = [{
'recording': tracks[1],
'position': '1',
'number': 'A1',
}]
release['medium-list'].append({
'position': '2',
@ -454,6 +456,7 @@ class MBLibraryTest(unittest.TestCase):
'length': 42,
},
'position': 9,
'number': 'A1',
}],
'position': 5,
}],

View file

@ -175,7 +175,11 @@ class ImageStructureTestMixin(ArtTestMixin):
class ExtendedImageStructureTestMixin(ImageStructureTestMixin):
"""Checks for additional attributes in the image structure."""
"""Checks for additional attributes in the image structure.
Like the base `ImageStructureTestMixin`, per-format test classes
should include this mixin to add image-related tests.
"""
def assertExtendedImageAttributes(self, image, desc=None, type=None): # noqa
self.assertEqual(image.desc, desc)
@ -294,8 +298,23 @@ class GenreListTestMixin(object):
class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin,
_common.TempDirMixin):
"""Test writing and reading tags. Subclasses must set ``extension`` and
``audio_properties``.
"""Test writing and reading tags. Subclasses must set ``extension``
and ``audio_properties``.
The basic tests for all audio formats encompass three files provided
in our `rsrc` folder: `full.*`, `empty.*`, and `unparseable.*`.
Respectively, they should contain a full slate of common fields
listed in `full_initial_tags` below; no fields contents at all; and
an unparseable release date field.
To add support for a new file format to MediaFile, add these three
files and then create a `ReadWriteTestBase` subclass by copying n'
pasting one of the existing subclasses below. You will want to
update the `format` field in that subclass, and you will probably
need to fiddle with the `bitrate` and other format-specific fields.
You can also add image tests (using an additional `image.*` fixture
file) by including one of the image-related mixins.
"""
full_initial_tags = {
@ -554,6 +573,9 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin,
self.assertEqual(mediafile.disctotal, None)
def test_unparseable_date(self):
"""The `unparseable.*` fixture should not crash but should return None
for all parts of the release date.
"""
mediafile = self._mediafile_fixture('unparseable')
self.assertIsNone(mediafile.date)
@ -887,6 +909,29 @@ class AIFFTest(ReadWriteTestBase, unittest.TestCase):
}
# Check whether we have a Mutagen version with DSF support. We can
# remove this once we require a version that includes the feature.
try:
import mutagen.dsf # noqa
except:
HAVE_DSF = False
else:
HAVE_DSF = True
@unittest.skipIf(not HAVE_DSF, "Mutagen does not have DSF support")
class DSFTest(ReadWriteTestBase, unittest.TestCase):
extension = 'dsf'
audio_properties = {
'length': 0.01,
'bitrate': 11289600,
'format': u'DSD Stream File',
'samplerate': 5644800,
'bitdepth': 1,
'channels': 2,
}
class MediaFieldTest(unittest.TestCase):
def test_properties_from_fields(self):

View file

@ -115,15 +115,6 @@ class PlayPluginTest(unittest.TestCase, TestHelper):
open_mock.assert_not_called()
def test_warning_threshold_backwards_compat(self, open_mock):
self.config['play']['warning_treshold'] = 1
self.add_item(title=u'another NiceTitle')
with control_stdin("a"):
self.run_command(u'play', u'nice')
open_mock.assert_not_called()
def test_command_failed(self, open_mock):
open_mock.side_effect = OSError(u"some reason")

View file

@ -20,6 +20,7 @@ from __future__ import division, absolute_import, print_function
import os
import six
import shutil
import unittest
from test import _common
from beets.library import Item
@ -105,3 +106,10 @@ class ExtendedFieldTestMixin(_common.TestCase):
mediafile.MediaFile.add_field('artist', mediafile.MediaField())
self.assertIn(u'property "artist" already exists',
six.text_type(cm.exception))
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')

View file

@ -276,12 +276,12 @@ class ThumbnailsTest(unittest.TestCase, TestHelper):
if not gio.available:
self.skipTest(u"GIO library not found")
self.assertEqual(gio.uri(u"/foo"), b"file:///") # silent fail
self.assertEqual(gio.uri(b"/foo"), b"file:///foo")
self.assertEqual(gio.uri(b"/foo!"), b"file:///foo!")
self.assertEqual(gio.uri(u"/foo"), u"file:///") # silent fail
self.assertEqual(gio.uri(b"/foo"), u"file:///foo")
self.assertEqual(gio.uri(b"/foo!"), u"file:///foo!")
self.assertEqual(
gio.uri(b'/music/\xec\x8b\xb8\xec\x9d\xb4'),
b'file:///music/%EC%8B%B8%EC%9D%B4')
u'file:///music/%EC%8B%B8%EC%9D%B4')
def suite():

View file

@ -4,11 +4,12 @@
from __future__ import division, absolute_import, print_function
import json
import unittest
import os.path
from six import assertCountEqual
from test import _common
import json
from beets.library import Item, Album
from beetsplug import web
@ -21,15 +22,32 @@ class WebPluginTest(_common.LibTestCase):
# Add fixtures
for track in self.lib.items():
track.remove()
self.lib.add(Item(title=u'title', path='', id=1))
self.lib.add(Item(title=u'another title', path='', id=2))
self.lib.add(Item(title=u'title', path='/path_1', id=1))
self.lib.add(Item(title=u'another title', path='/path_2', id=2))
self.lib.add(Album(album=u'album', id=3))
self.lib.add(Album(album=u'another album', id=4))
web.app.config['TESTING'] = True
web.app.config['lib'] = self.lib
web.app.config['INCLUDE_PATHS'] = False
self.client = web.app.test_client()
def test_config_include_paths_true(self):
web.app.config['INCLUDE_PATHS'] = True
response = self.client.get('/item/1')
response.json = json.loads(response.data.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json['path'], u'/path_1')
def test_config_include_paths_false(self):
web.app.config['INCLUDE_PATHS'] = False
response = self.client.get('/item/1')
response.json = json.loads(response.data.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertNotIn('path', response.json)
def test_get_all_items(self):
response = self.client.get('/item/')
response.json = json.loads(response.data.decode('utf-8'))
@ -58,6 +76,23 @@ class WebPluginTest(_common.LibTestCase):
response = self.client.get('/item/3')
self.assertEqual(response.status_code, 404)
def test_get_single_item_by_path(self):
data_path = os.path.join(_common.RSRC, b'full.mp3')
self.lib.add(Item.from_path(data_path))
response = self.client.get('/item/path/' + data_path.decode('utf-8'))
response.json = json.loads(response.data.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json['title'], u'full')
def test_get_single_item_by_path_not_found_if_not_in_library(self):
data_path = os.path.join(_common.RSRC, b'full.mp3')
# data_path points to a valid file, but we have not added the file
# to the library.
response = self.client.get('/item/path/' + data_path.decode('utf-8'))
self.assertEqual(response.status_code, 404)
def test_get_item_empty_query(self):
response = self.client.get('/item/query/')
response.json = json.loads(response.data.decode('utf-8'))

View file

@ -4,7 +4,7 @@
# and then run "tox" from this directory.
[tox]
envlist = py27-test, py35-test, py27-flake8, docs
envlist = py27-test, py36-test, py27-flake8, docs
# The exhaustive list of environments is:
# envlist = py{27,34,35}-{test,cov}, py{27,34,35}-flake8, docs