diff --git a/appveyor.yml b/appveyor.yml index 4f350b938..938d3a5a4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -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: diff --git a/beets/__init__.py b/beets/__init__.py index 5d82b05f7..964d2592c 100644 --- a/beets/__init__.py +++ b/beets/__init__.py @@ -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 ' diff --git a/beets/__main__.py b/beets/__main__.py new file mode 100644 index 000000000..8010ca0dd --- /dev/null +++ b/beets/__main__.py @@ -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:]) diff --git a/beets/autotag/__init__.py b/beets/autotag/__init__.py index 3e79a4498..822bb60ef 100644 --- a/beets/autotag/__init__.py +++ b/beets/autotag/__init__.py @@ -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 diff --git a/beets/autotag/hooks.py b/beets/autotag/hooks.py index 40db6e8d3..3c403fcf4 100644 --- a/beets/autotag/hooks.py +++ b/beets/autotag/hooks.py @@ -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'): diff --git a/beets/autotag/match.py b/beets/autotag/match.py index 71d80e821..71b62adb7 100644 --- a/beets/autotag/match.py +++ b/beets/autotag/match.py @@ -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: diff --git a/beets/autotag/mb.py b/beets/autotag/mb.py index 55ecfc185..a6133adb1 100644 --- a/beets/autotag/mb.py +++ b/beets/autotag/mb.py @@ -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) diff --git a/beets/config_default.yaml b/beets/config_default.yaml index fa77a82dc..3b0377966 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -6,6 +6,7 @@ import: copy: yes move: no link: no + hardlink: no delete: no resume: ask incremental: no diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index d01e8a5c3..6b0ed8b43 100755 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -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 diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index d27897e69..470ca2ac6 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -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): diff --git a/beets/importer.py b/beets/importer.py index 6a10f4c97..690a499ff 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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, ) diff --git a/beets/library.py b/beets/library.py index 32176b68d..b263ecd64 100644 --- a/beets/library.py +++ b/beets/library.py @@ -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 diff --git a/beets/mediafile.py b/beets/mediafile.py index 0f595a36a..13f1b2dfb 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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', diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index ae30a9c60..df370b52e 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -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. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 168f0d515..8a07f6147 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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)" diff --git a/beets/util/__init__.py b/beets/util/__init__.py index a33d0e02e..f6cd488d6 100644 --- a/beets/util/__init__.py +++ b/beets/util/__init__.py @@ -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 diff --git a/beets/util/artresizer.py b/beets/util/artresizer.py index 4c2e92532..e84b775dc 100644 --- a/beets/util/artresizer.py +++ b/beets/util/artresizer.py @@ -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') diff --git a/beetsplug/absubmit.py b/beetsplug/absubmit.py index 646f0fb84..e30fe21fa 100644 --- a/beetsplug/absubmit.py +++ b/beetsplug/absubmit.py @@ -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() diff --git a/beetsplug/badfiles.py b/beetsplug/badfiles.py index 5ab0ae983..62c6d8af5 100644 --- a/beetsplug/badfiles.py +++ b/beetsplug/badfiles.py @@ -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] diff --git a/beetsplug/beatport.py b/beetsplug/beatport.py index c62abf7ab..8a73efa16 100644 --- a/beetsplug/beatport.py +++ b/beetsplug/beatport.py @@ -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): diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index fffa8a6ed..705692aa5 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -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) diff --git a/beetsplug/discogs.py b/beetsplug/discogs.py index cf10a2a36..0957b3403 100644 --- a/beetsplug/discogs.py +++ b/beetsplug/discogs.py @@ -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'] diff --git a/beetsplug/duplicates.py b/beetsplug/duplicates.py index 5ca314d3f..93d53c58a 100644 --- a/beetsplug/duplicates.py +++ b/beetsplug/duplicates.py @@ -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) diff --git a/beetsplug/embedart.py b/beetsplug/embedart.py index 948da6291..51d401af8 100644 --- a/beetsplug/embedart.py +++ b/beetsplug/embedart.py @@ -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) diff --git a/beetsplug/embyupdate.py b/beetsplug/embyupdate.py index 3af285973..5c731954b 100644 --- a/beetsplug/embyupdate.py +++ b/beetsplug/embyupdate.py @@ -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: diff --git a/beetsplug/fetchart.py b/beetsplug/fetchart.py index 27ffa49cb..91b77c0a4 100644 --- a/beetsplug/fetchart.py +++ b/beetsplug/fetchart.py @@ -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: PREFIX dbpprop: PREFIX owl: @@ -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 diff --git a/beetsplug/importadded.py b/beetsplug/importadded.py index 07434ee72..c1838884b 100644 --- a/beetsplug/importadded.py +++ b/beetsplug/importadded.py @@ -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] \ diff --git a/beetsplug/kodiupdate.py b/beetsplug/kodiupdate.py new file mode 100644 index 000000000..78f120d89 --- /dev/null +++ b/beetsplug/kodiupdate.py @@ -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.') diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index 0ed9daf3c..d7b84b0aa 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -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): diff --git a/beetsplug/lyrics.py b/beetsplug/lyrics.py index a642159c2..6714b2fee 100644 --- a/beetsplug/lyrics.py +++ b/beetsplug/lyrics.py @@ -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}) diff --git a/beetsplug/mbcollection.py b/beetsplug/mbcollection.py index a590a7605..909e6a0a5 100644 --- a/beetsplug/mbcollection.py +++ b/beetsplug/mbcollection.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2011, Jeffrey Aylesworth +# This file is part of beets. +# Copyright (c) 2011, Jeffrey Aylesworth # -# 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 diff --git a/beetsplug/mbsubmit.py b/beetsplug/mbsubmit.py index 58b357dd3..02bd5f697 100644 --- a/beetsplug/mbsubmit.py +++ b/beetsplug/mbsubmit.py @@ -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()) diff --git a/beetsplug/mpdstats.py b/beetsplug/mpdstats.py index 89b99a9df..bcc3da808 100644 --- a/beetsplug/mpdstats.py +++ b/beetsplug/mpdstats.py @@ -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() diff --git a/beetsplug/mpdupdate.py b/beetsplug/mpdupdate.py index 6c39375be..6ecc92131 100644 --- a/beetsplug/mpdupdate.py +++ b/beetsplug/mpdupdate.py @@ -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.') diff --git a/beetsplug/play.py b/beetsplug/play.py index 4a2174909..636d98d46 100644 --- a/beetsplug/play.py +++ b/beetsplug/play.py @@ -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) diff --git a/beetsplug/replaygain.py b/beetsplug/replaygain.py index 2d3af58a6..4cf7da7c5 100644 --- a/beetsplug/replaygain.py +++ b/beetsplug/replaygain.py @@ -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: diff --git a/beetsplug/smartplaylist.py b/beetsplug/smartplaylist.py index 514e74acc..009512c5c 100644 --- a/beetsplug/smartplaylist.py +++ b/beetsplug/smartplaylist.py @@ -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)) diff --git a/beetsplug/thumbnails.py b/beetsplug/thumbnails.py index 1ea90f01e..838206156 100644 --- a/beetsplug/thumbnails.py +++ b/beetsplug/thumbnails.py @@ -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) + ) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index e7b9ec81f..bd4677bd8 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -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/') +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/') 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}', diff --git a/docs/changelog.rst b/docs/changelog.rst index c5113bdfa..cb2934874 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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) diff --git a/docs/conf.py b/docs/conf.py index 99771e7b1..9573b2fba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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' diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index fb063aee0..4d41c8971 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -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). diff --git a/docs/guides/main.rst b/docs/guides/main.rst index 7feacb6bf..f0b3635c2 100644 --- a/docs/guides/main.rst +++ b/docs/guides/main.rst @@ -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 ` 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:: diff --git a/docs/guides/tagger.rst b/docs/guides/tagger.rst index 6c0e44f9e..4c9df42f8 100644 --- a/docs/guides/tagger.rst +++ b/docs/guides/tagger.rst @@ -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 diff --git a/docs/plugins/absubmit.rst b/docs/plugins/absubmit.rst index a8cd1b380..665133c64 100644 --- a/docs/plugins/absubmit.rst +++ b/docs/plugins/absubmit.rst @@ -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 ------------- diff --git a/docs/plugins/badfiles.rst b/docs/plugins/badfiles.rst index 80f543c54..0a32f1a36 100644 --- a/docs/plugins/badfiles.rst +++ b/docs/plugins/badfiles.rst @@ -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. diff --git a/docs/plugins/convert.rst b/docs/plugins/convert.rst index f2f28c63d..b8069506d 100644 --- a/docs/plugins/convert.rst +++ b/docs/plugins/convert.rst @@ -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: diff --git a/docs/plugins/discogs.rst b/docs/plugins/discogs.rst index eb60cca58..a02b34590 100644 --- a/docs/plugins/discogs.rst +++ b/docs/plugins/discogs.rst @@ -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 +`_. 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 --------------- diff --git a/docs/plugins/embedart.rst b/docs/plugins/embedart.rst index 94e91d995..68ea0f664 100644 --- a/docs/plugins/embedart.rst +++ b/docs/plugins/embedart.rst @@ -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 diff --git a/docs/plugins/embyupdate.rst b/docs/plugins/embyupdate.rst index 8dbb7c1db..00373b98c 100644 --- a/docs/plugins/embyupdate.rst +++ b/docs/plugins/embyupdate.rst @@ -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 diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 0e851d7da..0b2440aa8 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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 + diff --git a/docs/plugins/kodiupdate.rst b/docs/plugins/kodiupdate.rst new file mode 100644 index 000000000..a1ec04775 --- /dev/null +++ b/docs/plugins/kodiupdate.rst @@ -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`` diff --git a/docs/plugins/play.rst b/docs/plugins/play.rst index 1ac51a1f3..9b9110bde 100644 --- a/docs/plugins/play.rst +++ b/docs/plugins/play.rst @@ -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 ------------- diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst index f4ae063e0..9f1bbcd99 100644 --- a/docs/plugins/web.rst +++ b/docs/plugins/web.rst @@ -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`` +++++++++++++++++++++++++++++++ diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index 92ddc14d0..59e2eeb68 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -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). diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 8f009fc3f..094462d2f 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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. diff --git a/docs/reference/pathformat.rst b/docs/reference/pathformat.rst index 72453a6e5..667be3150 100644 --- a/docs/reference/pathformat.rst +++ b/docs/reference/pathformat.rst @@ -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 diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 2f3366d4c..b4789aa10 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -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 ------- diff --git a/extra/beets.reg b/extra/beets.reg index 30fb6e0e4..c02303d3d 100644 Binary files a/extra/beets.reg and b/extra/beets.reg differ diff --git a/setup.py b/setup.py index f88fe28b6..148643837 100755 --- a/setup.py +++ b/setup.py @@ -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'] + diff --git a/test/_common.py b/test/_common.py index 2e7418516..f3213ec31 100644 --- a/test/_common.py +++ b/test/_common.py @@ -54,6 +54,7 @@ _item_ident = 0 # OS feature test. HAVE_SYMLINK = sys.platform != 'win32' +HAVE_HARDLINK = sys.platform != 'win32' def item(lib=None): diff --git a/test/rsrc/convert_stub.py b/test/rsrc/convert_stub.py index 3ca71352a..f32bce09a 100755 --- a/test/rsrc/convert_stub.py +++ b/test/rsrc/convert_stub.py @@ -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') diff --git a/test/rsrc/empty.dsf b/test/rsrc/empty.dsf new file mode 100644 index 000000000..4cbceb3c9 Binary files /dev/null and b/test/rsrc/empty.dsf differ diff --git a/test/rsrc/full.dsf b/test/rsrc/full.dsf new file mode 100644 index 000000000..a90e6946f Binary files /dev/null and b/test/rsrc/full.dsf differ diff --git a/test/rsrc/unparseable.dsf b/test/rsrc/unparseable.dsf new file mode 100644 index 000000000..3b6292e32 Binary files /dev/null and b/test/rsrc/unparseable.dsf differ diff --git a/test/test_art.py b/test/test_art.py index aba180780..6b0e5bbc5 100644 --- a/test/test_art.py +++ b/test/test_art.py @@ -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') diff --git a/test/test_embedart.py b/test/test_embedart.py index ee08ecb4e..1622fffb4 100644 --- a/test/test_embedart.py +++ b/test/test_embedart.py @@ -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, diff --git a/test/test_files.py b/test/test_files.py index b566f363e..834d3391c 100644 --- a/test/test_files.py +++ b/test/test_files.py @@ -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): diff --git a/test/test_importadded.py b/test/test_importadded.py index 52aa26756..dd933c3c8 100644 --- a/test/test_importadded.py +++ b/test/test_importadded.py @@ -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): diff --git a/test/test_importer.py b/test/test_importer.py index 500ca027d..26dec3de8 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -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, }], diff --git a/test/test_library.py b/test/test_library.py index 7aa88e064..aaab6fe03 100644 --- a/test/test_library.py +++ b/test/test_library.py @@ -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): diff --git a/test/test_mb.py b/test/test_mb.py index ecbcf3c59..ca1bf2a1a 100644 --- a/test/test_mb.py +++ b/test/test_mb.py @@ -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, }], diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 5fcb14187..63df38b8e 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -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): diff --git a/test/test_play.py b/test/test_play.py index 9d66d7466..86fef99a9 100644 --- a/test/test_play.py +++ b/test/test_play.py @@ -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") diff --git a/test/test_plugin_mediafield.py b/test/test_plugin_mediafield.py index d9d2b0a1f..983f6e2c8 100644 --- a/test/test_plugin_mediafield.py +++ b/test/test_plugin_mediafield.py @@ -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') diff --git a/test/test_thumbnails.py b/test/test_thumbnails.py index d287c073a..dc03f06f7 100644 --- a/test/test_thumbnails.py +++ b/test/test_thumbnails.py @@ -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(): diff --git a/test/test_web.py b/test/test_web.py index e72ecf33d..98347d8af 100644 --- a/test/test_web.py +++ b/test/test_web.py @@ -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')) diff --git a/tox.ini b/tox.ini index 8115395c6..43bff8014 100644 --- a/tox.ini +++ b/tox.ini @@ -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