mirror of
https://github.com/beetbox/beets.git
synced 2025-12-15 04:55:10 +01:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
aab84413ec
78 changed files with 1201 additions and 402 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import os
|
|||
|
||||
from beets.util import confit
|
||||
|
||||
__version__ = u'1.4.3'
|
||||
__version__ = u'1.4.4'
|
||||
__author__ = u'Adrian Sampson <adrian@radbox.org>'
|
||||
|
||||
|
||||
|
|
|
|||
26
beets/__main__.py
Normal file
26
beets/__main__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2017, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""The __main__ module lets you run the beets CLI interface by typing
|
||||
`python -m beets`.
|
||||
"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import sys
|
||||
from .ui import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import:
|
|||
copy: yes
|
||||
move: no
|
||||
link: no
|
||||
hardlink: no
|
||||
delete: no
|
||||
resume: ask
|
||||
incremental: no
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class Candidate(object):
|
|||
self.match = match
|
||||
self.size = size
|
||||
|
||||
def _validate(self, extra):
|
||||
def _validate(self, plugin):
|
||||
"""Determine whether the candidate artwork is valid based on
|
||||
its dimensions (width and ratio).
|
||||
|
||||
|
|
@ -80,9 +80,7 @@ class Candidate(object):
|
|||
if not self.path:
|
||||
return self.CANDIDATE_BAD
|
||||
|
||||
if not (extra['enforce_ratio'] or
|
||||
extra['minwidth'] or
|
||||
extra['maxwidth']):
|
||||
if not (plugin.enforce_ratio or plugin.minwidth or plugin.maxwidth):
|
||||
return self.CANDIDATE_EXACT
|
||||
|
||||
# get_size returns None if no local imaging backend is available
|
||||
|
|
@ -101,22 +99,22 @@ class Candidate(object):
|
|||
long_edge = max(self.size)
|
||||
|
||||
# Check minimum size.
|
||||
if extra['minwidth'] and self.size[0] < extra['minwidth']:
|
||||
if plugin.minwidth and self.size[0] < plugin.minwidth:
|
||||
self._log.debug(u'image too small ({} < {})',
|
||||
self.size[0], extra['minwidth'])
|
||||
self.size[0], plugin.minwidth)
|
||||
return self.CANDIDATE_BAD
|
||||
|
||||
# Check aspect ratio.
|
||||
edge_diff = long_edge - short_edge
|
||||
if extra['enforce_ratio']:
|
||||
if extra['margin_px']:
|
||||
if edge_diff > extra['margin_px']:
|
||||
if plugin.enforce_ratio:
|
||||
if plugin.margin_px:
|
||||
if edge_diff > plugin.margin_px:
|
||||
self._log.debug(u'image is not close enough to being '
|
||||
u'square, ({} - {} > {})',
|
||||
long_edge, short_edge, extra['margin_px'])
|
||||
long_edge, short_edge, plugin.margin_px)
|
||||
return self.CANDIDATE_BAD
|
||||
elif extra['margin_percent']:
|
||||
margin_px = extra['margin_percent'] * long_edge
|
||||
elif plugin.margin_percent:
|
||||
margin_px = plugin.margin_percent * long_edge
|
||||
if edge_diff > margin_px:
|
||||
self._log.debug(u'image is not close enough to being '
|
||||
u'square, ({} - {} > {})',
|
||||
|
|
@ -129,20 +127,20 @@ class Candidate(object):
|
|||
return self.CANDIDATE_BAD
|
||||
|
||||
# Check maximum size.
|
||||
if extra['maxwidth'] and self.size[0] > extra['maxwidth']:
|
||||
if plugin.maxwidth and self.size[0] > plugin.maxwidth:
|
||||
self._log.debug(u'image needs resizing ({} > {})',
|
||||
self.size[0], extra['maxwidth'])
|
||||
self.size[0], plugin.maxwidth)
|
||||
return self.CANDIDATE_DOWNSCALE
|
||||
|
||||
return self.CANDIDATE_EXACT
|
||||
|
||||
def validate(self, extra):
|
||||
self.check = self._validate(extra)
|
||||
def validate(self, plugin):
|
||||
self.check = self._validate(plugin)
|
||||
return self.check
|
||||
|
||||
def resize(self, extra):
|
||||
if extra['maxwidth'] and self.check == self.CANDIDATE_DOWNSCALE:
|
||||
self.path = ArtResizer.shared.resize(extra['maxwidth'], self.path)
|
||||
def resize(self, plugin):
|
||||
if plugin.maxwidth and self.check == self.CANDIDATE_DOWNSCALE:
|
||||
self.path = ArtResizer.shared.resize(plugin.maxwidth, self.path)
|
||||
|
||||
|
||||
def _logged_get(log, *args, **kwargs):
|
||||
|
|
@ -198,13 +196,13 @@ class ArtSource(RequestMixin):
|
|||
self._log = log
|
||||
self._config = config
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
raise NotImplementedError()
|
||||
|
||||
def _candidate(self, **kwargs):
|
||||
return Candidate(source=self, log=self._log, **kwargs)
|
||||
|
||||
def fetch_image(self, candidate, extra):
|
||||
def fetch_image(self, candidate, plugin):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
|
@ -212,7 +210,7 @@ class LocalArtSource(ArtSource):
|
|||
IS_LOCAL = True
|
||||
LOC_STR = u'local'
|
||||
|
||||
def fetch_image(self, candidate, extra):
|
||||
def fetch_image(self, candidate, plugin):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -220,13 +218,13 @@ class RemoteArtSource(ArtSource):
|
|||
IS_LOCAL = False
|
||||
LOC_STR = u'remote'
|
||||
|
||||
def fetch_image(self, candidate, extra):
|
||||
def fetch_image(self, candidate, plugin):
|
||||
"""Downloads an image from a URL and checks whether it seems to
|
||||
actually be an image. If so, returns a path to the downloaded image.
|
||||
Otherwise, returns None.
|
||||
"""
|
||||
if extra['maxwidth']:
|
||||
candidate.url = ArtResizer.shared.proxy_url(extra['maxwidth'],
|
||||
if plugin.maxwidth:
|
||||
candidate.url = ArtResizer.shared.proxy_url(plugin.maxwidth,
|
||||
candidate.url)
|
||||
try:
|
||||
with closing(self.request(candidate.url, stream=True,
|
||||
|
|
@ -292,10 +290,14 @@ class RemoteArtSource(ArtSource):
|
|||
class CoverArtArchive(RemoteArtSource):
|
||||
NAME = u"Cover Art Archive"
|
||||
|
||||
URL = 'http://coverartarchive.org/release/{mbid}/front'
|
||||
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
|
||||
if util.SNI_SUPPORTED:
|
||||
URL = 'https://coverartarchive.org/release/{mbid}/front'
|
||||
GROUP_URL = 'https://coverartarchive.org/release-group/{mbid}/front'
|
||||
else:
|
||||
URL = 'http://coverartarchive.org/release/{mbid}/front'
|
||||
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front'
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
"""Return the Cover Art Archive and Cover Art Archive release group URLs
|
||||
using album MusicBrainz release ID and release group ID.
|
||||
"""
|
||||
|
|
@ -313,7 +315,7 @@ class Amazon(RemoteArtSource):
|
|||
URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||
INDICES = (1, 2)
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
"""Generate URLs using Amazon ID (ASIN) string.
|
||||
"""
|
||||
if album.asin:
|
||||
|
|
@ -327,7 +329,7 @@ class AlbumArtOrg(RemoteArtSource):
|
|||
URL = 'http://www.albumart.org/index_detail.php'
|
||||
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
"""Return art URL from AlbumArt.org using album ASIN.
|
||||
"""
|
||||
if not album.asin:
|
||||
|
|
@ -358,7 +360,7 @@ class GoogleImages(RemoteArtSource):
|
|||
self.key = self._config['google_key'].get(),
|
||||
self.cx = self._config['google_engine'].get(),
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
"""Return art URL from google custom search engine
|
||||
given an album title and interpreter.
|
||||
"""
|
||||
|
|
@ -394,8 +396,7 @@ class GoogleImages(RemoteArtSource):
|
|||
class FanartTV(RemoteArtSource):
|
||||
"""Art from fanart.tv requested using their API"""
|
||||
NAME = u"fanart.tv"
|
||||
|
||||
API_URL = 'http://webservice.fanart.tv/v3/'
|
||||
API_URL = 'https://webservice.fanart.tv/v3/'
|
||||
API_ALBUMS = API_URL + 'music/albums/'
|
||||
PROJECT_KEY = '61a7d0ab4e67162b7a0c7c35915cd48e'
|
||||
|
||||
|
|
@ -403,7 +404,7 @@ class FanartTV(RemoteArtSource):
|
|||
super(FanartTV, self).__init__(*args, **kwargs)
|
||||
self.client_key = self._config['fanarttv_key'].get()
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
if not album.mb_releasegroupid:
|
||||
return
|
||||
|
||||
|
|
@ -454,7 +455,7 @@ class FanartTV(RemoteArtSource):
|
|||
class ITunesStore(RemoteArtSource):
|
||||
NAME = u"iTunes Store"
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
"""Return art URL from iTunes Store given an album title.
|
||||
"""
|
||||
if not (album.albumartist and album.album):
|
||||
|
|
@ -488,8 +489,8 @@ class ITunesStore(RemoteArtSource):
|
|||
|
||||
class Wikipedia(RemoteArtSource):
|
||||
NAME = u"Wikipedia (queried through DBpedia)"
|
||||
DBPEDIA_URL = 'http://dbpedia.org/sparql'
|
||||
WIKIPEDIA_URL = 'http://en.wikipedia.org/w/api.php'
|
||||
DBPEDIA_URL = 'https://dbpedia.org/sparql'
|
||||
WIKIPEDIA_URL = 'https://en.wikipedia.org/w/api.php'
|
||||
SPARQL_QUERY = u'''PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX dbpprop: <http://dbpedia.org/property/>
|
||||
PREFIX owl: <http://dbpedia.org/ontology/>
|
||||
|
|
@ -512,7 +513,7 @@ class Wikipedia(RemoteArtSource):
|
|||
}}
|
||||
Limit 1'''
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
if not (album.albumartist and album.album):
|
||||
return
|
||||
|
||||
|
|
@ -624,16 +625,14 @@ class FileSystem(LocalArtSource):
|
|||
"""
|
||||
return [idx for (idx, x) in enumerate(cover_names) if x in filename]
|
||||
|
||||
def get(self, album, extra):
|
||||
def get(self, album, plugin, paths):
|
||||
"""Look for album art files in the specified directories.
|
||||
"""
|
||||
paths = extra['paths']
|
||||
if not paths:
|
||||
return
|
||||
cover_names = list(map(util.bytestring_path, extra['cover_names']))
|
||||
cover_names = list(map(util.bytestring_path, plugin.cover_names))
|
||||
cover_names_str = b'|'.join(cover_names)
|
||||
cover_pat = br''.join([br"(\b|_)(", cover_names_str, br")(\b|_)"])
|
||||
cautious = extra['cautious']
|
||||
|
||||
for path in paths:
|
||||
if not os.path.isdir(syspath(path)):
|
||||
|
|
@ -663,7 +662,7 @@ class FileSystem(LocalArtSource):
|
|||
remaining.append(fn)
|
||||
|
||||
# Fall back to any image in the folder.
|
||||
if remaining and not cautious:
|
||||
if remaining and not plugin.cautious:
|
||||
self._log.debug(u'using fallback art file {0}',
|
||||
util.displayable_path(remaining[0]))
|
||||
yield self._candidate(path=os.path.join(path, remaining[0]),
|
||||
|
|
@ -842,16 +841,6 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
"""
|
||||
out = None
|
||||
|
||||
# all the information any of the sources might need
|
||||
extra = {'paths': paths,
|
||||
'cover_names': self.cover_names,
|
||||
'cautious': self.cautious,
|
||||
'enforce_ratio': self.enforce_ratio,
|
||||
'margin_px': self.margin_px,
|
||||
'margin_percent': self.margin_percent,
|
||||
'minwidth': self.minwidth,
|
||||
'maxwidth': self.maxwidth}
|
||||
|
||||
for source in self.sources:
|
||||
if source.IS_LOCAL or not local_only:
|
||||
self._log.debug(
|
||||
|
|
@ -861,9 +850,9 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
)
|
||||
# URLs might be invalid at this point, or the image may not
|
||||
# fulfill the requirements
|
||||
for candidate in source.get(album, extra):
|
||||
source.fetch_image(candidate, extra)
|
||||
if candidate.validate(extra):
|
||||
for candidate in source.get(album, self, paths):
|
||||
source.fetch_image(candidate, self)
|
||||
if candidate.validate(self):
|
||||
out = candidate
|
||||
self._log.debug(
|
||||
u'using {0.LOC_STR} image {1}'.format(
|
||||
|
|
@ -873,7 +862,7 @@ class FetchArtPlugin(plugins.BeetsPlugin, RequestMixin):
|
|||
break
|
||||
|
||||
if out:
|
||||
out.resize(extra)
|
||||
out.resize(self)
|
||||
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class ImportAddedPlugin(BeetsPlugin):
|
|||
register('before_item_moved', self.record_import_mtime)
|
||||
register('item_copied', self.record_import_mtime)
|
||||
register('item_linked', self.record_import_mtime)
|
||||
register('item_hardlinked', self.record_import_mtime)
|
||||
register('album_imported', self.update_album_times)
|
||||
register('item_imported', self.update_item_times)
|
||||
register('after_write', self.update_after_write_time)
|
||||
|
|
@ -51,7 +52,7 @@ class ImportAddedPlugin(BeetsPlugin):
|
|||
|
||||
def record_if_inplace(self, task, session):
|
||||
if not (session.config['copy'] or session.config['move'] or
|
||||
session.config['link']):
|
||||
session.config['link'] or session.config['hardlink']):
|
||||
self._log.debug(u"In place import detected, recording mtimes from "
|
||||
u"source paths")
|
||||
items = [task.item] \
|
||||
|
|
|
|||
87
beetsplug/kodiupdate.py
Normal file
87
beetsplug/kodiupdate.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2017, Pauli Kettunen.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Updates a Kodi library whenever the beets library is changed.
|
||||
This is based on the Plex Update plugin.
|
||||
|
||||
Put something like the following in your config.yaml to configure:
|
||||
kodi:
|
||||
host: localhost
|
||||
port: 8080
|
||||
user: user
|
||||
pwd: secret
|
||||
"""
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
import requests
|
||||
from beets import config
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
|
||||
def update_kodi(host, port, user, password):
|
||||
"""Sends request to the Kodi api to start a library refresh.
|
||||
"""
|
||||
url = "http://{0}:{1}/jsonrpc/".format(host, port)
|
||||
|
||||
"""Content-Type: application/json is mandatory
|
||||
according to the kodi jsonrpc documentation"""
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
|
||||
# Create the payload. Id seems to be mandatory.
|
||||
payload = {'jsonrpc': '2.0', 'method': 'AudioLibrary.Scan', 'id': 1}
|
||||
r = requests.post(
|
||||
url,
|
||||
auth=(user, password),
|
||||
json=payload,
|
||||
headers=headers)
|
||||
|
||||
return r
|
||||
|
||||
|
||||
class KodiUpdate(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(KodiUpdate, self).__init__()
|
||||
|
||||
# Adding defaults.
|
||||
config['kodi'].add({
|
||||
u'host': u'localhost',
|
||||
u'port': 8080,
|
||||
u'user': u'kodi',
|
||||
u'pwd': u'kodi'})
|
||||
|
||||
config['kodi']['pwd'].redact = True
|
||||
self.register_listener('database_change', self.listen_for_db_change)
|
||||
|
||||
def listen_for_db_change(self, lib, model):
|
||||
"""Listens for beets db change and register the update"""
|
||||
self.register_listener('cli_exit', self.update)
|
||||
|
||||
def update(self, lib):
|
||||
"""When the client exists try to send refresh request to Kodi server.
|
||||
"""
|
||||
self._log.info(u'Updating Kodi library...')
|
||||
|
||||
# Try to send update request.
|
||||
try:
|
||||
update_kodi(
|
||||
config['kodi']['host'].get(),
|
||||
config['kodi']['port'].get(),
|
||||
config['kodi']['user'].get(),
|
||||
config['kodi']['pwd'].get())
|
||||
self._log.info(u'... started.')
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
self._log.warning(u'Update failed.')
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2011, Jeffrey Aylesworth <jeffrey@aylesworth.ca>
|
||||
# This file is part of beets.
|
||||
# Copyright (c) 2011, Jeffrey Aylesworth <mail@jeffrey.red>
|
||||
#
|
||||
# Permission to use, copy, modify, and/or distribute this software for any
|
||||
# purpose with or without fee is hereby granted, provided that the above
|
||||
# copyright notice and this permission notice appear in all copies.
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ def _rep(obj, expand=False):
|
|||
out = dict(obj)
|
||||
|
||||
if isinstance(obj, beets.library.Item):
|
||||
del out['path']
|
||||
if app.config.get('INCLUDE_PATHS', False):
|
||||
out['path'] = util.displayable_path(out['path'])
|
||||
else:
|
||||
del out['path']
|
||||
|
||||
# Get the size (in bytes) of the backing file. This is useful
|
||||
# for the Tomahawk resolver API.
|
||||
|
|
@ -173,11 +176,16 @@ class QueryConverter(PathConverter):
|
|||
return ','.join(value)
|
||||
|
||||
|
||||
class EverythingConverter(PathConverter):
|
||||
regex = '.*?'
|
||||
|
||||
|
||||
# Flask setup.
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
app.url_map.converters['idlist'] = IdListConverter
|
||||
app.url_map.converters['query'] = QueryConverter
|
||||
app.url_map.converters['everything'] = EverythingConverter
|
||||
|
||||
|
||||
@app.before_request
|
||||
|
|
@ -218,6 +226,16 @@ def item_query(queries):
|
|||
return g.lib.items(queries)
|
||||
|
||||
|
||||
@app.route('/item/path/<everything:path>')
|
||||
def item_at_path(path):
|
||||
query = beets.library.PathQuery('path', path.encode('utf-8'))
|
||||
item = g.lib.items(query).get()
|
||||
if item:
|
||||
return flask.jsonify(_rep(item))
|
||||
else:
|
||||
return flask.abort(404)
|
||||
|
||||
|
||||
@app.route('/item/values/<string:key>')
|
||||
def item_unique_field_values(key):
|
||||
sort_key = flask.request.args.get('sort_key', key)
|
||||
|
|
@ -309,6 +327,7 @@ class WebPlugin(BeetsPlugin):
|
|||
'host': u'127.0.0.1',
|
||||
'port': 8337,
|
||||
'cors': '',
|
||||
'include_paths': False,
|
||||
})
|
||||
|
||||
def commands(self):
|
||||
|
|
@ -327,6 +346,8 @@ class WebPlugin(BeetsPlugin):
|
|||
# Normalizes json output
|
||||
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
|
||||
|
||||
app.config['INCLUDE_PATHS'] = self.config['include_paths']
|
||||
|
||||
# Enable CORS if required.
|
||||
if self.config['cors']:
|
||||
self._log.info(u'Enabling CORS with origin: {0}',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ into this if you've installed Python yourself with `Homebrew`_ or otherwise.)
|
|||
|
||||
If this happens, you can install beets for the current user only (sans
|
||||
``sudo``) by typing ``pip install --user beets``. If you do that, you might want
|
||||
to add ``~/Library/Python/2.7/bin`` to your ``$PATH``.
|
||||
to add ``~/Library/Python/3.6/bin`` to your ``$PATH``.
|
||||
|
||||
.. _System Integrity Protection: https://support.apple.com/en-us/HT204899
|
||||
.. _Homebrew: http://brew.sh
|
||||
|
|
@ -93,28 +93,28 @@ Installing on Windows
|
|||
Installing beets on Windows can be tricky. Following these steps might help you
|
||||
get it right:
|
||||
|
||||
1. If you don't have it, `install Python`_ (you want Python 2.7).
|
||||
1. If you don't have it, `install Python`_ (you want Python 3.6). The
|
||||
installer should give you the option to "add Python to PATH." Check this
|
||||
box. If you do that, you can skip the next step.
|
||||
|
||||
2. If you haven't done so already, set your ``PATH`` environment variable to
|
||||
include Python and its scripts. To do so, you have to get the "Properties"
|
||||
window for "My Computer", then choose the "Advanced" tab, then hit the
|
||||
"Environment Variables" button, and then look for the ``PATH`` variable in
|
||||
the table. Add the following to the end of the variable's value:
|
||||
``;C:\Python27;C:\Python27\Scripts``.
|
||||
``;C:\Python36;C:\Python36\Scripts``. You may need to adjust these paths to
|
||||
point to your Python installation.
|
||||
|
||||
3. Next, `install pip`_ (if you don't have it already) by downloading and
|
||||
running the `get-pip.py`_ script.
|
||||
3. Now install beets by running: ``pip install beets``
|
||||
|
||||
4. Now install beets by running: ``pip install beets``
|
||||
|
||||
5. You're all set! Type ``beet`` at the command prompt to make sure everything's
|
||||
4. You're all set! Type ``beet`` at the command prompt to make sure everything's
|
||||
in order.
|
||||
|
||||
Windows users may also want to install a context menu item for importing files
|
||||
into beets. Just download and open `beets.reg`_ to add the necessary keys to the
|
||||
registry. You can then right-click a directory and choose "Import with beets".
|
||||
If Python is in a nonstandard location on your system, you may have to edit the
|
||||
command path manually.
|
||||
into beets. Download the `beets.reg`_ file and open it in a text file to make
|
||||
sure the paths to Python match your system. Then double-click the file add the
|
||||
necessary keys to your registry. You can then right-click a directory and
|
||||
choose "Import with beets".
|
||||
|
||||
Because I don't use Windows myself, I may have missed something. If you have
|
||||
trouble or you have more detail to contribute here, please direct it to
|
||||
|
|
@ -142,8 +142,8 @@ place to start::
|
|||
Change that first path to a directory where you'd like to keep your music. Then,
|
||||
for ``library``, choose a good place to keep a database file that keeps an index
|
||||
of your music. (The config's format is `YAML`_. You'll want to configure your
|
||||
text editor to use spaces, not real tabs, for indentation.)
|
||||
|
||||
text editor to use spaces, not real tabs, for indentation. Also, ``~`` means
|
||||
your home directory in these paths, even on Windows.)
|
||||
|
||||
The default configuration assumes you want to start a new organized music folder
|
||||
(that ``directory`` above) and that you'll *copy* cleaned-up music into that
|
||||
|
|
@ -238,21 +238,30 @@ songs. Thus::
|
|||
$ beet ls album:bird
|
||||
The Mae Shi - Terrorbird - Revelation Six
|
||||
|
||||
As you can see, search terms by default search all attributes of songs. (They're
|
||||
By default, a search term will match any of a handful of :ref:`common
|
||||
attributes <keywordquery>` of songs.
|
||||
(They're
|
||||
also implicitly joined by ANDs: a track must match *all* criteria in order to
|
||||
match the query.) To narrow a search term to a particular metadata field, just
|
||||
put the field before the term, separated by a : character. So ``album:bird``
|
||||
only looks for ``bird`` in the "album" field of your songs. (Need to know more?
|
||||
:doc:`/reference/query/` will answer all your questions.)
|
||||
|
||||
The ``beet list`` command has another useful option worth mentioning, ``-a``,
|
||||
which searches for albums instead of songs::
|
||||
The ``beet list`` command also has an ``-a`` option, which searches for albums instead of songs::
|
||||
|
||||
$ beet ls -a forever
|
||||
Bon Iver - For Emma, Forever Ago
|
||||
Freezepop - Freezepop Forever
|
||||
|
||||
So handy!
|
||||
There's also an ``-f`` option (for *format*) that lets you specify what gets displayed in the results of a search::
|
||||
|
||||
$ beet ls -a forever -f "[$format] $album ($year) - $artist - $title"
|
||||
[MP3] For Emma, Forever Ago (2009) - Bon Iver - Flume
|
||||
[AAC] Freezepop Forever (2011) - Freezepop - Harebrained Scheme
|
||||
|
||||
In the format option, field references like `$format` and `$year` are filled
|
||||
in with data from each result. You can see a full list of available fields by
|
||||
running ``beet fields``.
|
||||
|
||||
Beets also has a ``stats`` command, just in case you want to see how much music
|
||||
you have::
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@ To use the ``discogs`` plugin, first enable it in your configuration (see
|
|||
|
||||
pip install discogs-client
|
||||
|
||||
You will also need to register for a `Discogs`_ account. The first time you
|
||||
run the :ref:`import-cmd` command after enabling the plugin, it will ask you
|
||||
to authorize with Discogs by visiting the site in a browser. Subsequent runs
|
||||
will not require re-authorization.
|
||||
You will also need to register for a `Discogs`_ account, and provide
|
||||
authentication credentials via a personal access token or an OAuth2
|
||||
authorization.
|
||||
|
||||
Matches from Discogs will now show up during import alongside matches from
|
||||
MusicBrainz.
|
||||
|
|
@ -25,6 +24,25 @@ MusicBrainz.
|
|||
If you have a Discogs ID for an album you want to tag, you can also enter it
|
||||
at the "enter Id" prompt in the importer.
|
||||
|
||||
OAuth Authorization
|
||||
```````````````````
|
||||
|
||||
The first time you run the :ref:`import-cmd` command after enabling the plugin,
|
||||
it will ask you to authorize with Discogs by visiting the site in a browser.
|
||||
Subsequent runs will not require re-authorization.
|
||||
|
||||
Authentication via Personal Access Token
|
||||
````````````````````````````````````````
|
||||
|
||||
As an alternative to OAuth, you can get a token from Discogs and add it to
|
||||
your configuration.
|
||||
To get a personal access token (called a "user token" in the `discogs-client`_
|
||||
documentation), login to `Discogs`_, and visit the
|
||||
`Developer settings page
|
||||
<https://www.discogs.com/settings/developers>`_. Press the ``Generate new
|
||||
token`` button, and place the generated token in your configuration, as the
|
||||
``user_token`` config option in the ``discogs`` section.
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
44
docs/plugins/kodiupdate.rst
Normal file
44
docs/plugins/kodiupdate.rst
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
KodiUpdate Plugin
|
||||
=================
|
||||
|
||||
The ``kodiupdate`` plugin lets you automatically update `Kodi`_'s music
|
||||
library whenever you change your beets library.
|
||||
|
||||
To use ``kodiupdate`` plugin, enable it in your configuration
|
||||
(see :ref:`using-plugins`).
|
||||
Then, you'll want to configure the specifics of your Kodi host.
|
||||
You can do that using a ``kodi:`` section in your ``config.yaml``,
|
||||
which looks like this::
|
||||
|
||||
kodi:
|
||||
host: localhost
|
||||
port: 8080
|
||||
user: kodi
|
||||
pwd: kodi
|
||||
|
||||
To use the ``kodiupdate`` plugin you need to install the `requests`_ library with::
|
||||
|
||||
pip install requests
|
||||
|
||||
You'll also need to enable JSON-RPC in Kodi in order the use the plugin.
|
||||
In Kodi's interface, navigate to System/Settings/Network/Services and choose "Allow control of Kodi via HTTP."
|
||||
|
||||
With that all in place, you'll see beets send the "update" command to your Kodi
|
||||
host every time you change your beets library.
|
||||
|
||||
.. _Kodi: http://kodi.tv/
|
||||
.. _requests: http://docs.python-requests.org/en/latest/
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
The available options under the ``kodi:`` section are:
|
||||
|
||||
- **host**: The Kodi host name.
|
||||
Default: ``localhost``
|
||||
- **port**: The Kodi host port.
|
||||
Default: 8080
|
||||
- **user**: The Kodi host user.
|
||||
Default: ``kodi``
|
||||
- **pwd**: The Kodi host password.
|
||||
Default: ``kodi``
|
||||
|
|
@ -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
|
||||
-------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
+++++++++++++++++++++++++++++++
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
-------
|
||||
|
||||
|
|
|
|||
BIN
extra/beets.reg
BIN
extra/beets.reg
Binary file not shown.
6
setup.py
6
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'] +
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ _item_ident = 0
|
|||
|
||||
# OS feature test.
|
||||
HAVE_SYMLINK = sys.platform != 'win32'
|
||||
HAVE_HARDLINK = sys.platform != 'win32'
|
||||
|
||||
|
||||
def item(lib=None):
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ a specified text tag.
|
|||
"""
|
||||
|
||||
from __future__ import division, absolute_import, print_function
|
||||
from os.path import dirname, abspath
|
||||
import six
|
||||
import sys
|
||||
import platform
|
||||
import locale
|
||||
|
||||
beets_src = dirname(dirname(dirname(abspath(__file__))))
|
||||
sys.path.insert(0, beets_src)
|
||||
PY2 = sys.version_info[0] == 2
|
||||
|
||||
from beets.util import arg_encoding # noqa: E402
|
||||
|
||||
# From `beets.util`.
|
||||
def arg_encoding():
|
||||
try:
|
||||
return locale.getdefaultlocale()[1] or 'utf-8'
|
||||
except ValueError:
|
||||
return 'utf-8'
|
||||
|
||||
|
||||
def convert(in_file, out_file, tag):
|
||||
|
|
@ -27,7 +31,7 @@ def convert(in_file, out_file, tag):
|
|||
# On Windows, use Unicode paths. (The test harness gives them to us
|
||||
# as UTF-8 bytes.)
|
||||
if platform.system() == 'Windows':
|
||||
if not six.PY2:
|
||||
if not PY2:
|
||||
in_file = in_file.encode(arg_encoding())
|
||||
out_file = out_file.encode(arg_encoding())
|
||||
in_file = in_file.decode('utf-8')
|
||||
|
|
|
|||
BIN
test/rsrc/empty.dsf
Normal file
BIN
test/rsrc/empty.dsf
Normal file
Binary file not shown.
BIN
test/rsrc/full.dsf
Normal file
BIN
test/rsrc/full.dsf
Normal file
Binary file not shown.
BIN
test/rsrc/unparseable.dsf
Normal file
BIN
test/rsrc/unparseable.dsf
Normal file
Binary file not shown.
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
2
tox.ini
2
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue