mirror of
https://github.com/beetbox/beets.git
synced 2026-03-18 11:24:31 +01:00
Merge branch 'master' into import-filefilter
Conflicts: beets/importer.py beets/plugins.py beetsplug/ihate.py
This commit is contained in:
commit
ad65242ebd
117 changed files with 2558 additions and 2480 deletions
|
|
@ -10,8 +10,8 @@ matrix:
|
|||
env: {TOX_ENV: py26}
|
||||
- python: 2.7
|
||||
env: {TOX_ENV: py27cov, COVERAGE: 1}
|
||||
- python: pypy
|
||||
env: {TOX_ENV: pypy}
|
||||
# - python: pypy
|
||||
# env: {TOX_ENV: pypy}
|
||||
- python: 2.7
|
||||
env: {TOX_ENV: docs}
|
||||
- python: 2.7
|
||||
|
|
|
|||
2
beet
2
beet
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
__version__ = '1.3.10'
|
||||
__version__ = '1.3.11'
|
||||
__author__ = 'Adrian Sampson <adrian@radbox.org>'
|
||||
|
||||
import beets.library
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,8 +14,8 @@
|
|||
|
||||
"""Facilities for automatically determining files' correct metadata.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from beets import logging
|
||||
from beets import config
|
||||
|
||||
# Parts of external interface.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -13,10 +13,10 @@
|
|||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Glue between metadata sources and the matching logic."""
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
import re
|
||||
|
||||
from beets import logging
|
||||
from beets import plugins
|
||||
from beets import config
|
||||
from beets.autotag import mb
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -18,10 +18,10 @@ releases and tracks.
|
|||
from __future__ import division
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from munkres import Munkres
|
||||
|
||||
from beets import logging
|
||||
from beets import plugins
|
||||
from beets import config
|
||||
from beets.util import plurality
|
||||
|
|
@ -267,7 +267,7 @@ def match_by_id(items):
|
|||
# If all album IDs are equal, look up the album.
|
||||
if bool(reduce(lambda x, y: x if x == y else (), albumids)):
|
||||
albumid = albumids[0]
|
||||
log.debug(u'Searching for discovered album ID: {0}'.format(albumid))
|
||||
log.debug(u'Searching for discovered album ID: {0}', albumid)
|
||||
return hooks.album_for_mbid(albumid)
|
||||
else:
|
||||
log.debug(u'No album ID consensus.')
|
||||
|
|
@ -330,7 +330,7 @@ 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}'.format(info.artist, info.album))
|
||||
log.debug(u'Candidate: {0} - {1}', info.artist, info.album)
|
||||
|
||||
# Discard albums with zero tracks.
|
||||
if not info.tracks:
|
||||
|
|
@ -345,7 +345,7 @@ def _add_candidate(items, results, info):
|
|||
# Discard matches without required tags.
|
||||
for req_tag in config['match']['required'].as_str_seq():
|
||||
if getattr(info, req_tag) is None:
|
||||
log.debug(u'Ignored. Missing required tag: {0}'.format(req_tag))
|
||||
log.debug(u'Ignored. Missing required tag: {0}', req_tag)
|
||||
return
|
||||
|
||||
# Find mapping between the items and the track info.
|
||||
|
|
@ -358,10 +358,10 @@ def _add_candidate(items, results, info):
|
|||
penalties = [key for _, key in dist]
|
||||
for penalty in config['match']['ignored'].as_str_seq():
|
||||
if penalty in penalties:
|
||||
log.debug(u'Ignored. Penalty: {0}'.format(penalty))
|
||||
log.debug(u'Ignored. Penalty: {0}', penalty)
|
||||
return
|
||||
|
||||
log.debug(u'Success. Distance: {0}'.format(dist))
|
||||
log.debug(u'Success. Distance: {0}', dist)
|
||||
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
|
||||
extra_items, extra_tracks)
|
||||
|
||||
|
|
@ -387,7 +387,7 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||
likelies, consensus = current_metadata(items)
|
||||
cur_artist = likelies['artist']
|
||||
cur_album = likelies['album']
|
||||
log.debug(u'Tagging {0} - {1}'.format(cur_artist, cur_album))
|
||||
log.debug(u'Tagging {0} - {1}', cur_artist, cur_album)
|
||||
|
||||
# The output result (distance, AlbumInfo) tuples (keyed by MB album
|
||||
# ID).
|
||||
|
|
@ -395,7 +395,7 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||
|
||||
# Search by explicit ID.
|
||||
if search_id is not None:
|
||||
log.debug(u'Searching for album ID: {0}'.format(search_id))
|
||||
log.debug(u'Searching for album ID: {0}', search_id)
|
||||
search_cands = hooks.albums_for_id(search_id)
|
||||
|
||||
# Use existing metadata or text search.
|
||||
|
|
@ -405,7 +405,7 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||
if id_info:
|
||||
_add_candidate(items, candidates, id_info)
|
||||
rec = _recommendation(candidates.values())
|
||||
log.debug(u'Album ID match recommendation is {0}'.format(str(rec)))
|
||||
log.debug(u'Album ID match recommendation is {0}', str(rec))
|
||||
if candidates and not config['import']['timid']:
|
||||
# If we have a very good MBID match, return immediately.
|
||||
# Otherwise, this match will compete against metadata-based
|
||||
|
|
@ -418,20 +418,19 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||
if not (search_artist and search_album):
|
||||
# No explicit search terms -- use current metadata.
|
||||
search_artist, search_album = cur_artist, cur_album
|
||||
log.debug(u'Search terms: {0} - {1}'.format(search_artist,
|
||||
search_album))
|
||||
log.debug(u'Search terms: {0} - {1}', search_artist, search_album)
|
||||
|
||||
# Is this album likely to be a "various artist" release?
|
||||
va_likely = ((not consensus['artist']) or
|
||||
(search_artist.lower() in VA_ARTISTS) or
|
||||
any(item.comp for item in items))
|
||||
log.debug(u'Album might be VA: {0}'.format(str(va_likely)))
|
||||
log.debug(u'Album might be VA: {0}', str(va_likely))
|
||||
|
||||
# Get the results from the data sources.
|
||||
search_cands = hooks.album_candidates(items, search_artist,
|
||||
search_album, va_likely)
|
||||
|
||||
log.debug(u'Evaluating {0} candidates.'.format(len(search_cands)))
|
||||
log.debug(u'Evaluating {0} candidates.', len(search_cands))
|
||||
for info in search_cands:
|
||||
_add_candidate(items, candidates, info)
|
||||
|
||||
|
|
@ -456,7 +455,7 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||
# First, try matching by MusicBrainz ID.
|
||||
trackid = search_id or item.mb_trackid
|
||||
if trackid:
|
||||
log.debug(u'Searching for track ID: {0}'.format(trackid))
|
||||
log.debug(u'Searching for track ID: {0}', trackid)
|
||||
for track_info in hooks.tracks_for_id(trackid):
|
||||
dist = track_distance(item, track_info, incl_artist=True)
|
||||
candidates[track_info.track_id] = \
|
||||
|
|
@ -477,8 +476,7 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||
# Search terms.
|
||||
if not (search_artist and search_title):
|
||||
search_artist, search_title = item.artist, item.title
|
||||
log.debug(u'Item search terms: {0} - {1}'.format(search_artist,
|
||||
search_title))
|
||||
log.debug(u'Item search terms: {0} - {1}', search_artist, search_title)
|
||||
|
||||
# Get and evaluate candidate metadata.
|
||||
for track_info in hooks.item_candidates(item, search_artist, search_title):
|
||||
|
|
@ -486,7 +484,7 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
|
||||
|
||||
# Sort by distance and return with recommendation.
|
||||
log.debug(u'Found {0} candidates.'.format(len(candidates)))
|
||||
log.debug(u'Found {0} candidates.', len(candidates))
|
||||
candidates = sorted(candidates.itervalues())
|
||||
rec = _recommendation(candidates)
|
||||
return candidates, rec
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,12 +14,12 @@
|
|||
|
||||
"""Searches for albums in the MusicBrainz database.
|
||||
"""
|
||||
import logging
|
||||
import musicbrainzngs
|
||||
import re
|
||||
import traceback
|
||||
from urlparse import urljoin
|
||||
|
||||
from beets import logging
|
||||
import beets.autotag.hooks
|
||||
import beets
|
||||
from beets import util
|
||||
|
|
@ -374,7 +374,7 @@ def album_for_id(releaseid):
|
|||
"""
|
||||
albumid = _parse_id(releaseid)
|
||||
if not albumid:
|
||||
log.debug(u'Invalid MBID ({0}).'.format(releaseid))
|
||||
log.debug(u'Invalid MBID ({0}).', releaseid)
|
||||
return
|
||||
try:
|
||||
res = musicbrainzngs.get_release_by_id(albumid,
|
||||
|
|
@ -394,7 +394,7 @@ def track_for_id(releaseid):
|
|||
"""
|
||||
trackid = _parse_id(releaseid)
|
||||
if not trackid:
|
||||
log.debug(u'Invalid MBID ({0}).'.format(releaseid))
|
||||
log.debug(u'Invalid MBID ({0}).', releaseid)
|
||||
return
|
||||
try:
|
||||
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -19,7 +19,6 @@ from __future__ import print_function
|
|||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import pickle
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
|
@ -27,7 +26,9 @@ from tempfile import mkdtemp
|
|||
from bisect import insort, bisect_left
|
||||
from contextlib import contextmanager
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from beets import logging
|
||||
from beets import autotag
|
||||
from beets import library
|
||||
from beets import dbcore
|
||||
|
|
@ -71,7 +72,7 @@ def _open_state():
|
|||
# unpickling, including ImportError. We use a catch-all
|
||||
# exception to avoid enumerating them all (the docs don't even have a
|
||||
# full list!).
|
||||
log.debug(u'state file could not be read: {0}'.format(exc))
|
||||
log.debug(u'state file could not be read: {0}', exc)
|
||||
return {}
|
||||
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ def _save_state(state):
|
|||
with open(config['statefile'].as_filename(), 'w') as f:
|
||||
pickle.dump(state, f)
|
||||
except IOError as exc:
|
||||
log.error(u'state file could not be written: {0}'.format(exc))
|
||||
log.error(u'state file could not be written: {0}', exc)
|
||||
|
||||
|
||||
# Utilities for reading and writing the beets progress file, which
|
||||
|
|
@ -174,14 +175,13 @@ class ImportSession(object):
|
|||
"""Controls an import action. Subclasses should implement methods to
|
||||
communicate with the user or otherwise make decisions.
|
||||
"""
|
||||
def __init__(self, lib, logfile, paths, query):
|
||||
"""Create a session. `lib` is a Library object. `logfile` is a
|
||||
file-like object open for writing or None if no logging is to be
|
||||
performed. Either `paths` or `query` is non-null and indicates
|
||||
def __init__(self, lib, loghandler, paths, query):
|
||||
"""Create a session. `lib` is a Library object. `loghandler` is a
|
||||
logging.Handler. Either `paths` or `query` is non-null and indicates
|
||||
the source of files to be imported.
|
||||
"""
|
||||
self.lib = lib
|
||||
self.logfile = logfile
|
||||
self.logger = self._setup_logging(loghandler)
|
||||
self.paths = paths
|
||||
self.query = query
|
||||
self.seen_idents = set()
|
||||
|
|
@ -191,6 +191,14 @@ class ImportSession(object):
|
|||
if self.paths:
|
||||
self.paths = map(normpath, self.paths)
|
||||
|
||||
def _setup_logging(self, loghandler):
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.propagate = False
|
||||
if not loghandler:
|
||||
loghandler = logging.NullHandler()
|
||||
logger.handlers = [loghandler]
|
||||
return logger
|
||||
|
||||
def set_config(self, config):
|
||||
"""Set `config` property from global import config and make
|
||||
implied changes.
|
||||
|
|
@ -225,13 +233,10 @@ class ImportSession(object):
|
|||
self.want_resume = config['resume'].as_choice([True, False, 'ask'])
|
||||
|
||||
def tag_log(self, status, paths):
|
||||
"""Log a message about a given album to logfile. The status should
|
||||
reflect the reason the album couldn't be tagged.
|
||||
"""Log a message about a given album to the importer log. The status
|
||||
should reflect the reason the album couldn't be tagged.
|
||||
"""
|
||||
if self.logfile:
|
||||
print(u'{0} {1}'.format(status, displayable_path(paths)),
|
||||
file=self.logfile)
|
||||
self.logfile.flush()
|
||||
self.logger.info(u'{0} {1}', status, displayable_path(paths))
|
||||
|
||||
def log_choice(self, task, duplicate=False):
|
||||
"""Logs the task's current choice if it should be logged. If
|
||||
|
|
@ -269,6 +274,7 @@ class ImportSession(object):
|
|||
def run(self):
|
||||
"""Run the import task.
|
||||
"""
|
||||
self.logger.info(u'import started {0}', time.asctime())
|
||||
self.set_config(config['import'])
|
||||
|
||||
# Set up the pipeline.
|
||||
|
|
@ -347,8 +353,8 @@ class ImportSession(object):
|
|||
# Either accept immediately or prompt for input to decide.
|
||||
if self.want_resume is True or \
|
||||
self.should_resume(toppath):
|
||||
log.warn(u'Resuming interrupted import of {0}'.format(
|
||||
util.displayable_path(toppath)))
|
||||
log.warn(u'Resuming interrupted import of {0}',
|
||||
util.displayable_path(toppath))
|
||||
self._is_resuming[toppath] = True
|
||||
else:
|
||||
# Clear progress; we're starting from the top.
|
||||
|
|
@ -481,13 +487,12 @@ class ImportTask(object):
|
|||
|
||||
def remove_duplicates(self, lib):
|
||||
duplicate_items = self.duplicate_items(lib)
|
||||
log.debug(u'removing {0} old duplicated items'
|
||||
.format(len(duplicate_items)))
|
||||
log.debug(u'removing {0} old duplicated items', len(duplicate_items))
|
||||
for item in duplicate_items:
|
||||
item.remove()
|
||||
if lib.directory in util.ancestry(item.path):
|
||||
log.debug(u'deleting duplicate {0}'
|
||||
.format(util.displayable_path(item.path)))
|
||||
log.debug(u'deleting duplicate {0}',
|
||||
util.displayable_path(item.path))
|
||||
util.remove(item.path)
|
||||
util.prune_dirs(os.path.dirname(item.path),
|
||||
lib.directory)
|
||||
|
|
@ -686,12 +691,11 @@ class ImportTask(object):
|
|||
self.album.store()
|
||||
log.debug(
|
||||
u'Reimported album: added {0}, flexible '
|
||||
u'attributes {1} from album {2} for {3}'.format(
|
||||
self.album.added,
|
||||
replaced_album._values_flex.keys(),
|
||||
replaced_album.id,
|
||||
displayable_path(self.album.path),
|
||||
)
|
||||
u'attributes {1} from album {2} for {3}',
|
||||
self.album.added,
|
||||
replaced_album._values_flex.keys(),
|
||||
replaced_album.id,
|
||||
displayable_path(self.album.path)
|
||||
)
|
||||
|
||||
for item in self.imported_items():
|
||||
|
|
@ -701,20 +705,18 @@ class ImportTask(object):
|
|||
item.added = dup_item.added
|
||||
log.debug(
|
||||
u'Reimported item added {0} '
|
||||
u'from item {1} for {2}'.format(
|
||||
item.added,
|
||||
dup_item.id,
|
||||
displayable_path(item.path),
|
||||
)
|
||||
u'from item {1} for {2}',
|
||||
item.added,
|
||||
dup_item.id,
|
||||
displayable_path(item.path)
|
||||
)
|
||||
item.update(dup_item._values_flex)
|
||||
log.debug(
|
||||
u'Reimported item flexible attributes {0} '
|
||||
u'from item {1} for {2}'.format(
|
||||
dup_item._values_flex.keys(),
|
||||
dup_item.id,
|
||||
displayable_path(item.path),
|
||||
)
|
||||
u'from item {1} for {2}',
|
||||
dup_item._values_flex.keys(),
|
||||
dup_item.id,
|
||||
displayable_path(item.path)
|
||||
)
|
||||
item.store()
|
||||
|
||||
|
|
@ -724,13 +726,12 @@ class ImportTask(object):
|
|||
"""
|
||||
for item in self.imported_items():
|
||||
for dup_item in self.replaced_items[item]:
|
||||
log.debug(u'Replacing item {0}: {1}'
|
||||
.format(dup_item.id,
|
||||
displayable_path(item.path)))
|
||||
log.debug(u'Replacing item {0}: {1}',
|
||||
dup_item.id, displayable_path(item.path))
|
||||
dup_item.remove()
|
||||
log.debug(u'{0} of {1} items replaced'
|
||||
.format(sum(bool(l) for l in self.replaced_items.values()),
|
||||
len(self.imported_items())))
|
||||
log.debug(u'{0} of {1} items replaced',
|
||||
sum(bool(l) for l in self.replaced_items.values()),
|
||||
len(self.imported_items()))
|
||||
|
||||
def choose_match(self, session):
|
||||
"""Ask the session which match should apply and apply it.
|
||||
|
|
@ -1004,8 +1005,8 @@ class ImportTaskFactory(object):
|
|||
def singleton(self, path, item=None):
|
||||
if not item:
|
||||
if self.session.already_imported(self.toppath, [path]):
|
||||
log.debug(u'Skipping previously-imported path: {0}'
|
||||
.format(displayable_path(path)))
|
||||
log.debug(u'Skipping previously-imported path: {0}',
|
||||
displayable_path(path))
|
||||
self.skipped += 1
|
||||
return []
|
||||
|
||||
|
|
@ -1031,8 +1032,8 @@ class ImportTaskFactory(object):
|
|||
dirs = list(set(os.path.dirname(p) for p in paths))
|
||||
|
||||
if self.session.already_imported(self.toppath, dirs):
|
||||
log.debug(u'Skipping previously-imported path: {0}'
|
||||
.format(displayable_path(dirs)))
|
||||
log.debug(u'Skipping previously-imported path: {0}',
|
||||
displayable_path(dirs))
|
||||
self.skipped += 1
|
||||
return []
|
||||
|
||||
|
|
@ -1091,14 +1092,10 @@ class ImportTaskFactory(object):
|
|||
# Silently ignore non-music files.
|
||||
pass
|
||||
elif isinstance(exc.reason, mediafile.UnreadableFileError):
|
||||
log.warn(u'unreadable file: {0}'.format(
|
||||
displayable_path(path))
|
||||
)
|
||||
log.warn(u'unreadable file: {0}', displayable_path(path))
|
||||
else:
|
||||
log.error(u'error reading {0}: {1}'.format(
|
||||
displayable_path(path),
|
||||
exc,
|
||||
))
|
||||
log.error(u'error reading {0}: {1}',
|
||||
displayable_path(path), exc)
|
||||
|
||||
|
||||
# Full-album pipeline stages.
|
||||
|
|
@ -1123,13 +1120,13 @@ def read_tasks(session):
|
|||
"'copy' or 'move' to be enabled.")
|
||||
continue
|
||||
|
||||
log.debug(u'extracting archive {0}'
|
||||
.format(displayable_path(toppath)))
|
||||
log.debug(u'extracting archive {0}',
|
||||
displayable_path(toppath))
|
||||
archive_task = task_factory.archive(toppath)[0]
|
||||
try:
|
||||
archive_task.extract()
|
||||
except Exception as exc:
|
||||
log.error(u'extraction failed: {0}'.format(exc))
|
||||
log.error(u'extraction failed: {0}', exc)
|
||||
continue
|
||||
|
||||
# Continue reading albums from the extracted directory.
|
||||
|
|
@ -1150,12 +1147,12 @@ def read_tasks(session):
|
|||
yield archive_task
|
||||
|
||||
if not imported:
|
||||
log.warn(u'No files imported from {0}'
|
||||
.format(displayable_path(user_toppath)))
|
||||
log.warn(u'No files imported from {0}',
|
||||
displayable_path(user_toppath))
|
||||
|
||||
# Show skipped directories.
|
||||
if skipped:
|
||||
log.info(u'Skipped {0} directories.'.format(skipped))
|
||||
log.info(u'Skipped {0} directories.', skipped)
|
||||
|
||||
|
||||
def query_tasks(session):
|
||||
|
|
@ -1174,8 +1171,8 @@ def query_tasks(session):
|
|||
else:
|
||||
# Search for albums.
|
||||
for album in session.lib.albums(session.query):
|
||||
log.debug(u'yielding album {0}: {1} - {2}'
|
||||
.format(album.id, album.albumartist, album.album))
|
||||
log.debug(u'yielding album {0}: {1} - {2}',
|
||||
album.id, album.albumartist, album.album)
|
||||
items = list(album.items())
|
||||
|
||||
# Clear IDs from re-tagged items so they appear "fresh" when
|
||||
|
|
@ -1202,7 +1199,7 @@ def lookup_candidates(session, task):
|
|||
return
|
||||
|
||||
plugins.send('import_task_start', session=session, task=task)
|
||||
log.debug(u'Looking up: {0}'.format(displayable_path(task.paths)))
|
||||
log.debug(u'Looking up: {0}', displayable_path(task.paths))
|
||||
task.lookup_candidates()
|
||||
|
||||
|
||||
|
|
@ -1350,12 +1347,11 @@ def log_files(session, task):
|
|||
return
|
||||
|
||||
if isinstance(task, SingletonImportTask):
|
||||
log.info(
|
||||
'Singleton: {0}'.format(displayable_path(task.item['path'])))
|
||||
log.info(u'Singleton: {0}', displayable_path(task.item['path']))
|
||||
elif task.items:
|
||||
log.info('Album {0}'.format(displayable_path(task.paths[0])))
|
||||
log.info(u'Album {0}', displayable_path(task.paths[0]))
|
||||
for item in task.items:
|
||||
log.info(' {0}'.format(displayable_path(item['path'])))
|
||||
log.info(u' {0}', displayable_path(item['path']))
|
||||
|
||||
|
||||
def group_albums(session):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -16,12 +16,13 @@
|
|||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import shlex
|
||||
import unicodedata
|
||||
import time
|
||||
import re
|
||||
from unidecode import unidecode
|
||||
|
||||
from beets import logging
|
||||
from beets.mediafile import MediaFile, MutagenError, UnreadableFileError
|
||||
from beets import plugins
|
||||
from beets import util
|
||||
|
|
@ -509,7 +510,7 @@ class Item(LibModel):
|
|||
self.write(path)
|
||||
return True
|
||||
except FileOperationError as exc:
|
||||
log.error(exc)
|
||||
log.error(str(exc))
|
||||
return False
|
||||
|
||||
def try_sync(self, write=None):
|
||||
|
|
@ -837,9 +838,9 @@ class Album(LibModel):
|
|||
return
|
||||
|
||||
new_art = util.unique_path(new_art)
|
||||
log.debug(u'moving album art {0} to {1}'
|
||||
.format(util.displayable_path(old_art),
|
||||
util.displayable_path(new_art)))
|
||||
log.debug(u'moving album art {0} to {1}',
|
||||
util.displayable_path(old_art),
|
||||
util.displayable_path(new_art))
|
||||
if copy:
|
||||
util.copy(old_art, new_art)
|
||||
elif link:
|
||||
|
|
|
|||
107
beets/logging.py
Normal file
107
beets/logging.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2015, 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.
|
||||
|
||||
"""A drop-in replacement for the standard-library `logging` module that
|
||||
allows {}-style log formatting on Python 2 and 3.
|
||||
|
||||
Provides everything the "logging" module does. The only difference is
|
||||
that when getLogger(name) instantiates a logger that logger uses
|
||||
{}-style formatting.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
from copy import copy
|
||||
from logging import * # noqa
|
||||
import sys
|
||||
|
||||
|
||||
# We need special hacks for Python 2.6 due to logging.Logger being an
|
||||
# old- style class and having no loggerClass attribute.
|
||||
PY26 = sys.version_info[:2] == (2, 6)
|
||||
|
||||
|
||||
class StrFormatLogger(Logger):
|
||||
"""A version of `Logger` that uses `str.format`-style formatting
|
||||
instead of %-style formatting.
|
||||
"""
|
||||
|
||||
class _LogMessage(object):
|
||||
def __init__(self, msg, args, kwargs):
|
||||
self.msg = msg
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __str__(self):
|
||||
return self.msg.format(*self.args, **self.kwargs)
|
||||
|
||||
def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs):
|
||||
"""Log msg.format(*args, **kwargs)"""
|
||||
m = self._LogMessage(msg, args, kwargs)
|
||||
return Logger._log(self, level, m, (), exc_info, extra)
|
||||
# We cannot call super(StrFormatLogger, self) because it is not
|
||||
# allowed on old-style classes (py2), which Logger is in python 2.6.
|
||||
# Moreover, we cannot make StrFormatLogger a new-style class (by
|
||||
# declaring 'class StrFormatLogger(Logger, object)' because the class-
|
||||
# patching stmt 'logger.__class__ = StrFormatLogger' would not work:
|
||||
# both prev & new __class__ values must be either old- or new- style;
|
||||
# no mixing allowed.
|
||||
|
||||
if PY26:
|
||||
def getChild(self, suffix):
|
||||
"""Shameless copy from cpython's Lib/logging/__init__.py"""
|
||||
if self.root is not self:
|
||||
suffix = '.'.join((self.name, suffix))
|
||||
return self.manager.getLogger(suffix)
|
||||
|
||||
my_manager = copy(Logger.manager)
|
||||
my_manager.loggerClass = StrFormatLogger
|
||||
|
||||
|
||||
def getLogger(name=None):
|
||||
if name:
|
||||
return my_manager.getLogger(name)
|
||||
else:
|
||||
return Logger.root
|
||||
|
||||
|
||||
# On Python 2.6, there is no Manager.loggerClass so we dynamically
|
||||
# change the logger class. We must be careful to do that on new loggers
|
||||
# only to avoid side-effects.
|
||||
if PY26:
|
||||
# Wrap Manager.getLogger.
|
||||
old_getLogger = my_manager.getLogger
|
||||
|
||||
def new_getLogger(name):
|
||||
change_its_type = not isinstance(my_manager.loggerDict.get(name),
|
||||
Logger)
|
||||
# it either does not exist or is a placeholder
|
||||
logger = old_getLogger(name)
|
||||
if change_its_type:
|
||||
logger.__class__ = StrFormatLogger
|
||||
return logger
|
||||
|
||||
my_manager.getLogger = new_getLogger
|
||||
|
||||
|
||||
# Offer NullHandler in Python 2.6 to reduce the difference with never versions
|
||||
if PY26:
|
||||
class NullHandler(Handler):
|
||||
def handle(self, record):
|
||||
pass
|
||||
|
||||
def emit(self, record):
|
||||
pass
|
||||
|
||||
def createLock(self):
|
||||
self.lock = None
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -48,10 +48,10 @@ import math
|
|||
import struct
|
||||
import imghdr
|
||||
import os
|
||||
import logging
|
||||
import traceback
|
||||
import enum
|
||||
|
||||
from beets import logging
|
||||
from beets.util import displayable_path
|
||||
|
||||
|
||||
|
|
@ -1313,7 +1313,7 @@ class MediaFile(object):
|
|||
try:
|
||||
self.mgfile = mutagen.File(path)
|
||||
except unreadable_exc as exc:
|
||||
log.debug(u'header parsing failed: {0}'.format(unicode(exc)))
|
||||
log.debug(u'header parsing failed: {0}', unicode(exc))
|
||||
raise UnreadableFileError(path)
|
||||
except IOError as exc:
|
||||
if type(exc) == IOError:
|
||||
|
|
@ -1326,7 +1326,7 @@ class MediaFile(object):
|
|||
except Exception as exc:
|
||||
# Isolate bugs in Mutagen.
|
||||
log.debug(traceback.format_exc())
|
||||
log.error(u'uncaught Mutagen exception in open: {0}'.format(exc))
|
||||
log.error(u'uncaught Mutagen exception in open: {0}', exc)
|
||||
raise MutagenError(path, exc)
|
||||
|
||||
if self.mgfile is None:
|
||||
|
|
@ -1399,7 +1399,7 @@ class MediaFile(object):
|
|||
raise
|
||||
except Exception as exc:
|
||||
log.debug(traceback.format_exc())
|
||||
log.error(u'uncaught Mutagen exception in save: {0}'.format(exc))
|
||||
log.error(u'uncaught Mutagen exception in save: {0}', exc)
|
||||
raise MutagenError(self.path, exc)
|
||||
|
||||
def delete(self):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,14 +14,15 @@
|
|||
|
||||
"""Support for beets plugins."""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
import inspect
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
|
||||
|
||||
import beets
|
||||
from beets import logging
|
||||
from beets import mediafile
|
||||
|
||||
PLUGIN_NAMESPACE = 'beetsplug'
|
||||
|
|
@ -41,6 +42,23 @@ class PluginConflictException(Exception):
|
|||
"""
|
||||
|
||||
|
||||
class PluginLogFilter(logging.Filter):
|
||||
"""A logging filter that identifies the plugin that emitted a log
|
||||
message.
|
||||
"""
|
||||
def __init__(self, plugin):
|
||||
self.prefix = u'{0}: '.format(plugin.name)
|
||||
|
||||
def filter(self, record):
|
||||
if hasattr(record.msg, 'msg') and isinstance(record.msg.msg,
|
||||
basestring):
|
||||
# A _LogMessage from our hacked-up Logging replacement.
|
||||
record.msg.msg = self.prefix + record.msg.msg
|
||||
elif isinstance(record.msg, basestring):
|
||||
record.msg = self.prefix + record.msg
|
||||
return True
|
||||
|
||||
|
||||
# Managing the plugins themselves.
|
||||
|
||||
class BeetsPlugin(object):
|
||||
|
|
@ -51,7 +69,6 @@ class BeetsPlugin(object):
|
|||
def __init__(self, name=None):
|
||||
"""Perform one-time plugin setup.
|
||||
"""
|
||||
self.import_stages = []
|
||||
self.name = name or self.__module__.split('.')[-1]
|
||||
self.config = beets.config[self.name]
|
||||
if not self.template_funcs:
|
||||
|
|
@ -60,6 +77,12 @@ class BeetsPlugin(object):
|
|||
self.template_fields = {}
|
||||
if not self.album_template_fields:
|
||||
self.album_template_fields = {}
|
||||
self.import_stages = []
|
||||
|
||||
self._log = log.getChild(self.name)
|
||||
self._log.setLevel(logging.NOTSET) # Use `beets` logger level.
|
||||
if beets.config['verbose']:
|
||||
self._log.addFilter(PluginLogFilter(self))
|
||||
|
||||
def commands(self):
|
||||
"""Should return a list of beets.ui.Subcommand objects for
|
||||
|
|
@ -67,6 +90,36 @@ class BeetsPlugin(object):
|
|||
"""
|
||||
return ()
|
||||
|
||||
def get_import_stages(self):
|
||||
"""Return a list of functions that should be called as importer
|
||||
pipelines stages.
|
||||
|
||||
The callables are wrapped versions of the functions in
|
||||
`self.import_stages`. Wrapping provides some bookkeeping for the
|
||||
plugin: specifically, the logging level is adjusted to WARNING.
|
||||
"""
|
||||
return [self._set_log_level(logging.WARNING, import_stage)
|
||||
for import_stage in self.import_stages]
|
||||
|
||||
def _set_log_level(self, log_level, func):
|
||||
"""Wrap `func` to temporarily set this plugin's logger level to
|
||||
`log_level` (and restore it after the function returns).
|
||||
|
||||
The level is *not* adjusted when beets is in verbose
|
||||
mode---i.e., the plugin logger continues to delegate to the base
|
||||
beets logger.
|
||||
"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not beets.config['verbose']:
|
||||
old_log_level = self._log.level
|
||||
self._log.setLevel(log_level)
|
||||
result = func(*args, **kwargs)
|
||||
if not beets.config['verbose']:
|
||||
self._log.setLevel(old_log_level)
|
||||
return result
|
||||
return wrapper
|
||||
|
||||
def queries(self):
|
||||
"""Should return a dict mapping prefixes to Query subclasses.
|
||||
"""
|
||||
|
|
@ -132,7 +185,8 @@ class BeetsPlugin(object):
|
|||
"""
|
||||
if cls.listeners is None:
|
||||
cls.listeners = defaultdict(list)
|
||||
cls.listeners[event].append(func)
|
||||
if func not in cls.listeners[event]:
|
||||
cls.listeners[event].append(func)
|
||||
|
||||
@classmethod
|
||||
def listen(cls, event):
|
||||
|
|
@ -149,9 +203,7 @@ class BeetsPlugin(object):
|
|||
... pass
|
||||
"""
|
||||
def helper(func):
|
||||
if cls.listeners is None:
|
||||
cls.listeners = defaultdict(list)
|
||||
cls.listeners[event].append(func)
|
||||
cls.register_listener(event, func)
|
||||
return func
|
||||
return helper
|
||||
|
||||
|
|
@ -204,7 +256,7 @@ def load_plugins(names=()):
|
|||
except ImportError as exc:
|
||||
# Again, this is hacky:
|
||||
if exc.args[0].endswith(' ' + name):
|
||||
log.warn(u'** plugin {0} not found'.format(name))
|
||||
log.warn(u'** plugin {0} not found', name)
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
|
|
@ -214,7 +266,7 @@ def load_plugins(names=()):
|
|||
_classes.add(obj)
|
||||
|
||||
except:
|
||||
log.warn(u'** error loading plugin {0}'.format(name))
|
||||
log.warn(u'** error loading plugin {0}', name)
|
||||
log.warn(traceback.format_exc())
|
||||
|
||||
|
||||
|
|
@ -349,8 +401,7 @@ def import_stages():
|
|||
"""Get a list of import stage functions defined by plugins."""
|
||||
stages = []
|
||||
for plugin in find_plugins():
|
||||
if hasattr(plugin, 'import_stages'):
|
||||
stages += plugin.import_stages
|
||||
stages += plugin.get_import_stages()
|
||||
return stages
|
||||
|
||||
|
||||
|
|
@ -398,7 +449,7 @@ def send(event, **arguments):
|
|||
|
||||
Returns a list of return values from the handlers.
|
||||
"""
|
||||
log.debug(u'Sending event: {0}'.format(event))
|
||||
log.debug(u'Sending event: {0}', event)
|
||||
return_values = []
|
||||
for handler in event_handlers()[event]:
|
||||
# Don't break legacy plugins if we want to pass more arguments
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -23,7 +23,6 @@ import optparse
|
|||
import textwrap
|
||||
import sys
|
||||
from difflib import SequenceMatcher
|
||||
import logging
|
||||
import sqlite3
|
||||
import errno
|
||||
import re
|
||||
|
|
@ -31,6 +30,7 @@ import struct
|
|||
import traceback
|
||||
import os.path
|
||||
|
||||
from beets import logging
|
||||
from beets import library
|
||||
from beets import plugins
|
||||
from beets import util
|
||||
|
|
@ -866,14 +866,14 @@ def _configure(options):
|
|||
|
||||
config_path = config.user_config_path()
|
||||
if os.path.isfile(config_path):
|
||||
log.debug(u'user configuration: {0}'.format(
|
||||
util.displayable_path(config_path)))
|
||||
log.debug(u'user configuration: {0}',
|
||||
util.displayable_path(config_path))
|
||||
else:
|
||||
log.debug(u'no user configuration found at {0}'.format(
|
||||
util.displayable_path(config_path)))
|
||||
log.debug(u'no user configuration found at {0}',
|
||||
util.displayable_path(config_path))
|
||||
|
||||
log.debug(u'data directory: {0}'
|
||||
.format(util.displayable_path(config.config_dir())))
|
||||
log.debug(u'data directory: {0}',
|
||||
util.displayable_path(config.config_dir()))
|
||||
return config
|
||||
|
||||
|
||||
|
|
@ -895,9 +895,9 @@ def _open_library(config):
|
|||
util.displayable_path(dbpath)
|
||||
))
|
||||
log.debug(u'library database: {0}\n'
|
||||
u'library directory: {1}'
|
||||
.format(util.displayable_path(lib.path),
|
||||
util.displayable_path(lib.directory)))
|
||||
u'library directory: {1}',
|
||||
util.displayable_path(lib.path),
|
||||
util.displayable_path(lib.directory))
|
||||
return lib
|
||||
|
||||
|
||||
|
|
@ -924,7 +924,8 @@ def _raw_main(args, lib=None):
|
|||
# Special case for the `config --edit` command: bypass _setup so
|
||||
# that an invalid configuration does not prevent the editor from
|
||||
# starting.
|
||||
if subargs[0] == 'config' and ('-e' in subargs or '--edit' in subargs):
|
||||
if subargs and subargs[0] == 'config' \
|
||||
and ('-e' in subargs or '--edit' in subargs):
|
||||
from beets.ui.commands import config_edit
|
||||
return config_edit()
|
||||
|
||||
|
|
@ -945,7 +946,7 @@ def main(args=None):
|
|||
_raw_main(args)
|
||||
except UserError as exc:
|
||||
message = exc.args[0] if exc.args else None
|
||||
log.error(u'error: {0}'.format(message))
|
||||
log.error(u'error: {0}', message)
|
||||
sys.exit(1)
|
||||
except util.HumanReadableException as exc:
|
||||
exc.log(log)
|
||||
|
|
@ -957,7 +958,7 @@ def main(args=None):
|
|||
log.error(exc)
|
||||
sys.exit(1)
|
||||
except confit.ConfigError as exc:
|
||||
log.error(u'configuration error: {0}'.format(exc))
|
||||
log.error(u'configuration error: {0}', exc)
|
||||
sys.exit(1)
|
||||
except IOError as exc:
|
||||
if exc.errno == errno.EPIPE:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -17,12 +17,10 @@ interface.
|
|||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import codecs
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import beets
|
||||
from beets import ui
|
||||
|
|
@ -37,6 +35,7 @@ from beets.util import syspath, normpath, ancestry, displayable_path
|
|||
from beets.util.functemplate import Template
|
||||
from beets import library
|
||||
from beets import config
|
||||
from beets import logging
|
||||
from beets.util.confit import _package_path
|
||||
|
||||
VARIOUS_ARTISTS = u'Various Artists'
|
||||
|
|
@ -764,8 +763,8 @@ class TerminalImportSession(importer.ImportSession):
|
|||
"""Decide what to do when a new album or item seems similar to one
|
||||
that's already in the library.
|
||||
"""
|
||||
log.warn(u"This {0} is already in the library!"
|
||||
.format("album" if task.is_album else "item"))
|
||||
log.warn(u"This {0} is already in the library!",
|
||||
("album" if task.is_album else "item"))
|
||||
|
||||
if config['import']['quiet']:
|
||||
# In quiet mode, don't prompt -- just skip.
|
||||
|
|
@ -824,29 +823,22 @@ def import_files(lib, paths, query):
|
|||
|
||||
# Open the log.
|
||||
if config['import']['log'].get() is not None:
|
||||
logpath = config['import']['log'].as_filename()
|
||||
logpath = syspath(config['import']['log'].as_filename())
|
||||
try:
|
||||
logfile = codecs.open(syspath(logpath), 'a', 'utf8')
|
||||
loghandler = logging.FileHandler(logpath)
|
||||
except IOError:
|
||||
raise ui.UserError(u"could not open log file for writing: %s" %
|
||||
displayable_path(logpath))
|
||||
print(u'import started', time.asctime(), file=logfile)
|
||||
raise ui.UserError(u"could not open log file for writing: "
|
||||
u"{0}".format(displayable_path(loghandler)))
|
||||
else:
|
||||
logfile = None
|
||||
loghandler = None
|
||||
|
||||
# Never ask for input in quiet mode.
|
||||
if config['import']['resume'].get() == 'ask' and \
|
||||
config['import']['quiet']:
|
||||
config['import']['resume'] = False
|
||||
|
||||
session = TerminalImportSession(lib, logfile, paths, query)
|
||||
try:
|
||||
session.run()
|
||||
finally:
|
||||
# If we were logging, close the file.
|
||||
if logfile:
|
||||
print(u'', file=logfile)
|
||||
logfile.close()
|
||||
session = TerminalImportSession(lib, loghandler, paths, query)
|
||||
session.run()
|
||||
|
||||
# Emit event.
|
||||
plugins.send('import', lib=lib, paths=paths)
|
||||
|
|
@ -1014,16 +1006,16 @@ def update_items(lib, query, album, move, pretend):
|
|||
|
||||
# Did the item change since last checked?
|
||||
if item.current_mtime() <= item.mtime:
|
||||
log.debug(u'skipping {0} because mtime is up to date ({1})'
|
||||
.format(displayable_path(item.path), item.mtime))
|
||||
log.debug(u'skipping {0} because mtime is up to date ({1})',
|
||||
displayable_path(item.path), item.mtime)
|
||||
continue
|
||||
|
||||
# Read new data.
|
||||
try:
|
||||
item.read()
|
||||
except library.ReadError as exc:
|
||||
log.error(u'error reading {0}: {1}'.format(
|
||||
displayable_path(item.path), exc))
|
||||
log.error(u'error reading {0}: {1}',
|
||||
displayable_path(item.path), exc)
|
||||
continue
|
||||
|
||||
# Special-case album artist when it matches track artist. (Hacky
|
||||
|
|
@ -1065,7 +1057,7 @@ def update_items(lib, query, album, move, pretend):
|
|||
continue
|
||||
album = lib.get_album(album_id)
|
||||
if not album: # Empty albums have already been removed.
|
||||
log.debug(u'emptied album {0}'.format(album_id))
|
||||
log.debug(u'emptied album {0}', album_id)
|
||||
continue
|
||||
first_item = album.items().get()
|
||||
|
||||
|
|
@ -1076,7 +1068,7 @@ def update_items(lib, query, album, move, pretend):
|
|||
|
||||
# Move album art (and any inconsistent items).
|
||||
if move and lib.directory in ancestry(first_item.path):
|
||||
log.debug(u'moving album {0}'.format(album_id))
|
||||
log.debug(u'moving album {0}', album_id)
|
||||
album.move()
|
||||
|
||||
|
||||
|
|
@ -1298,8 +1290,7 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm):
|
|||
if move:
|
||||
cur_path = obj.path
|
||||
if lib.directory in ancestry(cur_path): # In library?
|
||||
log.debug(u'moving object {0}'
|
||||
.format(displayable_path(cur_path)))
|
||||
log.debug(u'moving object {0}', displayable_path(cur_path))
|
||||
obj.move()
|
||||
|
||||
obj.try_sync(write)
|
||||
|
|
@ -1377,9 +1368,9 @@ def move_items(lib, dest, query, copy, album):
|
|||
|
||||
action = 'Copying' if copy else 'Moving'
|
||||
entity = 'album' if album else 'item'
|
||||
log.info(u'{0} {1} {2}s.'.format(action, len(objs), entity))
|
||||
log.info(u'{0} {1} {2}s.', action, len(objs), entity)
|
||||
for obj in objs:
|
||||
log.debug(u'moving: {0}'.format(util.displayable_path(obj.path)))
|
||||
log.debug(u'moving: {0}', util.displayable_path(obj.path))
|
||||
|
||||
obj.move(copy, basedir=dest)
|
||||
obj.store()
|
||||
|
|
@ -1425,18 +1416,15 @@ def write_items(lib, query, pretend, force):
|
|||
for item in items:
|
||||
# Item deleted?
|
||||
if not os.path.exists(syspath(item.path)):
|
||||
log.info(u'missing file: {0}'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
log.info(u'missing file: {0}', util.displayable_path(item.path))
|
||||
continue
|
||||
|
||||
# Get an Item object reflecting the "clean" (on-disk) state.
|
||||
try:
|
||||
clean_item = library.Item.from_path(item.path)
|
||||
except library.ReadError as exc:
|
||||
log.error(u'error reading {0}: {1}'.format(
|
||||
displayable_path(item.path), exc
|
||||
))
|
||||
log.error(u'error reading {0}: {1}',
|
||||
displayable_path(item.path), exc)
|
||||
continue
|
||||
|
||||
# Check for and display changes.
|
||||
|
|
@ -1503,7 +1491,12 @@ def config_edit():
|
|||
|
||||
if 'EDITOR' in os.environ:
|
||||
editor = os.environ['EDITOR']
|
||||
args = [editor, editor, path]
|
||||
try:
|
||||
editor = shlex.split(editor)
|
||||
except ValueError: # Malformed shell tokens.
|
||||
editor = [editor]
|
||||
args = editor + [path]
|
||||
args.insert(1, args[0])
|
||||
elif platform.system() == 'Darwin':
|
||||
args = ['open', 'open', '-n', path]
|
||||
elif platform.system() == 'Windows':
|
||||
|
|
@ -1517,7 +1510,7 @@ def config_edit():
|
|||
try:
|
||||
os.execlp(*args)
|
||||
except OSError:
|
||||
raise ui.UserError("Could not edit configuration. Please"
|
||||
raise ui.UserError("Could not edit configuration. Please "
|
||||
"set the EDITOR environment variable.")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -544,10 +544,7 @@ def truncate_path(path, length=MAX_FILENAME_LENGTH):
|
|||
|
||||
def str2bool(value):
|
||||
"""Returns a boolean reflecting a human-entered string."""
|
||||
if value.lower() in ('yes', '1', 'true', 't', 'y'):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return value.lower() in ('yes', '1', 'true', 't', 'y')
|
||||
|
||||
|
||||
def as_string(value):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte
|
||||
# Copyright 2015, Fabrice Laporte
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -20,7 +20,8 @@ import subprocess
|
|||
import os
|
||||
import re
|
||||
from tempfile import NamedTemporaryFile
|
||||
import logging
|
||||
|
||||
from beets import logging
|
||||
from beets import util
|
||||
|
||||
# Resizing methods
|
||||
|
|
@ -58,9 +59,8 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
|||
"""
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
from PIL import Image
|
||||
log.debug(u'artresizer: PIL resizing {0} to {1}'.format(
|
||||
util.displayable_path(path_in), util.displayable_path(path_out)
|
||||
))
|
||||
log.debug(u'artresizer: PIL resizing {0} to {1}',
|
||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
||||
|
||||
try:
|
||||
im = Image.open(util.syspath(path_in))
|
||||
|
|
@ -69,9 +69,8 @@ def pil_resize(maxwidth, path_in, path_out=None):
|
|||
im.save(path_out)
|
||||
return path_out
|
||||
except IOError:
|
||||
log.error(u"PIL cannot create thumbnail for '{0}'".format(
|
||||
util.displayable_path(path_in)
|
||||
))
|
||||
log.error(u"PIL cannot create thumbnail for '{0}'",
|
||||
util.displayable_path(path_in))
|
||||
return path_in
|
||||
|
||||
|
||||
|
|
@ -80,9 +79,8 @@ def im_resize(maxwidth, path_in, path_out=None):
|
|||
Return the output path of resized image.
|
||||
"""
|
||||
path_out = path_out or temp_file_for(path_in)
|
||||
log.debug(u'artresizer: ImageMagick resizing {0} to {1}'.format(
|
||||
util.displayable_path(path_in), util.displayable_path(path_out)
|
||||
))
|
||||
log.debug(u'artresizer: ImageMagick resizing {0} to {1}',
|
||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
||||
|
||||
# "-resize widthxheight>" shrinks images with dimension(s) larger
|
||||
# than the corresponding width and/or height dimension(s). The >
|
||||
|
|
@ -94,9 +92,8 @@ def im_resize(maxwidth, path_in, path_out=None):
|
|||
'-resize', '{0}x^>'.format(maxwidth), path_out
|
||||
])
|
||||
except subprocess.CalledProcessError:
|
||||
log.warn(u'artresizer: IM convert failed for {0}'.format(
|
||||
util.displayable_path(path_in)
|
||||
))
|
||||
log.warn(u'artresizer: IM convert failed for {0}',
|
||||
util.displayable_path(path_in))
|
||||
return path_in
|
||||
return path_out
|
||||
|
||||
|
|
@ -134,7 +131,7 @@ class ArtResizer(object):
|
|||
specified, with an inferred method.
|
||||
"""
|
||||
self.method = self._check_method(method)
|
||||
log.debug(u"artresizer: method is {0}".format(self.method))
|
||||
log.debug(u"artresizer: method is {0}", self.method)
|
||||
self.can_compare = self._can_compare()
|
||||
|
||||
def resize(self, maxwidth, path_in, path_out=None):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of Confit.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
"""Adds Beatport release and track search support to the autotagger
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
|
@ -23,8 +22,6 @@ import requests
|
|||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class BeatportAPIError(Exception):
|
||||
pass
|
||||
|
|
@ -194,7 +191,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
try:
|
||||
return self._get_releases(query)
|
||||
except BeatportAPIError as e:
|
||||
log.debug(u'Beatport API Error: {0} (query: {1})'.format(e, query))
|
||||
self._log.debug(u'API Error: {0} (query: {1})', e, query)
|
||||
return []
|
||||
|
||||
def item_candidates(self, item, artist, title):
|
||||
|
|
@ -205,14 +202,14 @@ class BeatportPlugin(BeetsPlugin):
|
|||
try:
|
||||
return self._get_tracks(query)
|
||||
except BeatportAPIError as e:
|
||||
log.debug(u'Beatport API Error: {0} (query: {1})'.format(e, query))
|
||||
self._log.debug(u'API Error: {0} (query: {1})', e, query)
|
||||
return []
|
||||
|
||||
def album_for_id(self, release_id):
|
||||
"""Fetches a release by its Beatport ID and returns an AlbumInfo object
|
||||
or None if the release is not found.
|
||||
"""
|
||||
log.debug(u'Searching Beatport for release {0}'.format(release_id))
|
||||
self._log.debug(u'Searching for release {0}', release_id)
|
||||
match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id)
|
||||
if not match:
|
||||
return None
|
||||
|
|
@ -224,7 +221,7 @@ class BeatportPlugin(BeetsPlugin):
|
|||
"""Fetches a track by its Beatport ID and returns a TrackInfo object
|
||||
or None if the track is not found.
|
||||
"""
|
||||
log.debug(u'Searching Beatport for track {0}'.format(str(track_id)))
|
||||
self._log.debug(u'Searching for track {0}', track_id)
|
||||
match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id)
|
||||
if not match:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -21,13 +21,13 @@ from __future__ import print_function
|
|||
import re
|
||||
from string import Template
|
||||
import traceback
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
import beets
|
||||
from beets.plugins import BeetsPlugin
|
||||
import beets.ui
|
||||
from beets import logging
|
||||
from beets import vfs
|
||||
from beets.util import bluelet
|
||||
from beets.library import Item
|
||||
|
|
@ -1154,10 +1154,10 @@ class BPDPlugin(BeetsPlugin):
|
|||
|
||||
def start_bpd(self, lib, host, port, password, volume, debug):
|
||||
"""Starts a BPD server."""
|
||||
if debug:
|
||||
log.setLevel(logging.DEBUG)
|
||||
if debug: # FIXME this should be managed by BeetsPlugin
|
||||
self._log.setLevel(logging.DEBUG)
|
||||
else:
|
||||
log.setLevel(logging.WARNING)
|
||||
self._log.setLevel(logging.WARNING)
|
||||
try:
|
||||
server = Server(lib, host, port, password)
|
||||
server.cmd_setvol(None, volume)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, aroquen
|
||||
# Copyright 2015, aroquen
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,13 +15,10 @@
|
|||
"""Determine BPM by pressing a key to the rhythm."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
|
||||
from beets import ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def bpm(max_strokes):
|
||||
"""Returns average BPM (possibly of a playing song)
|
||||
|
|
@ -73,15 +70,15 @@ class BPMPlugin(BeetsPlugin):
|
|||
|
||||
item = items[0]
|
||||
if item['bpm']:
|
||||
log.info(u'Found bpm {0}'.format(item['bpm']))
|
||||
self._log.info(u'Found bpm {0}', item['bpm'])
|
||||
if not overwrite:
|
||||
return
|
||||
|
||||
log.info(u'Press Enter {0} times to the rhythm or Ctrl-D '
|
||||
u'to exit'.format(self.config['max_strokes'].get(int)))
|
||||
self._log.info(u'Press Enter {0} times to the rhythm or Ctrl-D '
|
||||
u'to exit', self.config['max_strokes'].get(int))
|
||||
new_bpm = bpm(self.config['max_strokes'].get(int))
|
||||
item['bpm'] = int(new_bpm)
|
||||
if write:
|
||||
item.try_write()
|
||||
item.store()
|
||||
log.info(u'Added new bpm {0}'.format(item['bpm']))
|
||||
self._log.info(u'Added new bpm {0}', item['bpm'])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte.
|
||||
# Copyright 2015, Fabrice Laporte.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -16,13 +16,11 @@
|
|||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
from itertools import tee, izip
|
||||
from beets import plugins, ui
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
from beets import plugins, ui
|
||||
|
||||
|
||||
class BucketError(Exception):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -22,7 +22,6 @@ from beets import config
|
|||
from beets.util import confit
|
||||
from beets.autotag import hooks
|
||||
import acoustid
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
API_KEY = '1vOwZtEn'
|
||||
|
|
@ -32,8 +31,6 @@ COMMON_REL_THRESH = 0.6 # How many tracks must have an album in common?
|
|||
MAX_RECORDINGS = 5
|
||||
MAX_RELEASES = 5
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# Stores the Acoustid match information for each track. This is
|
||||
# populated when an import task begins and then used when searching for
|
||||
# candidates. It maps audio file paths to (recording_ids, release_ids)
|
||||
|
|
@ -57,40 +54,40 @@ def prefix(it, count):
|
|||
yield v
|
||||
|
||||
|
||||
def acoustid_match(path):
|
||||
def acoustid_match(log, path):
|
||||
"""Gets metadata for a file from Acoustid and populates the
|
||||
_matches, _fingerprints, and _acoustids dictionaries accordingly.
|
||||
"""
|
||||
try:
|
||||
duration, fp = acoustid.fingerprint_file(util.syspath(path))
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.error(u'fingerprinting of {0} failed: {1}'
|
||||
.format(util.displayable_path(repr(path)), str(exc)))
|
||||
log.error(u'fingerprinting of {0} failed: {1}',
|
||||
util.displayable_path(repr(path)), str(exc))
|
||||
return None
|
||||
_fingerprints[path] = fp
|
||||
try:
|
||||
res = acoustid.lookup(API_KEY, fp, duration,
|
||||
meta='recordings releases')
|
||||
except acoustid.AcoustidError as exc:
|
||||
log.debug(u'fingerprint matching {0} failed: {1}'
|
||||
.format(util.displayable_path(repr(path)), str(exc)))
|
||||
log.debug(u'fingerprint matching {0} failed: {1}',
|
||||
util.displayable_path(repr(path)), exc)
|
||||
return None
|
||||
log.debug(u'chroma: fingerprinted {0}'
|
||||
.format(util.displayable_path(repr(path))))
|
||||
log.debug(u'chroma: fingerprinted {0}',
|
||||
util.displayable_path(repr(path)))
|
||||
|
||||
# Ensure the response is usable and parse it.
|
||||
if res['status'] != 'ok' or not res.get('results'):
|
||||
log.debug(u'chroma: no match found')
|
||||
log.debug(u'no match found')
|
||||
return None
|
||||
result = res['results'][0] # Best match.
|
||||
if result['score'] < SCORE_THRESH:
|
||||
log.debug(u'chroma: no results above threshold')
|
||||
log.debug(u'no results above threshold')
|
||||
return None
|
||||
_acoustids[path] = result['id']
|
||||
|
||||
# Get recording and releases from the result.
|
||||
if not result.get('recordings'):
|
||||
log.debug(u'chroma: no recordings found')
|
||||
log.debug(u'no recordings found')
|
||||
return None
|
||||
recording_ids = []
|
||||
release_ids = []
|
||||
|
|
@ -99,9 +96,8 @@ def acoustid_match(path):
|
|||
if 'releases' in recording:
|
||||
release_ids += [rel['id'] for rel in recording['releases']]
|
||||
|
||||
log.debug(u'chroma: matched recordings {0} on releases {1}'.format(
|
||||
recording_ids, release_ids,
|
||||
))
|
||||
log.debug(u'matched recordings {0} on releases {1}',
|
||||
recording_ids, release_ids)
|
||||
_matches[path] = recording_ids, release_ids
|
||||
|
||||
|
||||
|
|
@ -136,7 +132,10 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
})
|
||||
|
||||
if self.config['auto']:
|
||||
self.register_listener('import_task_start', fingerprint_task)
|
||||
self.register_listener('import_task_start', self.fingerprint_task)
|
||||
|
||||
def fingerprint_task(self, task, session):
|
||||
return fingerprint_task(self._log, task, session)
|
||||
|
||||
def track_distance(self, item, info):
|
||||
dist = hooks.Distance()
|
||||
|
|
@ -155,7 +154,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
if album:
|
||||
albums.append(album)
|
||||
|
||||
log.debug(u'acoustid album candidates: {0}'.format(len(albums)))
|
||||
self._log.debug(u'acoustid album candidates: {0}', len(albums))
|
||||
return albums
|
||||
|
||||
def item_candidates(self, item, artist, title):
|
||||
|
|
@ -168,7 +167,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
track = hooks.track_for_mbid(recording_id)
|
||||
if track:
|
||||
tracks.append(track)
|
||||
log.debug(u'acoustid item candidates: {0}'.format(len(tracks)))
|
||||
self._log.debug(u'acoustid item candidates: {0}', len(tracks))
|
||||
return tracks
|
||||
|
||||
def commands(self):
|
||||
|
|
@ -180,7 +179,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
apikey = config['acoustid']['apikey'].get(unicode)
|
||||
except confit.NotFoundError:
|
||||
raise ui.UserError('no Acoustid user API key provided')
|
||||
submit_items(apikey, lib.items(ui.decargs(args)))
|
||||
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
|
||||
submit_cmd.func = submit_cmd_func
|
||||
|
||||
fingerprint_cmd = ui.Subcommand(
|
||||
|
|
@ -190,7 +189,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
|
||||
def fingerprint_cmd_func(lib, opts, args):
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
fingerprint_item(item,
|
||||
fingerprint_item(self._log, item,
|
||||
write=config['import']['write'].get(bool))
|
||||
fingerprint_cmd.func = fingerprint_cmd_func
|
||||
|
||||
|
|
@ -200,13 +199,13 @@ class AcoustidPlugin(plugins.BeetsPlugin):
|
|||
# Hooks into import process.
|
||||
|
||||
|
||||
def fingerprint_task(task, session):
|
||||
def fingerprint_task(log, task, session):
|
||||
"""Fingerprint each item in the task for later use during the
|
||||
autotagging candidate search.
|
||||
"""
|
||||
items = task.items if task.is_album else [task.item]
|
||||
for item in items:
|
||||
acoustid_match(item.path)
|
||||
acoustid_match(log, item.path)
|
||||
|
||||
|
||||
@AcoustidPlugin.listen('import_task_apply')
|
||||
|
|
@ -223,18 +222,18 @@ def apply_acoustid_metadata(task, session):
|
|||
# UI commands.
|
||||
|
||||
|
||||
def submit_items(userkey, items, chunksize=64):
|
||||
def submit_items(log, userkey, items, chunksize=64):
|
||||
"""Submit fingerprints for the items to the Acoustid server.
|
||||
"""
|
||||
data = [] # The running list of dictionaries to submit.
|
||||
|
||||
def submit_chunk():
|
||||
"""Submit the current accumulated fingerprint data."""
|
||||
log.info(u'submitting {0} fingerprints'.format(len(data)))
|
||||
log.info(u'submitting {0} fingerprints', len(data))
|
||||
try:
|
||||
acoustid.submit(API_KEY, userkey, data)
|
||||
except acoustid.AcoustidError as exc:
|
||||
log.warn(u'acoustid submission error: {0}'.format(exc))
|
||||
log.warn(u'acoustid submission error: {0}', exc)
|
||||
del data[:]
|
||||
|
||||
for item in items:
|
||||
|
|
@ -270,7 +269,7 @@ def submit_items(userkey, items, chunksize=64):
|
|||
submit_chunk()
|
||||
|
||||
|
||||
def fingerprint_item(item, write=False):
|
||||
def fingerprint_item(log, item, write=False):
|
||||
"""Get the fingerprint for an Item. If the item already has a
|
||||
fingerprint, it is not regenerated. If fingerprint generation fails,
|
||||
return None. If the items are associated with a library, they are
|
||||
|
|
@ -279,34 +278,28 @@ def fingerprint_item(item, write=False):
|
|||
"""
|
||||
# Get a fingerprint and length for this track.
|
||||
if not item.length:
|
||||
log.info(u'{0}: no duration available'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
log.info(u'{0}: no duration available',
|
||||
util.displayable_path(item.path))
|
||||
elif item.acoustid_fingerprint:
|
||||
if write:
|
||||
log.info(u'{0}: fingerprint exists, skipping'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
log.info(u'{0}: fingerprint exists, skipping',
|
||||
util.displayable_path(item.path))
|
||||
else:
|
||||
log.info(u'{0}: using existing fingerprint'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
log.info(u'{0}: using existing fingerprint',
|
||||
util.displayable_path(item.path))
|
||||
return item.acoustid_fingerprint
|
||||
else:
|
||||
log.info(u'{0}: fingerprinting'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
log.info(u'{0}: fingerprinting',
|
||||
util.displayable_path(item.path))
|
||||
try:
|
||||
_, fp = acoustid.fingerprint_file(item.path)
|
||||
item.acoustid_fingerprint = fp
|
||||
if write:
|
||||
log.info(u'{0}: writing fingerprint'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
log.info(u'{0}: writing fingerprint',
|
||||
util.displayable_path(item.path))
|
||||
item.try_write()
|
||||
if item._db:
|
||||
item.store()
|
||||
return item.acoustid_fingerprint
|
||||
except acoustid.FingerprintGenerationError as exc:
|
||||
log.info(u'fingerprint generation failed: {0}'
|
||||
.format(exc))
|
||||
log.info(u'fingerprint generation failed: {0}', exc)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Jakob Schnitzer.
|
||||
# Copyright 2015, Jakob Schnitzer.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
"""Converts tracks or albums to external directory
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import subprocess
|
||||
|
|
@ -24,10 +23,9 @@ from string import Template
|
|||
|
||||
from beets import ui, util, plugins, config
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beetsplug.embedart import embed_item
|
||||
from beetsplug.embedart import EmbedCoverArtPlugin
|
||||
from beets.util.confit import ConfigTypeError
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
_fs_lock = threading.Lock()
|
||||
_temp_files = [] # Keep track of temporary transcoded files for deletion.
|
||||
|
||||
|
|
@ -83,55 +81,6 @@ def get_format(format=None):
|
|||
return (command.encode('utf8'), extension.encode('utf8'))
|
||||
|
||||
|
||||
def encode(command, source, dest, pretend=False):
|
||||
"""Encode `source` to `dest` using command template `command`.
|
||||
|
||||
Raises `subprocess.CalledProcessError` if the command exited with a
|
||||
non-zero status code.
|
||||
"""
|
||||
quiet = config['convert']['quiet'].get()
|
||||
|
||||
if not quiet and not pretend:
|
||||
log.info(u'Encoding {0}'.format(util.displayable_path(source)))
|
||||
|
||||
# Substitute $source and $dest in the argument list.
|
||||
args = shlex.split(command)
|
||||
for i, arg in enumerate(args):
|
||||
args[i] = Template(arg).safe_substitute({
|
||||
'source': source,
|
||||
'dest': dest,
|
||||
})
|
||||
|
||||
if pretend:
|
||||
log.info(' '.join(args))
|
||||
return
|
||||
|
||||
try:
|
||||
util.command_output(args)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Something went wrong (probably Ctrl+C), remove temporary files
|
||||
log.info(u'Encoding {0} failed. Cleaning up...'
|
||||
.format(util.displayable_path(source)))
|
||||
log.debug(u'Command {0} exited with status {1}'.format(
|
||||
exc.cmd.decode('utf8', 'ignore'),
|
||||
exc.returncode,
|
||||
))
|
||||
util.remove(dest)
|
||||
util.prune_dirs(os.path.dirname(dest))
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ui.UserError(
|
||||
u"convert: could invoke '{0}': {1}".format(
|
||||
' '.join(args), exc
|
||||
)
|
||||
)
|
||||
|
||||
if not quiet and not pretend:
|
||||
log.info(u'Finished encoding {0}'.format(
|
||||
util.displayable_path(source))
|
||||
)
|
||||
|
||||
|
||||
def should_transcode(item, format):
|
||||
"""Determine whether the item should be transcoded as part of
|
||||
conversion (i.e., its bitrate is high or it has the wrong format).
|
||||
|
|
@ -144,204 +93,6 @@ def should_transcode(item, format):
|
|||
item.bitrate >= 1000 * maxbr
|
||||
|
||||
|
||||
def convert_item(dest_dir, keep_new, path_formats, format, pretend=False):
|
||||
command, ext = get_format(format)
|
||||
item, original, converted = None, None, None
|
||||
while True:
|
||||
item = yield (item, original, converted)
|
||||
dest = item.destination(basedir=dest_dir, path_formats=path_formats)
|
||||
|
||||
# When keeping the new file in the library, we first move the
|
||||
# current (pristine) file to the destination. We'll then copy it
|
||||
# back to its old path or transcode it to a new path.
|
||||
if keep_new:
|
||||
original = dest
|
||||
converted = item.path
|
||||
if should_transcode(item, format):
|
||||
converted = replace_ext(converted, ext)
|
||||
else:
|
||||
original = item.path
|
||||
if should_transcode(item, format):
|
||||
dest = replace_ext(dest, ext)
|
||||
converted = dest
|
||||
|
||||
# Ensure that only one thread tries to create directories at a
|
||||
# time. (The existence check is not atomic with the directory
|
||||
# creation inside this function.)
|
||||
if not pretend:
|
||||
with _fs_lock:
|
||||
util.mkdirall(dest)
|
||||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
log.info(u'Skipping {0} (target file exists)'.format(
|
||||
util.displayable_path(item.path)
|
||||
))
|
||||
continue
|
||||
|
||||
if keep_new:
|
||||
if pretend:
|
||||
log.info(u'mv {0} {1}'.format(
|
||||
util.displayable_path(item.path),
|
||||
util.displayable_path(original),
|
||||
))
|
||||
else:
|
||||
log.info(u'Moving to {0}'.format(
|
||||
util.displayable_path(original))
|
||||
)
|
||||
util.move(item.path, original)
|
||||
|
||||
if should_transcode(item, format):
|
||||
try:
|
||||
encode(command, original, converted, pretend)
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
else:
|
||||
if pretend:
|
||||
log.info(u'cp {0} {1}'.format(
|
||||
util.displayable_path(original),
|
||||
util.displayable_path(converted),
|
||||
))
|
||||
else:
|
||||
# No transcoding necessary.
|
||||
log.info(u'Copying {0}'.format(
|
||||
util.displayable_path(item.path))
|
||||
)
|
||||
util.copy(original, converted)
|
||||
|
||||
if pretend:
|
||||
continue
|
||||
|
||||
# Write tags from the database to the converted file.
|
||||
item.try_write(path=converted)
|
||||
|
||||
if keep_new:
|
||||
# If we're keeping the transcoded file, read it again (after
|
||||
# writing) to get new bitrate, duration, etc.
|
||||
item.path = converted
|
||||
item.read()
|
||||
item.store() # Store new path and audio data.
|
||||
|
||||
if config['convert']['embed']:
|
||||
album = item.get_album()
|
||||
if album and album.artpath:
|
||||
embed_item(item, album.artpath, itempath=converted)
|
||||
|
||||
if keep_new:
|
||||
plugins.send('after_convert', item=item,
|
||||
dest=dest, keepnew=True)
|
||||
else:
|
||||
plugins.send('after_convert', item=item,
|
||||
dest=converted, keepnew=False)
|
||||
|
||||
|
||||
def convert_on_import(lib, item):
|
||||
"""Transcode a file automatically after it is imported into the
|
||||
library.
|
||||
"""
|
||||
format = config['convert']['format'].get(unicode).lower()
|
||||
if should_transcode(item, format):
|
||||
command, ext = get_format()
|
||||
fd, dest = tempfile.mkstemp('.' + ext)
|
||||
os.close(fd)
|
||||
_temp_files.append(dest) # Delete the transcode later.
|
||||
try:
|
||||
encode(command, item.path, dest)
|
||||
except subprocess.CalledProcessError:
|
||||
return
|
||||
item.path = dest
|
||||
item.write()
|
||||
item.read() # Load new audio information data.
|
||||
item.store()
|
||||
|
||||
|
||||
def copy_album_art(album, dest_dir, path_formats, pretend=False):
|
||||
"""Copies the associated cover art of the album. Album must have at least
|
||||
one track.
|
||||
"""
|
||||
if not album or not album.artpath:
|
||||
return
|
||||
|
||||
album_item = album.items().get()
|
||||
# Album shouldn't be empty.
|
||||
if not album_item:
|
||||
return
|
||||
|
||||
# Get the destination of the first item (track) of the album, we use this
|
||||
# function to format the path accordingly to path_formats.
|
||||
dest = album_item.destination(basedir=dest_dir, path_formats=path_formats)
|
||||
|
||||
# Remove item from the path.
|
||||
dest = os.path.join(*util.components(dest)[:-1])
|
||||
|
||||
dest = album.art_destination(album.artpath, item_dir=dest)
|
||||
if album.artpath == dest:
|
||||
return
|
||||
|
||||
if not pretend:
|
||||
util.mkdirall(dest)
|
||||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
log.info(u'Skipping {0} (target file exists)'.format(
|
||||
util.displayable_path(album.artpath)
|
||||
))
|
||||
return
|
||||
|
||||
if pretend:
|
||||
log.info(u'cp {0} {1}'.format(
|
||||
util.displayable_path(album.artpath),
|
||||
util.displayable_path(dest),
|
||||
))
|
||||
else:
|
||||
log.info(u'Copying cover art to {0}'.format(
|
||||
util.displayable_path(dest)))
|
||||
util.copy(album.artpath, dest)
|
||||
|
||||
|
||||
def convert_func(lib, opts, args):
|
||||
if not opts.dest:
|
||||
opts.dest = config['convert']['dest'].get()
|
||||
if not opts.dest:
|
||||
raise ui.UserError('no convert destination set')
|
||||
opts.dest = util.bytestring_path(opts.dest)
|
||||
|
||||
if not opts.threads:
|
||||
opts.threads = config['convert']['threads'].get(int)
|
||||
|
||||
if config['convert']['paths']:
|
||||
path_formats = ui.get_path_formats(config['convert']['paths'])
|
||||
else:
|
||||
path_formats = ui.get_path_formats()
|
||||
|
||||
if not opts.format:
|
||||
opts.format = config['convert']['format'].get(unicode).lower()
|
||||
|
||||
pretend = opts.pretend if opts.pretend is not None else \
|
||||
config['convert']['pretend'].get(bool)
|
||||
|
||||
if not pretend:
|
||||
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
|
||||
|
||||
if not (opts.yes or ui.input_yn("Convert? (Y/n)")):
|
||||
return
|
||||
|
||||
if opts.album:
|
||||
albums = lib.albums(ui.decargs(args))
|
||||
items = (i for a in albums for i in a.items())
|
||||
if config['convert']['copy_album_art']:
|
||||
for album in albums:
|
||||
copy_album_art(album, opts.dest, path_formats, pretend)
|
||||
else:
|
||||
items = iter(lib.items(ui.decargs(args)))
|
||||
convert = [convert_item(opts.dest,
|
||||
opts.keep_new,
|
||||
path_formats,
|
||||
opts.format,
|
||||
pretend)
|
||||
for _ in range(opts.threads)]
|
||||
pipe = util.pipeline.Pipeline([items, convert])
|
||||
pipe.run_parallel()
|
||||
|
||||
|
||||
class ConvertPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(ConvertPlugin, self).__init__()
|
||||
|
|
@ -379,6 +130,8 @@ class ConvertPlugin(BeetsPlugin):
|
|||
})
|
||||
self.import_stages = [self.auto_convert]
|
||||
|
||||
self.register_listener('import_task_files', self._cleanup)
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('convert', help='convert to external location')
|
||||
cmd.parser.add_option('-p', '--pretend', action='store_true',
|
||||
|
|
@ -397,19 +150,257 @@ class ConvertPlugin(BeetsPlugin):
|
|||
help='set the destination directory')
|
||||
cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
|
||||
help='do not ask for confirmation')
|
||||
cmd.func = convert_func
|
||||
cmd.func = self.convert_func
|
||||
return [cmd]
|
||||
|
||||
def auto_convert(self, config, task):
|
||||
if self.config['auto']:
|
||||
for item in task.imported_items():
|
||||
convert_on_import(config.lib, item)
|
||||
self.convert_on_import(config.lib, item)
|
||||
|
||||
# Utilities converted from functions to methods on logging overhaul
|
||||
|
||||
@ConvertPlugin.listen('import_task_files')
|
||||
def _cleanup(task, session):
|
||||
for path in task.old_paths:
|
||||
if path in _temp_files:
|
||||
if os.path.isfile(path):
|
||||
util.remove(path)
|
||||
_temp_files.remove(path)
|
||||
def encode(self, command, source, dest, pretend=False):
|
||||
"""Encode `source` to `dest` using command template `command`.
|
||||
|
||||
Raises `subprocess.CalledProcessError` if the command exited with a
|
||||
non-zero status code.
|
||||
"""
|
||||
quiet = self.config['quiet'].get()
|
||||
|
||||
if not quiet and not pretend:
|
||||
self._log.info(u'Encoding {0}', util.displayable_path(source))
|
||||
|
||||
# Substitute $source and $dest in the argument list.
|
||||
args = shlex.split(command)
|
||||
for i, arg in enumerate(args):
|
||||
args[i] = Template(arg).safe_substitute({
|
||||
'source': source,
|
||||
'dest': dest,
|
||||
})
|
||||
|
||||
if pretend:
|
||||
self._log.info(' '.join(args))
|
||||
return
|
||||
|
||||
try:
|
||||
util.command_output(args)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Something went wrong (probably Ctrl+C), remove temporary files
|
||||
self._log.info(u'Encoding {0} failed. Cleaning up...',
|
||||
util.displayable_path(source))
|
||||
self._log.debug(u'Command {0} exited with status {1}',
|
||||
exc.cmd.decode('utf8', 'ignore'),
|
||||
exc.returncode)
|
||||
util.remove(dest)
|
||||
util.prune_dirs(os.path.dirname(dest))
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ui.UserError(
|
||||
u"convert: could invoke '{0}': {1}".format(
|
||||
' '.join(args), exc
|
||||
)
|
||||
)
|
||||
|
||||
if not quiet and not pretend:
|
||||
self._log.info(u'Finished encoding {0}',
|
||||
util.displayable_path(source))
|
||||
|
||||
def convert_item(self, dest_dir, keep_new, path_formats, format,
|
||||
pretend=False):
|
||||
command, ext = get_format(format)
|
||||
item, original, converted = None, None, None
|
||||
while True:
|
||||
item = yield (item, original, converted)
|
||||
dest = item.destination(basedir=dest_dir,
|
||||
path_formats=path_formats)
|
||||
|
||||
# When keeping the new file in the library, we first move the
|
||||
# current (pristine) file to the destination. We'll then copy it
|
||||
# back to its old path or transcode it to a new path.
|
||||
if keep_new:
|
||||
original = dest
|
||||
converted = item.path
|
||||
if should_transcode(item, format):
|
||||
converted = replace_ext(converted, ext)
|
||||
else:
|
||||
original = item.path
|
||||
if should_transcode(item, format):
|
||||
dest = replace_ext(dest, ext)
|
||||
converted = dest
|
||||
|
||||
# Ensure that only one thread tries to create directories at a
|
||||
# time. (The existence check is not atomic with the directory
|
||||
# creation inside this function.)
|
||||
if not pretend:
|
||||
with _fs_lock:
|
||||
util.mkdirall(dest)
|
||||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
self._log.info(u'Skipping {0} (target file exists)',
|
||||
util.displayable_path(item.path))
|
||||
continue
|
||||
|
||||
if keep_new:
|
||||
if pretend:
|
||||
self._log.info(u'mv {0} {1}',
|
||||
util.displayable_path(item.path),
|
||||
util.displayable_path(original))
|
||||
else:
|
||||
self._log.info(u'Moving to {0}',
|
||||
util.displayable_path(original))
|
||||
util.move(item.path, original)
|
||||
|
||||
if should_transcode(item, format):
|
||||
try:
|
||||
self.encode(command, original, converted, pretend)
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
else:
|
||||
if pretend:
|
||||
self._log.info(u'cp {0} {1}',
|
||||
util.displayable_path(original),
|
||||
util.displayable_path(converted))
|
||||
else:
|
||||
# No transcoding necessary.
|
||||
self._log.info(u'Copying {0}',
|
||||
util.displayable_path(item.path))
|
||||
util.copy(original, converted)
|
||||
|
||||
if pretend:
|
||||
continue
|
||||
|
||||
# Write tags from the database to the converted file.
|
||||
item.try_write(path=converted)
|
||||
|
||||
if keep_new:
|
||||
# If we're keeping the transcoded file, read it again (after
|
||||
# writing) to get new bitrate, duration, etc.
|
||||
item.path = converted
|
||||
item.read()
|
||||
item.store() # Store new path and audio data.
|
||||
|
||||
if self.config['embed']:
|
||||
album = item.get_album()
|
||||
if album and album.artpath:
|
||||
EmbedCoverArtPlugin().embed_item(item, album.artpath,
|
||||
itempath=converted)
|
||||
|
||||
if keep_new:
|
||||
plugins.send('after_convert', item=item,
|
||||
dest=dest, keepnew=True)
|
||||
else:
|
||||
plugins.send('after_convert', item=item,
|
||||
dest=converted, keepnew=False)
|
||||
|
||||
def copy_album_art(self, album, dest_dir, path_formats, pretend=False):
|
||||
"""Copies the associated cover art of the album. Album must have at
|
||||
least one track.
|
||||
"""
|
||||
if not album or not album.artpath:
|
||||
return
|
||||
|
||||
album_item = album.items().get()
|
||||
# Album shouldn't be empty.
|
||||
if not album_item:
|
||||
return
|
||||
|
||||
# Get the destination of the first item (track) of the album, we use
|
||||
# this function to format the path accordingly to path_formats.
|
||||
dest = album_item.destination(basedir=dest_dir,
|
||||
path_formats=path_formats)
|
||||
|
||||
# Remove item from the path.
|
||||
dest = os.path.join(*util.components(dest)[:-1])
|
||||
|
||||
dest = album.art_destination(album.artpath, item_dir=dest)
|
||||
if album.artpath == dest:
|
||||
return
|
||||
|
||||
if not pretend:
|
||||
util.mkdirall(dest)
|
||||
|
||||
if os.path.exists(util.syspath(dest)):
|
||||
self._log.info(u'Skipping {0} (target file exists)',
|
||||
util.displayable_path(album.artpath))
|
||||
return
|
||||
|
||||
if pretend:
|
||||
self._log.info(u'cp {0} {1}',
|
||||
util.displayable_path(album.artpath),
|
||||
util.displayable_path(dest))
|
||||
else:
|
||||
self._log.info(u'Copying cover art to {0}',
|
||||
util.displayable_path(dest))
|
||||
util.copy(album.artpath, dest)
|
||||
|
||||
def convert_func(self, lib, opts, args):
|
||||
if not opts.dest:
|
||||
opts.dest = self.config['dest'].get()
|
||||
if not opts.dest:
|
||||
raise ui.UserError('no convert destination set')
|
||||
opts.dest = util.bytestring_path(opts.dest)
|
||||
|
||||
if not opts.threads:
|
||||
opts.threads = self.config['threads'].get(int)
|
||||
|
||||
if self.config['paths']:
|
||||
path_formats = ui.get_path_formats(self.config['paths'])
|
||||
else:
|
||||
path_formats = ui.get_path_formats()
|
||||
|
||||
if not opts.format:
|
||||
opts.format = self.config['format'].get(unicode).lower()
|
||||
|
||||
pretend = opts.pretend if opts.pretend is not None else \
|
||||
self.config['pretend'].get(bool)
|
||||
|
||||
if not pretend:
|
||||
ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
|
||||
|
||||
if not (opts.yes or ui.input_yn("Convert? (Y/n)")):
|
||||
return
|
||||
|
||||
if opts.album:
|
||||
albums = lib.albums(ui.decargs(args))
|
||||
items = (i for a in albums for i in a.items())
|
||||
if self.config['copy_album_art']:
|
||||
for album in albums:
|
||||
self.copy_album_art(album, opts.dest, path_formats,
|
||||
pretend)
|
||||
else:
|
||||
items = iter(lib.items(ui.decargs(args)))
|
||||
convert = [self.convert_item(opts.dest,
|
||||
opts.keep_new,
|
||||
path_formats,
|
||||
opts.format,
|
||||
pretend)
|
||||
for _ in range(opts.threads)]
|
||||
pipe = util.pipeline.Pipeline([items, convert])
|
||||
pipe.run_parallel()
|
||||
|
||||
def convert_on_import(self, lib, item):
|
||||
"""Transcode a file automatically after it is imported into the
|
||||
library.
|
||||
"""
|
||||
format = self.config['format'].get(unicode).lower()
|
||||
if should_transcode(item, format):
|
||||
command, ext = get_format()
|
||||
fd, dest = tempfile.mkstemp('.' + ext)
|
||||
os.close(fd)
|
||||
_temp_files.append(dest) # Delete the transcode later.
|
||||
try:
|
||||
self.encode(command, item.path, dest)
|
||||
except subprocess.CalledProcessError:
|
||||
return
|
||||
item.path = dest
|
||||
item.write()
|
||||
item.read() # Load new audio information data.
|
||||
item.store()
|
||||
|
||||
def _cleanup(self, task, session):
|
||||
for path in task.old_paths:
|
||||
if path in _temp_files:
|
||||
if os.path.isfile(path):
|
||||
util.remove(path)
|
||||
_temp_files.remove(path)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
"""Adds Discogs album search support to the autotagger. Requires the
|
||||
discogs-client library.
|
||||
"""
|
||||
from beets import logging
|
||||
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import confit
|
||||
|
|
@ -22,12 +23,10 @@ from discogs_client import Release, Client
|
|||
from discogs_client.exceptions import DiscogsAPIError
|
||||
from requests.exceptions import ConnectionError
|
||||
import beets
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# Silence spurious INFO log lines generated by urllib3.
|
||||
urllib3_logger = logging.getLogger('requests.packages.urllib3')
|
||||
|
|
@ -89,7 +88,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
raise beets.ui.UserError('Discogs authorization failed')
|
||||
|
||||
# Save the token for later use.
|
||||
log.debug('Discogs token {0}, secret {1}'.format(token, secret))
|
||||
self._log.debug('Discogs token {0}, secret {1}', token, secret)
|
||||
with open(self._tokenfile(), 'w') as f:
|
||||
json.dump({'token': token, 'secret': secret}, f)
|
||||
|
||||
|
|
@ -117,10 +116,10 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
try:
|
||||
return self.get_albums(query)
|
||||
except DiscogsAPIError as e:
|
||||
log.debug(u'Discogs API Error: {0} (query: {1})'.format(e, query))
|
||||
self._log.debug(u'API Error: {0} (query: {1})', e, query)
|
||||
return []
|
||||
except ConnectionError as e:
|
||||
log.debug(u'HTTP Connection Error: {0}'.format(e))
|
||||
self._log.debug(u'HTTP Connection Error: {0}', e)
|
||||
return []
|
||||
|
||||
def album_for_id(self, album_id):
|
||||
|
|
@ -130,7 +129,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
if not self.discogs_client:
|
||||
return
|
||||
|
||||
log.debug(u'Searching Discogs for release {0}'.format(str(album_id)))
|
||||
self._log.debug(u'Searching for release {0}', album_id)
|
||||
# Discogs-IDs are simple integers. We only look for those at the end
|
||||
# of an input string as to avoid confusion with other metadata plugins.
|
||||
# An optional bracket can follow the integer, as this is how discogs
|
||||
|
|
@ -145,11 +144,10 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
getattr(result, 'title')
|
||||
except DiscogsAPIError as e:
|
||||
if e.message != '404 Not Found':
|
||||
log.debug(u'Discogs API Error: {0} (query: {1})'
|
||||
.format(e, result._uri))
|
||||
self._log.debug(u'API Error: {0} (query: {1})', e, result._uri)
|
||||
return None
|
||||
except ConnectionError as e:
|
||||
log.debug(u'HTTP Connection Error: {0}'.format(e))
|
||||
self._log.debug(u'HTTP Connection Error: {0}', e)
|
||||
return None
|
||||
return self.get_album_info(result)
|
||||
|
||||
|
|
@ -294,7 +292,7 @@ class DiscogsPlugin(BeetsPlugin):
|
|||
if match:
|
||||
medium, index = match.groups()
|
||||
else:
|
||||
log.debug(u'Invalid Discogs position: {0}'.format(position))
|
||||
self._log.debug(u'Invalid position: {0}', position)
|
||||
medium = index = None
|
||||
return medium or None, index or None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Pedro Silva.
|
||||
# Copyright 2015, Pedro Silva.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,14 +15,12 @@
|
|||
"""List duplicate tracks or albums.
|
||||
"""
|
||||
import shlex
|
||||
import logging
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs, print_obj, vararg_callback, Subcommand, UserError
|
||||
from beets.util import command_output, displayable_path, subprocess
|
||||
|
||||
PLUGIN = 'duplicates'
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def _process_item(item, lib, copy=False, move=False, delete=False,
|
||||
|
|
@ -47,7 +45,7 @@ def _process_item(item, lib, copy=False, move=False, delete=False,
|
|||
print_obj(item, lib, fmt=format)
|
||||
|
||||
|
||||
def _checksum(item, prog):
|
||||
def _checksum(item, prog, log):
|
||||
"""Run external `prog` on file path associated with `item`, cache
|
||||
output as flexattr on a key that is the name of the program, and
|
||||
return the key, checksum tuple.
|
||||
|
|
@ -56,24 +54,24 @@ def _checksum(item, prog):
|
|||
key = args[0]
|
||||
checksum = getattr(item, key, False)
|
||||
if not checksum:
|
||||
log.debug(u'{0}: key {1} on item {2} not cached: computing checksum'
|
||||
.format(PLUGIN, key, displayable_path(item.path)))
|
||||
log.debug(u'{0}: key {1} on item {2} not cached: computing checksum',
|
||||
PLUGIN, key, displayable_path(item.path))
|
||||
try:
|
||||
checksum = command_output(args)
|
||||
setattr(item, key, checksum)
|
||||
item.store()
|
||||
log.debug(u'{)}: computed checksum for {1} using {2}'
|
||||
.format(PLUGIN, item.title, key))
|
||||
log.debug(u'{0}: computed checksum for {1} using {2}',
|
||||
PLUGIN, item.title, key)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.debug(u'{0}: failed to checksum {1}: {2}'
|
||||
.format(PLUGIN, displayable_path(item.path), e))
|
||||
log.debug(u'{0}: failed to checksum {1}: {2}',
|
||||
PLUGIN, displayable_path(item.path), e)
|
||||
else:
|
||||
log.debug(u'{0}: key {1} on item {2} cached: not computing checksum'
|
||||
.format(PLUGIN, key, displayable_path(item.path)))
|
||||
log.debug(u'{0}: key {1} on item {2} cached: not computing checksum',
|
||||
PLUGIN, key, displayable_path(item.path))
|
||||
return key, checksum
|
||||
|
||||
|
||||
def _group_by(objs, keys):
|
||||
def _group_by(objs, keys, log):
|
||||
"""Return a dictionary with keys arbitrary concatenations of attributes and
|
||||
values lists of objects (Albums or Items) with those keys.
|
||||
"""
|
||||
|
|
@ -86,17 +84,17 @@ def _group_by(objs, keys):
|
|||
key = '\001'.join(values)
|
||||
counts[key].append(obj)
|
||||
else:
|
||||
log.debug(u'{0}: all keys {1} on item {2} are null: skipping'
|
||||
.format(PLUGIN, str(keys), displayable_path(obj.path)))
|
||||
log.debug(u'{0}: all keys {1} on item {2} are null: skipping',
|
||||
PLUGIN, str(keys), displayable_path(obj.path))
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def _duplicates(objs, keys, full):
|
||||
def _duplicates(objs, keys, full, log):
|
||||
"""Generate triples of keys, duplicate counts, and constituent objects.
|
||||
"""
|
||||
offset = 0 if full else 1
|
||||
for k, objs in _group_by(objs, keys).iteritems():
|
||||
for k, objs in _group_by(objs, keys, log).iteritems():
|
||||
if len(objs) > 1:
|
||||
yield (k, len(objs) - offset, objs[offset:])
|
||||
|
||||
|
|
@ -214,12 +212,13 @@ class DuplicatesPlugin(BeetsPlugin):
|
|||
'duplicates: "checksum" option must be a command'
|
||||
)
|
||||
for i in items:
|
||||
k, _ = _checksum(i, checksum)
|
||||
k, _ = self._checksum(i, checksum, self._log)
|
||||
keys = [k]
|
||||
|
||||
for obj_id, obj_count, objs in _duplicates(items,
|
||||
keys=keys,
|
||||
full=full):
|
||||
full=full,
|
||||
log=self._log):
|
||||
if obj_id: # Skip empty IDs.
|
||||
for o in objs:
|
||||
_process_item(o, lib,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
"""Fetch a variety of acoustic metrics from The Echo Nest.
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
import socket
|
||||
import os
|
||||
import tempfile
|
||||
|
|
@ -28,8 +27,6 @@ import pyechonest
|
|||
import pyechonest.song
|
||||
import pyechonest.track
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# If a request at the EchoNest fails, we want to retry the request RETRIES
|
||||
# times and wait between retries for RETRY_INTERVAL seconds.
|
||||
RETRIES = 10
|
||||
|
|
@ -137,7 +134,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
self.config.add(ATTRIBUTES)
|
||||
|
||||
pyechonest.config.ECHO_NEST_API_KEY = \
|
||||
config['echonest']['apikey'].get(unicode)
|
||||
self.config['apikey'].get(unicode)
|
||||
|
||||
if self.config['auto']:
|
||||
self.import_stages = [self.imported]
|
||||
|
|
@ -153,31 +150,30 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
except pyechonest.util.EchoNestAPIError as e:
|
||||
if e.code == 3:
|
||||
# reached access limit per minute
|
||||
log.debug(u'echonest: rate-limited on try {0}; '
|
||||
u'waiting {1} seconds'
|
||||
.format(i + 1, RETRY_INTERVAL))
|
||||
self._log.debug(u'rate-limited on try {0}; waiting {1} '
|
||||
u'seconds', i + 1, RETRY_INTERVAL)
|
||||
time.sleep(RETRY_INTERVAL)
|
||||
elif e.code == 5:
|
||||
# specified identifier does not exist
|
||||
# no use in trying again.
|
||||
log.debug(u'echonest: {0}'.format(e))
|
||||
self._log.debug(u'{0}', e)
|
||||
return None
|
||||
else:
|
||||
log.error(u'echonest: {0}'.format(e.args[0][0]))
|
||||
self._log.error(u'{0}', e.args[0][0])
|
||||
return None
|
||||
except (pyechonest.util.EchoNestIOError, socket.error) as e:
|
||||
log.warn(u'echonest: IO error: {0}'.format(e))
|
||||
self._log.warn(u'IO error: {0}', e)
|
||||
time.sleep(RETRY_INTERVAL)
|
||||
except Exception as e:
|
||||
# there was an error analyzing the track, status: error
|
||||
log.debug(u'echonest: {0}'.format(e))
|
||||
self._log.debug(u'{0}', e)
|
||||
return None
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# If we exited the loop without breaking, then we used up all
|
||||
# our allotted retries.
|
||||
log.error(u'echonest request failed repeatedly')
|
||||
self._log.error(u'request failed repeatedly')
|
||||
return None
|
||||
return result
|
||||
|
||||
|
|
@ -188,7 +184,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
seconds, it's considered a match.
|
||||
"""
|
||||
if not songs:
|
||||
log.debug(u'echonest: no songs found')
|
||||
self._log.debug(u'no songs found')
|
||||
return
|
||||
|
||||
pick = None
|
||||
|
|
@ -226,13 +222,13 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
# Look up the Echo Nest ID based on the MBID.
|
||||
else:
|
||||
if not item.mb_trackid:
|
||||
log.debug(u'echonest: no ID available')
|
||||
self._log.debug(u'no ID available')
|
||||
return
|
||||
mbid = 'musicbrainz:track:{0}'.format(item.mb_trackid)
|
||||
track = self._echofun(pyechonest.track.track_from_id,
|
||||
identifier=mbid)
|
||||
if not track:
|
||||
log.debug(u'echonest: lookup by MBID failed')
|
||||
self._log.debug(u'lookup by MBID failed')
|
||||
return
|
||||
enid = track.song_id
|
||||
|
||||
|
|
@ -267,13 +263,13 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
source = item.path
|
||||
tmp = None
|
||||
if item.format not in ALLOWED_FORMATS:
|
||||
if config['echonest']['convert']:
|
||||
if self.config['convert']:
|
||||
tmp = source = self.convert(source)
|
||||
if not tmp:
|
||||
return
|
||||
|
||||
if os.stat(source).st_size > UPLOAD_MAX_SIZE:
|
||||
if config['echonest']['truncate']:
|
||||
if self.config['truncate']:
|
||||
source = self.truncate(source)
|
||||
if tmp is not None:
|
||||
util.remove(tmp)
|
||||
|
|
@ -292,10 +288,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
fd, dest = tempfile.mkstemp(u'.ogg')
|
||||
os.close(fd)
|
||||
|
||||
log.info(u'echonest: encoding {0} to {1}'.format(
|
||||
util.displayable_path(source),
|
||||
util.displayable_path(dest),
|
||||
))
|
||||
self._log.info(u'encoding {0} to {1}',
|
||||
util.displayable_path(source),
|
||||
util.displayable_path(dest))
|
||||
|
||||
opts = []
|
||||
for arg in CONVERT_COMMAND.split():
|
||||
|
|
@ -306,13 +301,11 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
try:
|
||||
util.command_output(opts)
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
log.debug(u'echonest: encode failed: {0}'.format(exc))
|
||||
self._log.debug(u'encode failed: {0}', exc)
|
||||
util.remove(dest)
|
||||
return
|
||||
|
||||
log.info(u'echonest: finished encoding {0}'.format(
|
||||
util.displayable_path(source))
|
||||
)
|
||||
self._log.info(u'finished encoding {0}', util.displayable_path(source))
|
||||
return dest
|
||||
|
||||
def truncate(self, source):
|
||||
|
|
@ -320,10 +313,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
fd, dest = tempfile.mkstemp(u'.ogg')
|
||||
os.close(fd)
|
||||
|
||||
log.info(u'echonest: truncating {0} to {1}'.format(
|
||||
util.displayable_path(source),
|
||||
util.displayable_path(dest),
|
||||
))
|
||||
self._log.info(u'truncating {0} to {1}',
|
||||
util.displayable_path(source),
|
||||
util.displayable_path(dest))
|
||||
|
||||
opts = []
|
||||
for arg in TRUNCATE_COMMAND.split():
|
||||
|
|
@ -334,13 +326,11 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
try:
|
||||
util.command_output(opts)
|
||||
except (OSError, subprocess.CalledProcessError) as exc:
|
||||
log.debug(u'echonest: truncate failed: {0}'.format(exc))
|
||||
self._log.debug(u'truncate failed: {0}', exc)
|
||||
util.remove(dest)
|
||||
return
|
||||
|
||||
log.info(u'echonest: truncate encoding {0}'.format(
|
||||
util.displayable_path(source))
|
||||
)
|
||||
self._log.info(u'truncate encoding {0}', util.displayable_path(source))
|
||||
return dest
|
||||
|
||||
def analyze(self, item):
|
||||
|
|
@ -349,18 +339,18 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
"""
|
||||
prepared = self.prepare_upload(item)
|
||||
if not prepared:
|
||||
log.debug(u'echonest: could not prepare file for upload')
|
||||
self._log.debug(u'could not prepare file for upload')
|
||||
return
|
||||
|
||||
source, tmp = prepared
|
||||
log.info(u'echonest: uploading file, please be patient')
|
||||
self._log.info(u'uploading file, please be patient')
|
||||
track = self._echofun(pyechonest.track.track_from_filename,
|
||||
filename=source)
|
||||
if tmp is not None:
|
||||
util.remove(tmp)
|
||||
|
||||
if not track:
|
||||
log.debug(u'echonest: failed to upload file')
|
||||
self._log.debug(u'failed to upload file')
|
||||
return
|
||||
|
||||
# Sometimes we have a track but no song. I guess this happens for
|
||||
|
|
@ -404,21 +394,19 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
# There are four different ways to get a song. Each method is a
|
||||
# callable that takes the Item as an argument.
|
||||
methods = [self.profile, self.search]
|
||||
if config['echonest']['upload']:
|
||||
if self.config['upload']:
|
||||
methods.append(self.analyze)
|
||||
|
||||
# Try each method in turn.
|
||||
for method in methods:
|
||||
song = method(item)
|
||||
if song:
|
||||
log.debug(
|
||||
u'echonest: got song through {0}: {1} - {2} [{3}]'.format(
|
||||
method.__name__,
|
||||
item.artist,
|
||||
item.title,
|
||||
song.get('duration'),
|
||||
)
|
||||
)
|
||||
self._log.debug(u'got song through {0}: {1} - {2} [{3}]',
|
||||
method.__name__,
|
||||
item.artist,
|
||||
item.title,
|
||||
song.get('duration'),
|
||||
)
|
||||
return song
|
||||
|
||||
def apply_metadata(self, item, values, write=False):
|
||||
|
|
@ -429,7 +417,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
for k, v in values.iteritems():
|
||||
if k in ATTRIBUTES:
|
||||
field = ATTRIBUTES[k]
|
||||
log.debug(u'echonest: metadata: {0} = {1}'.format(field, v))
|
||||
self._log.debug(u'metadata: {0} = {1}', field, v)
|
||||
if field == 'bpm':
|
||||
item[field] = int(v)
|
||||
else:
|
||||
|
|
@ -441,7 +429,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
item['initial_key'] = key
|
||||
if 'id' in values:
|
||||
enid = values['id']
|
||||
log.debug(u'echonest: metadata: {0} = {1}'.format(ID_KEY, enid))
|
||||
self._log.debug(u'metadata: {0} = {1}', ID_KEY, enid)
|
||||
item[ID_KEY] = enid
|
||||
|
||||
# Write and save.
|
||||
|
|
@ -468,7 +456,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
for field in ATTRIBUTES.values():
|
||||
if not item.get(field):
|
||||
return True
|
||||
log.info(u'echonest: no update required')
|
||||
self._log.info(u'no update required')
|
||||
return False
|
||||
|
||||
def commands(self):
|
||||
|
|
@ -483,8 +471,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin):
|
|||
self.config.set_args(opts)
|
||||
write = config['import']['write'].get(bool)
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
log.info(u'echonest: {0} - {1}'.format(item.artist,
|
||||
item.title))
|
||||
self._log.info(u'{0} - {1}', item.artist, item.title)
|
||||
if self.config['force'] or self.requires_update(item):
|
||||
song = self.fetch_song(item)
|
||||
if song:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,12 +14,12 @@
|
|||
|
||||
"""Allows beets to embed album art into file metadata."""
|
||||
import os.path
|
||||
import logging
|
||||
import imghdr
|
||||
import subprocess
|
||||
import platform
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
from beets import logging
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import mediafile
|
||||
from beets import ui
|
||||
|
|
@ -29,9 +29,6 @@ from beets.util.artresizer import ArtResizer
|
|||
from beets import config
|
||||
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class EmbedCoverArtPlugin(BeetsPlugin):
|
||||
"""Allows albumart to be embedded into the actual files.
|
||||
"""
|
||||
|
|
@ -46,13 +43,15 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
|
||||
if self.config['maxwidth'].get(int) and not ArtResizer.shared.local:
|
||||
self.config['maxwidth'] = 0
|
||||
log.warn(u"embedart: ImageMagick or PIL not found; "
|
||||
u"'maxwidth' option ignored")
|
||||
self._log.warn(u"ImageMagick or PIL not found; "
|
||||
u"'maxwidth' option ignored")
|
||||
if self.config['compare_threshold'].get(int) and not \
|
||||
ArtResizer.shared.can_compare:
|
||||
self.config['compare_threshold'] = 0
|
||||
log.warn(u"embedart: ImageMagick 6.8.7 or higher not installed; "
|
||||
u"'compare_threshold' option ignored")
|
||||
self._log.warn(u"ImageMagick 6.8.7 or higher not installed; "
|
||||
u"'compare_threshold' option ignored")
|
||||
|
||||
self.register_listener('album_imported', self.album_imported)
|
||||
|
||||
def commands(self):
|
||||
# Embed command.
|
||||
|
|
@ -62,19 +61,19 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
embed_cmd.parser.add_option(
|
||||
'-f', '--file', metavar='PATH', help='the image file to embed'
|
||||
)
|
||||
maxwidth = config['embedart']['maxwidth'].get(int)
|
||||
compare_threshold = config['embedart']['compare_threshold'].get(int)
|
||||
ifempty = config['embedart']['ifempty'].get(bool)
|
||||
maxwidth = self.config['maxwidth'].get(int)
|
||||
compare_threshold = self.config['compare_threshold'].get(int)
|
||||
ifempty = self.config['ifempty'].get(bool)
|
||||
|
||||
def embed_func(lib, opts, args):
|
||||
if opts.file:
|
||||
imagepath = normpath(opts.file)
|
||||
for item in lib.items(decargs(args)):
|
||||
embed_item(item, imagepath, maxwidth, None,
|
||||
compare_threshold, ifempty)
|
||||
self.embed_item(item, imagepath, maxwidth, None,
|
||||
compare_threshold, ifempty)
|
||||
else:
|
||||
for album in lib.albums(decargs(args)):
|
||||
embed_album(album, maxwidth)
|
||||
self.embed_album(album, maxwidth)
|
||||
|
||||
embed_cmd.func = embed_func
|
||||
|
||||
|
|
@ -87,7 +86,7 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
def extract_func(lib, opts, args):
|
||||
outpath = normpath(opts.outpath or 'cover')
|
||||
item = lib.items(decargs(args)).get()
|
||||
extract(outpath, item)
|
||||
self.extract(outpath, item)
|
||||
extract_cmd.func = extract_func
|
||||
|
||||
# Clear command.
|
||||
|
|
@ -95,186 +94,173 @@ class EmbedCoverArtPlugin(BeetsPlugin):
|
|||
help='remove images from file metadata')
|
||||
|
||||
def clear_func(lib, opts, args):
|
||||
clear(lib, decargs(args))
|
||||
self.clear(lib, decargs(args))
|
||||
clear_cmd.func = clear_func
|
||||
|
||||
return [embed_cmd, extract_cmd, clear_cmd]
|
||||
|
||||
def album_imported(self, lib, album):
|
||||
"""Automatically embed art into imported albums.
|
||||
"""
|
||||
if album.artpath and self.config['auto']:
|
||||
max_width = self.config['maxwidth'].get(int)
|
||||
self.embed_album(album, max_width, True)
|
||||
|
||||
@EmbedCoverArtPlugin.listen('album_imported')
|
||||
def album_imported(lib, album):
|
||||
"""Automatically embed art into imported albums.
|
||||
"""
|
||||
if album.artpath and config['embedart']['auto']:
|
||||
embed_album(album, config['embedart']['maxwidth'].get(int), True)
|
||||
|
||||
|
||||
def embed_item(item, imagepath, maxwidth=None, itempath=None,
|
||||
compare_threshold=0, ifempty=False, as_album=False):
|
||||
"""Embed an image into the item's media file.
|
||||
"""
|
||||
if compare_threshold:
|
||||
if not check_art_similarity(item, imagepath, compare_threshold):
|
||||
log.warn(u'Image not similar; skipping.')
|
||||
return
|
||||
if ifempty:
|
||||
art = get_art(item)
|
||||
if not art:
|
||||
pass
|
||||
else:
|
||||
log.debug(u'embedart: media file contained art already {0}'.format(
|
||||
displayable_path(imagepath)
|
||||
))
|
||||
return
|
||||
if maxwidth and not as_album:
|
||||
imagepath = resize_image(imagepath, maxwidth)
|
||||
|
||||
try:
|
||||
log.debug(u'embedart: embedding {0}'.format(
|
||||
displayable_path(imagepath)
|
||||
))
|
||||
item['images'] = [_mediafile_image(imagepath, maxwidth)]
|
||||
except IOError as exc:
|
||||
log.error(u'embedart: could not read image file: {0}'.format(exc))
|
||||
else:
|
||||
# We don't want to store the image in the database.
|
||||
item.try_write(itempath)
|
||||
del item['images']
|
||||
|
||||
|
||||
def embed_album(album, maxwidth=None, quiet=False):
|
||||
"""Embed album art into all of the album's items.
|
||||
"""
|
||||
imagepath = album.artpath
|
||||
if not imagepath:
|
||||
log.info(u'No album art present: {0} - {1}'.
|
||||
format(album.albumartist, album.album))
|
||||
return
|
||||
if not os.path.isfile(syspath(imagepath)):
|
||||
log.error(u'Album art not found at {0}'
|
||||
.format(displayable_path(imagepath)))
|
||||
return
|
||||
if maxwidth:
|
||||
imagepath = resize_image(imagepath, maxwidth)
|
||||
|
||||
log.log(
|
||||
logging.DEBUG if quiet else logging.INFO,
|
||||
u'Embedding album art into {0.albumartist} - {0.album}.'.format(album),
|
||||
)
|
||||
|
||||
for item in album.items():
|
||||
embed_item(item, imagepath, maxwidth, None,
|
||||
config['embedart']['compare_threshold'].get(int),
|
||||
config['embedart']['ifempty'].get(bool), as_album=True)
|
||||
|
||||
|
||||
def resize_image(imagepath, maxwidth):
|
||||
"""Returns path to an image resized to maxwidth.
|
||||
"""
|
||||
log.info(u'Resizing album art to {0} pixels wide'
|
||||
.format(maxwidth))
|
||||
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath))
|
||||
return imagepath
|
||||
|
||||
|
||||
def check_art_similarity(item, imagepath, compare_threshold):
|
||||
"""A boolean indicating if an image is similar to embedded item art.
|
||||
"""
|
||||
with NamedTemporaryFile(delete=True) as f:
|
||||
art = extract(f.name, item)
|
||||
|
||||
if art:
|
||||
# Converting images to grayscale tends to minimize the weight
|
||||
# of colors in the diff score
|
||||
cmd = 'convert {0} {1} -colorspace gray MIFF:- | ' \
|
||||
'compare -metric PHASH - null:'.format(syspath(imagepath),
|
||||
syspath(art))
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
close_fds=platform.system() != 'Windows',
|
||||
shell=True)
|
||||
stdout, stderr = proc.communicate()
|
||||
if proc.returncode:
|
||||
if proc.returncode != 1:
|
||||
log.warn(u'embedart: IM phashes compare failed for {0}, \
|
||||
{1}'.format(displayable_path(imagepath),
|
||||
displayable_path(art)))
|
||||
return
|
||||
phashDiff = float(stderr)
|
||||
def embed_item(self, item, imagepath, maxwidth=None, itempath=None,
|
||||
compare_threshold=0, ifempty=False, as_album=False):
|
||||
"""Embed an image into the item's media file.
|
||||
"""
|
||||
if compare_threshold:
|
||||
if not self.check_art_similarity(item, imagepath,
|
||||
compare_threshold):
|
||||
self._log.warn(u'Image not similar; skipping.')
|
||||
return
|
||||
if ifempty:
|
||||
art = self.get_art(item)
|
||||
if not art:
|
||||
pass
|
||||
else:
|
||||
phashDiff = float(stdout)
|
||||
self._log.debug(u'media file contained art already {0}',
|
||||
displayable_path(imagepath))
|
||||
return
|
||||
if maxwidth and not as_album:
|
||||
imagepath = self.resize_image(imagepath, maxwidth)
|
||||
|
||||
log.info(u'embedart: compare PHASH score is {0}'.format(phashDiff))
|
||||
if phashDiff > compare_threshold:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _mediafile_image(image_path, maxwidth=None):
|
||||
"""Return a `mediafile.Image` object for the path.
|
||||
"""
|
||||
|
||||
with open(syspath(image_path), 'rb') as f:
|
||||
data = f.read()
|
||||
return mediafile.Image(data, type=mediafile.ImageType.front)
|
||||
|
||||
|
||||
def get_art(item):
|
||||
# Extract the art.
|
||||
try:
|
||||
mf = mediafile.MediaFile(syspath(item.path))
|
||||
except mediafile.UnreadableFileError as exc:
|
||||
log.error(u'Could not extract art from {0}: {1}'.format(
|
||||
displayable_path(item.path), exc
|
||||
))
|
||||
return
|
||||
|
||||
return mf.art
|
||||
|
||||
# 'extractart' command.
|
||||
|
||||
|
||||
def extract(outpath, item):
|
||||
if not item:
|
||||
log.error(u'No item matches query.')
|
||||
return
|
||||
|
||||
art = get_art(item)
|
||||
|
||||
if not art:
|
||||
log.error(u'No album art present in {0} - {1}.'
|
||||
.format(item.artist, item.title))
|
||||
return
|
||||
|
||||
# Add an extension to the filename.
|
||||
ext = imghdr.what(None, h=art)
|
||||
if not ext:
|
||||
log.error(u'Unknown image type.')
|
||||
return
|
||||
outpath += '.' + ext
|
||||
|
||||
log.info(u'Extracting album art from: {0.artist} - {0.title} '
|
||||
u'to: {1}'.format(item, displayable_path(outpath)))
|
||||
with open(syspath(outpath), 'wb') as f:
|
||||
f.write(art)
|
||||
return outpath
|
||||
|
||||
|
||||
# 'clearart' command.
|
||||
|
||||
def clear(lib, query):
|
||||
log.info(u'Clearing album art from items:')
|
||||
for item in lib.items(query):
|
||||
log.info(u'{0} - {1}'.format(item.artist, item.title))
|
||||
try:
|
||||
mf = mediafile.MediaFile(syspath(item.path),
|
||||
config['id3v23'].get(bool))
|
||||
self._log.debug(u'embedding {0}', displayable_path(imagepath))
|
||||
item['images'] = [self._mediafile_image(imagepath, maxwidth)]
|
||||
except IOError as exc:
|
||||
self._log.error(u'could not read image file: {0}', exc)
|
||||
else:
|
||||
# We don't want to store the image in the database.
|
||||
item.try_write(itempath)
|
||||
del item['images']
|
||||
|
||||
def embed_album(self, album, maxwidth=None, quiet=False):
|
||||
"""Embed album art into all of the album's items.
|
||||
"""
|
||||
imagepath = album.artpath
|
||||
if not imagepath:
|
||||
self._log.info(u'No album art present: {0} - {1}',
|
||||
album.albumartist, album.album)
|
||||
return
|
||||
if not os.path.isfile(syspath(imagepath)):
|
||||
self._log.error(u'Album art not found at {0}',
|
||||
displayable_path(imagepath))
|
||||
return
|
||||
if maxwidth:
|
||||
imagepath = self.resize_image(imagepath, maxwidth)
|
||||
|
||||
self._log.log(
|
||||
logging.DEBUG if quiet else logging.INFO,
|
||||
u'Embedding album art into {0.albumartist} - {0.album}.', album
|
||||
)
|
||||
|
||||
for item in album.items():
|
||||
thresh = self.config['compare_threshold'].get(int)
|
||||
ifempty = self.config['ifempty'].get(bool)
|
||||
self.embed_item(item, imagepath, maxwidth, None,
|
||||
thresh, ifempty, as_album=True)
|
||||
|
||||
def resize_image(self, imagepath, maxwidth):
|
||||
"""Returns path to an image resized to maxwidth.
|
||||
"""
|
||||
self._log.info(u'Resizing album art to {0} pixels wide', maxwidth)
|
||||
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath))
|
||||
return imagepath
|
||||
|
||||
def check_art_similarity(self, item, imagepath, compare_threshold):
|
||||
"""A boolean indicating if an image is similar to embedded item art.
|
||||
"""
|
||||
with NamedTemporaryFile(delete=True) as f:
|
||||
art = self.extract(f.name, item)
|
||||
|
||||
if art:
|
||||
# Converting images to grayscale tends to minimize the weight
|
||||
# of colors in the diff score
|
||||
cmd = 'convert {0} {1} -colorspace gray MIFF:- | ' \
|
||||
'compare -metric PHASH - null:' \
|
||||
.format(syspath(imagepath), syspath(art))
|
||||
|
||||
is_windows = platform.system() != "Windows"
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
close_fds=is_windows,
|
||||
shell=True)
|
||||
stdout, stderr = proc.communicate()
|
||||
if proc.returncode:
|
||||
if proc.returncode != 1:
|
||||
self._log.warn(u'IM phashes compare failed for {0}, '
|
||||
u'{1}', displayable_path(imagepath),
|
||||
displayable_path(art))
|
||||
return
|
||||
phashDiff = float(stderr)
|
||||
else:
|
||||
phashDiff = float(stdout)
|
||||
|
||||
self._log.info(u'compare PHASH score is {0}', phashDiff)
|
||||
if phashDiff > compare_threshold:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _mediafile_image(self, image_path, maxwidth=None):
|
||||
"""Return a `mediafile.Image` object for the path.
|
||||
"""
|
||||
|
||||
with open(syspath(image_path), 'rb') as f:
|
||||
data = f.read()
|
||||
return mediafile.Image(data, type=mediafile.ImageType.front)
|
||||
|
||||
def get_art(self, item):
|
||||
# Extract the art.
|
||||
try:
|
||||
mf = mediafile.MediaFile(syspath(item.path))
|
||||
except mediafile.UnreadableFileError as exc:
|
||||
log.error(u'Could not clear art from {0}: {1}'.format(
|
||||
displayable_path(item.path), exc
|
||||
))
|
||||
continue
|
||||
del mf.art
|
||||
mf.save()
|
||||
self._log.error(u'Could not extract art from {0}: {1}',
|
||||
displayable_path(item.path), exc)
|
||||
return
|
||||
|
||||
return mf.art
|
||||
|
||||
# 'extractart' command.
|
||||
|
||||
def extract(self, outpath, item):
|
||||
if not item:
|
||||
self._log.error(u'No item matches query.')
|
||||
return
|
||||
|
||||
art = self.get_art(item)
|
||||
|
||||
if not art:
|
||||
self._log.error(u'No album art present in {0} - {1}.',
|
||||
item.artist, item.title)
|
||||
return
|
||||
|
||||
# Add an extension to the filename.
|
||||
ext = imghdr.what(None, h=art)
|
||||
if not ext:
|
||||
self._log.error(u'Unknown image type.')
|
||||
return
|
||||
outpath += '.' + ext
|
||||
|
||||
self._log.info(u'Extracting album art from: {0.artist} - {0.title} '
|
||||
u'to: {1}', item, displayable_path(outpath))
|
||||
with open(syspath(outpath), 'wb') as f:
|
||||
f.write(art)
|
||||
return outpath
|
||||
|
||||
# 'clearart' command.
|
||||
def clear(self, lib, query):
|
||||
self._log.info(u'Clearing album art from items:')
|
||||
for item in lib.items(query):
|
||||
self._log.info(u'{0} - {1}', item.artist, item.title)
|
||||
try:
|
||||
mf = mediafile.MediaFile(syspath(item.path),
|
||||
config['id3v23'].get(bool))
|
||||
except mediafile.UnreadableFileError as exc:
|
||||
self._log.error(u'Could not clear art from {0}: {1}',
|
||||
displayable_path(item.path), exc)
|
||||
continue
|
||||
del mf.art
|
||||
mf.save()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
"""Fetches album art.
|
||||
"""
|
||||
from contextlib import closing
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
|
@ -39,194 +38,167 @@ IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg']
|
|||
CONTENT_TYPES = ('image/jpeg',)
|
||||
DOWNLOAD_EXTENSION = '.jpg'
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
requests_session = requests.Session()
|
||||
requests_session.headers = {'User-Agent': 'beets'}
|
||||
|
||||
|
||||
def _fetch_image(url):
|
||||
"""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.
|
||||
"""
|
||||
log.debug(u'fetchart: downloading art: {0}'.format(url))
|
||||
try:
|
||||
with closing(requests_session.get(url, stream=True)) as resp:
|
||||
if 'Content-Type' not in resp.headers \
|
||||
or resp.headers['Content-Type'] not in CONTENT_TYPES:
|
||||
log.debug(u'fetchart: not an image')
|
||||
return
|
||||
|
||||
# Generate a temporary file with the correct extension.
|
||||
with NamedTemporaryFile(suffix=DOWNLOAD_EXTENSION, delete=False) \
|
||||
as fh:
|
||||
for chunk in resp.iter_content():
|
||||
fh.write(chunk)
|
||||
log.debug(u'fetchart: downloaded art to: {0}'.format(
|
||||
util.displayable_path(fh.name)
|
||||
))
|
||||
return fh.name
|
||||
except (IOError, requests.RequestException):
|
||||
log.debug(u'fetchart: error fetching art')
|
||||
|
||||
|
||||
# ART SOURCES ################################################################
|
||||
|
||||
# Cover Art Archive.
|
||||
class ArtSource(object):
|
||||
def __init__(self, log):
|
||||
self._log = log
|
||||
|
||||
CAA_URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg'
|
||||
CAA_GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front-500.jpg'
|
||||
def get(self, album):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
def caa_art(album):
|
||||
"""Return the Cover Art Archive and Cover Art Archive release group URLs
|
||||
using album MusicBrainz release ID and release group ID.
|
||||
"""
|
||||
if album.mb_albumid:
|
||||
yield CAA_URL.format(mbid=album.mb_albumid)
|
||||
if album.mb_releasegroupid:
|
||||
yield CAA_GROUP_URL.format(mbid=album.mb_releasegroupid)
|
||||
class CoverArtArchive(ArtSource):
|
||||
"""Cover Art Archive"""
|
||||
URL = 'http://coverartarchive.org/release/{mbid}/front-500.jpg'
|
||||
GROUP_URL = 'http://coverartarchive.org/release-group/{mbid}/front-500.jpg'
|
||||
|
||||
def get(self, album):
|
||||
"""Return the Cover Art Archive and Cover Art Archive release group URLs
|
||||
using album MusicBrainz release ID and release group ID.
|
||||
"""
|
||||
if album.mb_albumid:
|
||||
yield self.URL.format(mbid=album.mb_albumid)
|
||||
if album.mb_releasegroupid:
|
||||
yield self.GROUP_URL.format(mbid=album.mb_releasegroupid)
|
||||
|
||||
|
||||
# Art from Amazon.
|
||||
class Amazon(ArtSource):
|
||||
URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||
INDICES = (1, 2)
|
||||
|
||||
AMAZON_URL = 'http://images.amazon.com/images/P/%s.%02i.LZZZZZZZ.jpg'
|
||||
AMAZON_INDICES = (1, 2)
|
||||
def get(self, album):
|
||||
"""Generate URLs using Amazon ID (ASIN) string.
|
||||
"""
|
||||
if album.asin:
|
||||
for index in self.INDICES:
|
||||
yield self.URL % (album.asin, index)
|
||||
|
||||
|
||||
def art_for_asin(album):
|
||||
"""Generate URLs using Amazon ID (ASIN) string.
|
||||
"""
|
||||
if album.asin:
|
||||
for index in AMAZON_INDICES:
|
||||
yield AMAZON_URL % (album.asin, index)
|
||||
class AlbumArtOrg(ArtSource):
|
||||
"""AlbumArt.org scraper"""
|
||||
URL = 'http://www.albumart.org/index_detail.php'
|
||||
PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
|
||||
|
||||
|
||||
# AlbumArt.org scraper.
|
||||
|
||||
AAO_URL = 'http://www.albumart.org/index_detail.php'
|
||||
AAO_PAT = r'href\s*=\s*"([^>"]*)"[^>]*title\s*=\s*"View larger image"'
|
||||
|
||||
|
||||
def aao_art(album):
|
||||
"""Return art URL from AlbumArt.org using album ASIN.
|
||||
"""
|
||||
if not album.asin:
|
||||
return
|
||||
# Get the page from albumart.org.
|
||||
try:
|
||||
resp = requests_session.get(AAO_URL, params={'asin': album.asin})
|
||||
log.debug(u'fetchart: scraped art URL: {0}'.format(resp.url))
|
||||
except requests.RequestException:
|
||||
log.debug(u'fetchart: error scraping art page')
|
||||
return
|
||||
|
||||
# Search the page for the image URL.
|
||||
m = re.search(AAO_PAT, resp.text)
|
||||
if m:
|
||||
image_url = m.group(1)
|
||||
yield image_url
|
||||
else:
|
||||
log.debug(u'fetchart: no image found on page')
|
||||
|
||||
|
||||
# Google Images scraper.
|
||||
|
||||
GOOGLE_URL = 'https://ajax.googleapis.com/ajax/services/search/images'
|
||||
|
||||
|
||||
def google_art(album):
|
||||
"""Return art URL from google.org given an album title and
|
||||
interpreter.
|
||||
"""
|
||||
if not (album.albumartist and album.album):
|
||||
return
|
||||
search_string = (album.albumartist + ',' + album.album).encode('utf-8')
|
||||
response = requests_session.get(GOOGLE_URL, params={
|
||||
'v': '1.0',
|
||||
'q': search_string,
|
||||
'start': '0',
|
||||
})
|
||||
|
||||
# Get results using JSON.
|
||||
try:
|
||||
results = response.json()
|
||||
data = results['responseData']
|
||||
dataInfo = data['results']
|
||||
for myUrl in dataInfo:
|
||||
yield myUrl['unescapedUrl']
|
||||
except:
|
||||
log.debug(u'fetchart: error scraping art page')
|
||||
return
|
||||
|
||||
|
||||
# Art from the iTunes Store.
|
||||
|
||||
def itunes_art(album):
|
||||
"""Return art URL from iTunes Store given an album title.
|
||||
"""
|
||||
search_string = (album.albumartist + ' ' + album.album).encode('utf-8')
|
||||
try:
|
||||
# Isolate bugs in the iTunes library while searching.
|
||||
def get(self, album):
|
||||
"""Return art URL from AlbumArt.org using album ASIN.
|
||||
"""
|
||||
if not album.asin:
|
||||
return
|
||||
# Get the page from albumart.org.
|
||||
try:
|
||||
itunes_album = itunes.search_album(search_string)[0]
|
||||
except Exception as exc:
|
||||
log.debug('fetchart: iTunes search failed: {0}'.format(exc))
|
||||
resp = requests_session.get(self.URL, params={'asin': album.asin})
|
||||
self._log.debug(u'scraped art URL: {0}', resp.url)
|
||||
except requests.RequestException:
|
||||
self._log.debug(u'error scraping art page')
|
||||
return
|
||||
|
||||
if itunes_album.get_artwork()['100']:
|
||||
small_url = itunes_album.get_artwork()['100']
|
||||
big_url = small_url.replace('100x100', '1200x1200')
|
||||
yield big_url
|
||||
# Search the page for the image URL.
|
||||
m = re.search(self.PAT, resp.text)
|
||||
if m:
|
||||
image_url = m.group(1)
|
||||
yield image_url
|
||||
else:
|
||||
log.debug(u'fetchart: album has no artwork in iTunes Store')
|
||||
except IndexError:
|
||||
log.debug(u'fetchart: album not found in iTunes Store')
|
||||
self._log.debug(u'no image found on page')
|
||||
|
||||
|
||||
# Art from the filesystem.
|
||||
class GoogleImages(ArtSource):
|
||||
URL = 'https://ajax.googleapis.com/ajax/services/search/images'
|
||||
|
||||
def get(self, album):
|
||||
"""Return art URL from google.org given an album title and
|
||||
interpreter.
|
||||
"""
|
||||
if not (album.albumartist and album.album):
|
||||
return
|
||||
search_string = (album.albumartist + ',' + album.album).encode('utf-8')
|
||||
response = requests_session.get(self.URL, params={
|
||||
'v': '1.0',
|
||||
'q': search_string,
|
||||
'start': '0',
|
||||
})
|
||||
|
||||
# Get results using JSON.
|
||||
try:
|
||||
results = response.json()
|
||||
data = results['responseData']
|
||||
dataInfo = data['results']
|
||||
for myUrl in dataInfo:
|
||||
yield myUrl['unescapedUrl']
|
||||
except:
|
||||
self._log.debug(u'error scraping art page')
|
||||
return
|
||||
|
||||
|
||||
def filename_priority(filename, cover_names):
|
||||
"""Sort order for image names.
|
||||
class ITunesStore(ArtSource):
|
||||
# Art from the iTunes Store.
|
||||
def get(self, album):
|
||||
"""Return art URL from iTunes Store given an album title.
|
||||
"""
|
||||
search_string = (album.albumartist + ' ' + album.album).encode('utf-8')
|
||||
try:
|
||||
# Isolate bugs in the iTunes library while searching.
|
||||
try:
|
||||
itunes_album = itunes.search_album(search_string)[0]
|
||||
except Exception as exc:
|
||||
self._log.debug('iTunes search failed: {0}', exc)
|
||||
return
|
||||
|
||||
Return indexes of cover names found in the image filename. This
|
||||
means that images with lower-numbered and more keywords will have higher
|
||||
priority.
|
||||
"""
|
||||
return [idx for (idx, x) in enumerate(cover_names) if x in filename]
|
||||
if itunes_album.get_artwork()['100']:
|
||||
small_url = itunes_album.get_artwork()['100']
|
||||
big_url = small_url.replace('100x100', '1200x1200')
|
||||
yield big_url
|
||||
else:
|
||||
self._log.debug(u'album has no artwork in iTunes Store')
|
||||
except IndexError:
|
||||
self._log.debug(u'album not found in iTunes Store')
|
||||
|
||||
|
||||
def art_in_path(path, cover_names, cautious):
|
||||
"""Look for album art files in a specified directory.
|
||||
"""
|
||||
if not os.path.isdir(path):
|
||||
return
|
||||
class FileSystem(ArtSource):
|
||||
"""Art from the filesystem"""
|
||||
@staticmethod
|
||||
def filename_priority(filename, cover_names):
|
||||
"""Sort order for image names.
|
||||
|
||||
# Find all files that look like images in the directory.
|
||||
images = []
|
||||
for fn in os.listdir(path):
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
if fn.lower().endswith('.' + ext):
|
||||
images.append(fn)
|
||||
Return indexes of cover names found in the image filename. This
|
||||
means that images with lower-numbered and more keywords will have
|
||||
higher priority.
|
||||
"""
|
||||
return [idx for (idx, x) in enumerate(cover_names) if x in filename]
|
||||
|
||||
# Look for "preferred" filenames.
|
||||
images = sorted(images, key=lambda x: filename_priority(x, cover_names))
|
||||
cover_pat = r"(\b|_)({0})(\b|_)".format('|'.join(cover_names))
|
||||
for fn in images:
|
||||
if re.search(cover_pat, os.path.splitext(fn)[0], re.I):
|
||||
log.debug(u'fetchart: using well-named art file {0}'.format(
|
||||
util.displayable_path(fn)
|
||||
))
|
||||
return os.path.join(path, fn)
|
||||
def get(self, path, cover_names, cautious):
|
||||
"""Look for album art files in a specified directory.
|
||||
"""
|
||||
if not os.path.isdir(path):
|
||||
return
|
||||
|
||||
# Fall back to any image in the folder.
|
||||
if images and not cautious:
|
||||
log.debug(u'fetchart: using fallback art file {0}'.format(
|
||||
util.displayable_path(images[0])
|
||||
))
|
||||
return os.path.join(path, images[0])
|
||||
# Find all files that look like images in the directory.
|
||||
images = []
|
||||
for fn in os.listdir(path):
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
if fn.lower().endswith('.' + ext) and \
|
||||
os.path.isfile(os.path.join(path, fn)):
|
||||
images.append(fn)
|
||||
|
||||
# Look for "preferred" filenames.
|
||||
images = sorted(images,
|
||||
key=lambda x: self.filename_priority(x, cover_names))
|
||||
cover_pat = r"(\b|_)({0})(\b|_)".format('|'.join(cover_names))
|
||||
for fn in images:
|
||||
if re.search(cover_pat, os.path.splitext(fn)[0], re.I):
|
||||
self._log.debug(u'using well-named art file {0}',
|
||||
util.displayable_path(fn))
|
||||
return os.path.join(path, fn)
|
||||
|
||||
# Fall back to any image in the folder.
|
||||
if images and not cautious:
|
||||
self._log.debug(u'using fallback art file {0}',
|
||||
util.displayable_path(images[0]))
|
||||
return os.path.join(path, images[0])
|
||||
|
||||
|
||||
# Try each source in turn.
|
||||
|
|
@ -234,91 +206,16 @@ def art_in_path(path, cover_names, cautious):
|
|||
SOURCES_ALL = [u'coverart', u'itunes', u'amazon', u'albumart', u'google']
|
||||
|
||||
ART_FUNCS = {
|
||||
u'coverart': caa_art,
|
||||
u'itunes': itunes_art,
|
||||
u'albumart': aao_art,
|
||||
u'amazon': art_for_asin,
|
||||
u'google': google_art,
|
||||
u'coverart': CoverArtArchive,
|
||||
u'itunes': ITunesStore,
|
||||
u'albumart': AlbumArtOrg,
|
||||
u'amazon': Amazon,
|
||||
u'google': GoogleImages,
|
||||
}
|
||||
|
||||
|
||||
def _source_urls(album, sources=SOURCES_ALL):
|
||||
"""Generate possible source URLs for an album's art. The URLs are
|
||||
not guaranteed to work so they each need to be attempted in turn.
|
||||
This allows the main `art_for_album` function to abort iteration
|
||||
through this sequence early to avoid the cost of scraping when not
|
||||
necessary.
|
||||
"""
|
||||
for s in sources:
|
||||
urls = ART_FUNCS[s](album)
|
||||
for url in urls:
|
||||
yield url
|
||||
|
||||
|
||||
def art_for_album(album, paths, maxwidth=None, local_only=False):
|
||||
"""Given an Album object, returns a path to downloaded art for the
|
||||
album (or None if no art is found). If `maxwidth`, then images are
|
||||
resized to this maximum pixel size. If `local_only`, then only local
|
||||
image files from the filesystem are returned; no network requests
|
||||
are made.
|
||||
"""
|
||||
out = None
|
||||
|
||||
# Local art.
|
||||
cover_names = config['fetchart']['cover_names'].as_str_seq()
|
||||
cover_names = map(util.bytestring_path, cover_names)
|
||||
cautious = config['fetchart']['cautious'].get(bool)
|
||||
if paths:
|
||||
for path in paths:
|
||||
out = art_in_path(path, cover_names, cautious)
|
||||
if out:
|
||||
break
|
||||
|
||||
# Web art sources.
|
||||
remote_priority = config['fetchart']['remote_priority'].get(bool)
|
||||
if not local_only and (remote_priority or not out):
|
||||
for url in _source_urls(album,
|
||||
config['fetchart']['sources'].as_str_seq()):
|
||||
if maxwidth:
|
||||
url = ArtResizer.shared.proxy_url(maxwidth, url)
|
||||
candidate = _fetch_image(url)
|
||||
if candidate:
|
||||
out = candidate
|
||||
break
|
||||
|
||||
if maxwidth and out:
|
||||
out = ArtResizer.shared.resize(maxwidth, out)
|
||||
return out
|
||||
|
||||
|
||||
# PLUGIN LOGIC ###############################################################
|
||||
|
||||
|
||||
def batch_fetch_art(lib, albums, force, maxwidth=None):
|
||||
"""Fetch album art for each of the albums. This implements the manual
|
||||
fetchart CLI command.
|
||||
"""
|
||||
for album in albums:
|
||||
if album.artpath and not force:
|
||||
message = 'has album art'
|
||||
else:
|
||||
# In ordinary invocations, look for images on the
|
||||
# filesystem. When forcing, however, always go to the Web
|
||||
# sources.
|
||||
local_paths = None if force else [album.path]
|
||||
|
||||
path = art_for_album(album, local_paths, maxwidth)
|
||||
if path:
|
||||
album.set_art(path, False)
|
||||
album.store()
|
||||
message = ui.colorize('green', 'found album art')
|
||||
else:
|
||||
message = ui.colorize('red', 'no art found')
|
||||
|
||||
log.info(u'{0} - {1}: {2}'.format(album.albumartist, album.album,
|
||||
message))
|
||||
|
||||
|
||||
class FetchArtPlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(FetchArtPlugin, self).__init__()
|
||||
|
|
@ -346,8 +243,10 @@ class FetchArtPlugin(plugins.BeetsPlugin):
|
|||
available_sources = list(SOURCES_ALL)
|
||||
if not HAVE_ITUNES and u'itunes' in available_sources:
|
||||
available_sources.remove(u'itunes')
|
||||
self.config['sources'] = plugins.sanitize_choices(
|
||||
sources_name = plugins.sanitize_choices(
|
||||
self.config['sources'].as_str_seq(), available_sources)
|
||||
self.sources = [ART_FUNCS[s](self._log) for s in sources_name]
|
||||
self.fs_source = FileSystem(self._log)
|
||||
|
||||
# Asynchronous; after music is added to the library.
|
||||
def fetch_art(self, session, task):
|
||||
|
|
@ -363,7 +262,7 @@ class FetchArtPlugin(plugins.BeetsPlugin):
|
|||
# For any other choices (e.g., TRACKS), do nothing.
|
||||
return
|
||||
|
||||
path = art_for_album(task.album, task.paths, self.maxwidth, local)
|
||||
path = self.art_for_album(task.album, task.paths, local)
|
||||
|
||||
if path:
|
||||
self.art_paths[task] = path
|
||||
|
|
@ -390,7 +289,102 @@ class FetchArtPlugin(plugins.BeetsPlugin):
|
|||
help='re-download art when already present')
|
||||
|
||||
def func(lib, opts, args):
|
||||
batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force,
|
||||
self.maxwidth)
|
||||
self.batch_fetch_art(lib, lib.albums(ui.decargs(args)), opts.force)
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
||||
# Utilities converted from functions to methods on logging overhaul
|
||||
|
||||
def _fetch_image(self, url):
|
||||
"""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.
|
||||
"""
|
||||
self._log.debug(u'downloading art: {0}', url)
|
||||
try:
|
||||
with closing(requests_session.get(url, stream=True)) as resp:
|
||||
if 'Content-Type' not in resp.headers \
|
||||
or resp.headers['Content-Type'] not in CONTENT_TYPES:
|
||||
self._log.debug(u'not an image')
|
||||
return
|
||||
|
||||
# Generate a temporary file with the correct extension.
|
||||
with NamedTemporaryFile(suffix=DOWNLOAD_EXTENSION,
|
||||
delete=False) as fh:
|
||||
for chunk in resp.iter_content():
|
||||
fh.write(chunk)
|
||||
self._log.debug(u'downloaded art to: {0}',
|
||||
util.displayable_path(fh.name))
|
||||
return fh.name
|
||||
except (IOError, requests.RequestException):
|
||||
self._log.debug(u'error fetching art')
|
||||
|
||||
def art_for_album(self, album, paths, local_only=False):
|
||||
"""Given an Album object, returns a path to downloaded art for the
|
||||
album (or None if no art is found). If `maxwidth`, then images are
|
||||
resized to this maximum pixel size. If `local_only`, then only local
|
||||
image files from the filesystem are returned; no network requests
|
||||
are made.
|
||||
"""
|
||||
out = None
|
||||
|
||||
# Local art.
|
||||
cover_names = self.config['cover_names'].as_str_seq()
|
||||
cover_names = map(util.bytestring_path, cover_names)
|
||||
cautious = self.config['cautious'].get(bool)
|
||||
if paths:
|
||||
for path in paths:
|
||||
# FIXME
|
||||
out = self.fs_source.get(path, cover_names, cautious)
|
||||
if out:
|
||||
break
|
||||
|
||||
# Web art sources.
|
||||
remote_priority = self.config['remote_priority'].get(bool)
|
||||
if not local_only and (remote_priority or not out):
|
||||
for url in self._source_urls(album):
|
||||
if self.maxwidth:
|
||||
url = ArtResizer.shared.proxy_url(self.maxwidth, url)
|
||||
candidate = self._fetch_image(url)
|
||||
if candidate:
|
||||
out = candidate
|
||||
break
|
||||
|
||||
if self.maxwidth and out:
|
||||
out = ArtResizer.shared.resize(self.maxwidth, out)
|
||||
return out
|
||||
|
||||
def batch_fetch_art(self, lib, albums, force):
|
||||
"""Fetch album art for each of the albums. This implements the manual
|
||||
fetchart CLI command.
|
||||
"""
|
||||
for album in albums:
|
||||
if album.artpath and not force:
|
||||
message = 'has album art'
|
||||
else:
|
||||
# In ordinary invocations, look for images on the
|
||||
# filesystem. When forcing, however, always go to the Web
|
||||
# sources.
|
||||
local_paths = None if force else [album.path]
|
||||
|
||||
path = self.art_for_album(album, local_paths)
|
||||
if path:
|
||||
album.set_art(path, False)
|
||||
album.store()
|
||||
message = ui.colorize('green', 'found album art')
|
||||
else:
|
||||
message = ui.colorize('red', 'no art found')
|
||||
|
||||
self._log.info(u'{0.albumartist} - {0.album}: {1}', album, message)
|
||||
|
||||
def _source_urls(self, album):
|
||||
"""Generate possible source URLs for an album's art. The URLs are
|
||||
not guaranteed to work so they each need to be attempted in turn.
|
||||
This allows the main `art_for_album` function to abort iteration
|
||||
through this sequence early to avoid the cost of scraping when not
|
||||
necessary.
|
||||
"""
|
||||
for source in self.sources:
|
||||
urls = source.get(album)
|
||||
for url in urls:
|
||||
yield url
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Matt Lichtenberg.
|
||||
# Copyright 2015, Matt Lichtenberg.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -20,24 +20,6 @@ from beets.ui import Subcommand
|
|||
from beets.ui import decargs
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
log = logging.getLogger('beets.freedesktop')
|
||||
|
||||
|
||||
def process_query(lib, opts, args):
|
||||
for album in lib.albums(decargs(args)):
|
||||
process_album(album)
|
||||
|
||||
|
||||
def process_album(album):
|
||||
albumpath = album.item_dir()
|
||||
if album.artpath:
|
||||
fullartpath = album.artpath
|
||||
artfile = os.path.split(fullartpath)[1]
|
||||
create_file(albumpath, artfile)
|
||||
else:
|
||||
log.debug(u'freedesktop: album has no art')
|
||||
|
||||
|
||||
def create_file(albumpath, artfile):
|
||||
|
|
@ -61,11 +43,24 @@ class FreedesktopPlugin(BeetsPlugin):
|
|||
def commands(self):
|
||||
freedesktop_command = Subcommand("freedesktop",
|
||||
help="Create .directory files")
|
||||
freedesktop_command.func = process_query
|
||||
freedesktop_command.func = self.process_query
|
||||
return [freedesktop_command]
|
||||
|
||||
def imported(self, lib, album):
|
||||
automatic = self.config['auto'].get(bool)
|
||||
if not automatic:
|
||||
return
|
||||
process_album(album)
|
||||
self.process_album(album)
|
||||
|
||||
def process_query(self, lib, opts, args):
|
||||
for album in lib.albums(decargs(args)):
|
||||
self.process_album(album)
|
||||
|
||||
def process_album(self, album):
|
||||
albumpath = album.item_dir()
|
||||
if album.artpath:
|
||||
fullartpath = album.artpath
|
||||
artfile = os.path.split(fullartpath)[1]
|
||||
create_file(albumpath, artfile)
|
||||
else:
|
||||
self._log.debug(u'album has no art')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Jan-Erik Dahlin
|
||||
# Copyright 2015, Jan-Erik Dahlin
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Verrus, <github.com/Verrus/beets-plugin-featInTitle>
|
||||
# Copyright 2015, Verrus, <github.com/Verrus/beets-plugin-featInTitle>
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,14 +14,12 @@
|
|||
|
||||
"""Moves "featured" artists to the title from the artist field.
|
||||
"""
|
||||
import re
|
||||
|
||||
from beets import plugins
|
||||
from beets import ui
|
||||
from beets.util import displayable_path
|
||||
from beets import config
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def split_on_feat(artist):
|
||||
|
|
@ -45,70 +43,6 @@ def contains_feat(title):
|
|||
return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE))
|
||||
|
||||
|
||||
def update_metadata(item, feat_part, drop_feat, loglevel=logging.DEBUG):
|
||||
"""Choose how to add new artists to the title and set the new
|
||||
metadata. Also, print out messages about any changes that are made.
|
||||
If `drop_feat` is set, then do not add the artist to the title; just
|
||||
remove it from the artist field.
|
||||
"""
|
||||
# In all cases, update the artist fields.
|
||||
log.log(loglevel, u'artist: {0} -> {1}'.format(
|
||||
item.artist, item.albumartist))
|
||||
item.artist = item.albumartist
|
||||
if item.artist_sort:
|
||||
# Just strip the featured artist from the sort name.
|
||||
item.artist_sort, _ = split_on_feat(item.artist_sort)
|
||||
|
||||
# Only update the title if it does not already contain a featured
|
||||
# artist and if we do not drop featuring information.
|
||||
if not drop_feat and not contains_feat(item.title):
|
||||
new_title = u"{0} feat. {1}".format(item.title, feat_part)
|
||||
log.log(loglevel, u'title: {0} -> {1}'.format(item.title, new_title))
|
||||
item.title = new_title
|
||||
|
||||
|
||||
def ft_in_title(item, drop_feat, loglevel=logging.DEBUG):
|
||||
"""Look for featured artists in the item's artist fields and move
|
||||
them to the title.
|
||||
"""
|
||||
artist = item.artist.strip()
|
||||
albumartist = item.albumartist.strip()
|
||||
|
||||
# Check whether there is a featured artist on this track and the
|
||||
# artist field does not exactly match the album artist field. In
|
||||
# that case, we attempt to move the featured artist to the title.
|
||||
_, featured = split_on_feat(artist)
|
||||
if featured and albumartist != artist and albumartist:
|
||||
log.log(loglevel, displayable_path(item.path))
|
||||
feat_part = None
|
||||
|
||||
# Look for the album artist in the artist field. If it's not
|
||||
# present, give up.
|
||||
albumartist_split = artist.split(albumartist, 1)
|
||||
if len(albumartist_split) <= 1:
|
||||
log.log(loglevel, 'album artist not present in artist')
|
||||
|
||||
# If the last element of the split (the right-hand side of the
|
||||
# album artist) is nonempty, then it probably contains the
|
||||
# featured artist.
|
||||
elif albumartist_split[-1] != '':
|
||||
# Extract the featured artist from the right-hand side.
|
||||
_, feat_part = split_on_feat(albumartist_split[-1])
|
||||
|
||||
# Otherwise, if there's nothing on the right-hand side, look for a
|
||||
# featuring artist on the left-hand side.
|
||||
else:
|
||||
lhs, rhs = split_on_feat(albumartist_split[0])
|
||||
if rhs:
|
||||
feat_part = lhs
|
||||
|
||||
# If we have a featuring artist, move it to the title.
|
||||
if feat_part:
|
||||
update_metadata(item, feat_part, drop_feat, loglevel)
|
||||
else:
|
||||
log.log(loglevel, u'no featuring artists found')
|
||||
|
||||
|
||||
class FtInTitlePlugin(plugins.BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(FtInTitlePlugin, self).__init__()
|
||||
|
|
@ -138,7 +72,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
write = config['import']['write'].get(bool)
|
||||
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
ft_in_title(item, drop_feat, logging.INFO)
|
||||
self.ft_in_title(item, drop_feat)
|
||||
item.store()
|
||||
if write:
|
||||
item.try_write()
|
||||
|
|
@ -152,5 +86,66 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
|
|||
drop_feat = self.config['drop'].get(bool)
|
||||
|
||||
for item in task.imported_items():
|
||||
ft_in_title(item, drop_feat, logging.DEBUG)
|
||||
self.ft_in_title(item, drop_feat)
|
||||
item.store()
|
||||
|
||||
def update_metadata(self, item, feat_part, drop_feat):
|
||||
"""Choose how to add new artists to the title and set the new
|
||||
metadata. Also, print out messages about any changes that are made.
|
||||
If `drop_feat` is set, then do not add the artist to the title; just
|
||||
remove it from the artist field.
|
||||
"""
|
||||
# In all cases, update the artist fields.
|
||||
self._log.info(u'artist: {0} -> {1}', item.artist, item.albumartist)
|
||||
item.artist = item.albumartist
|
||||
if item.artist_sort:
|
||||
# Just strip the featured artist from the sort name.
|
||||
item.artist_sort, _ = split_on_feat(item.artist_sort)
|
||||
|
||||
# Only update the title if it does not already contain a featured
|
||||
# artist and if we do not drop featuring information.
|
||||
if not drop_feat and not contains_feat(item.title):
|
||||
new_title = u"{0} feat. {1}".format(item.title, feat_part)
|
||||
self._log.info(u'title: {0} -> {1}', item.title, new_title)
|
||||
item.title = new_title
|
||||
|
||||
def ft_in_title(self, item, drop_feat):
|
||||
"""Look for featured artists in the item's artist fields and move
|
||||
them to the title.
|
||||
"""
|
||||
artist = item.artist.strip()
|
||||
albumartist = item.albumartist.strip()
|
||||
|
||||
# Check whether there is a featured artist on this track and the
|
||||
# artist field does not exactly match the album artist field. In
|
||||
# that case, we attempt to move the featured artist to the title.
|
||||
_, featured = split_on_feat(artist)
|
||||
if featured and albumartist != artist and albumartist:
|
||||
self._log.info(displayable_path(item.path))
|
||||
feat_part = None
|
||||
|
||||
# Look for the album artist in the artist field. If it's not
|
||||
# present, give up.
|
||||
albumartist_split = artist.split(albumartist, 1)
|
||||
if len(albumartist_split) <= 1:
|
||||
self._log.info('album artist not present in artist')
|
||||
|
||||
# If the last element of the split (the right-hand side of the
|
||||
# album artist) is nonempty, then it probably contains the
|
||||
# featured artist.
|
||||
elif albumartist_split[-1] != '':
|
||||
# Extract the featured artist from the right-hand side.
|
||||
_, feat_part = split_on_feat(albumartist_split[-1])
|
||||
|
||||
# Otherwise, if there's nothing on the right-hand side, look for a
|
||||
# featuring artist on the left-hand side.
|
||||
else:
|
||||
lhs, rhs = split_on_feat(albumartist_split[0])
|
||||
if rhs:
|
||||
feat_part = lhs
|
||||
|
||||
# If we have a featuring artist, move it to the title.
|
||||
if feat_part:
|
||||
self.update_metadata(item, feat_part, drop_feat)
|
||||
else:
|
||||
self._log.info(u'no featuring artists found')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Philippe Mongeau.
|
||||
# Copyright 2015, Philippe Mongeau.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.dbcore.query import StringFieldQuery
|
||||
import beets
|
||||
import difflib
|
||||
|
||||
|
||||
|
|
@ -28,7 +27,7 @@ class FuzzyQuery(StringFieldQuery):
|
|||
if pattern.islower():
|
||||
val = val.lower()
|
||||
queryMatcher = difflib.SequenceMatcher(None, pattern, val)
|
||||
threshold = beets.config['fuzzy']['threshold'].as_number()
|
||||
threshold = self.config['threshold'].as_number()
|
||||
return queryMatcher.quick_ratio() >= threshold
|
||||
|
||||
|
||||
|
|
@ -41,5 +40,5 @@ class FuzzyPlugin(BeetsPlugin):
|
|||
})
|
||||
|
||||
def queries(self):
|
||||
prefix = beets.config['fuzzy']['prefix'].get(basestring)
|
||||
prefix = self.config['prefix'].get(basestring)
|
||||
return {prefix: FuzzyQuery}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
# Copyright 2015, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
"""Warns you about things you hate (or even blocks import)."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from beets import config
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
|
@ -39,8 +38,6 @@ def summary(task):
|
|||
|
||||
|
||||
class IHatePlugin(BeetsPlugin):
|
||||
_log = logging.getLogger('beets')
|
||||
|
||||
def __init__(self):
|
||||
super(IHatePlugin, self).__init__()
|
||||
self.register_listener('import_task_choice',
|
||||
|
|
@ -85,19 +82,17 @@ class IHatePlugin(BeetsPlugin):
|
|||
|
||||
if task.choice_flag == action.APPLY:
|
||||
if skip_queries or warn_queries:
|
||||
self._log.debug(u'[ihate] processing your hate')
|
||||
self._log.debug(u'processing your hate')
|
||||
if self.do_i_hate_this(task, skip_queries):
|
||||
task.choice_flag = action.SKIP
|
||||
self._log.info(u'[ihate] skipped: {0}'
|
||||
.format(summary(task)))
|
||||
self._log.info(u'skipped: {0}', summary(task))
|
||||
return
|
||||
if self.do_i_hate_this(task, warn_queries):
|
||||
self._log.info(u'[ihate] you maybe hate this: {0}'
|
||||
.format(summary(task)))
|
||||
self._log.info(u'you may hate this: {0}', summary(task))
|
||||
else:
|
||||
self._log.debug(u'[ihate] nothing to do')
|
||||
self._log.debug(u'nothing to do')
|
||||
else:
|
||||
self._log.debug(u'[ihate] user made a decision, nothing to do')
|
||||
self._log.debug(u'user made a decision, nothing to do')
|
||||
|
||||
def import_task_created_event(self, session, task):
|
||||
if task.items and len(task.items) > 0:
|
||||
|
|
|
|||
|
|
@ -6,15 +6,11 @@ Reimported albums and items are skipped.
|
|||
|
||||
from __future__ import unicode_literals, absolute_import, print_function
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from beets import config
|
||||
from beets import util
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class ImportAddedPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
|
|
@ -23,118 +19,104 @@ class ImportAddedPlugin(BeetsPlugin):
|
|||
'preserve_mtimes': False,
|
||||
})
|
||||
|
||||
# item.id for new items that were reimported
|
||||
self.reimported_item_ids = None
|
||||
# album.path for old albums that were replaced by a reimported album
|
||||
self.replaced_album_paths = None
|
||||
# item path in the library to the mtime of the source file
|
||||
self.item_mtime = dict()
|
||||
|
||||
@ImportAddedPlugin.listen('import_task_start')
|
||||
def check_config(task, session):
|
||||
config['importadded']['preserve_mtimes'].get(bool)
|
||||
register = self.register_listener
|
||||
register('import_task_start', self.check_config)
|
||||
register('import_task_start', self.record_if_inplace)
|
||||
register('import_task_files', self.record_reimported)
|
||||
register('before_item_moved', self.record_import_mtime)
|
||||
register('item_copied', self.record_import_mtime)
|
||||
register('item_linked', self.record_import_mtime)
|
||||
register('album_imported', self.update_album_times)
|
||||
register('item_imported', self.update_item_times)
|
||||
|
||||
# item.id for new items that were reimported
|
||||
reimported_item_ids = None
|
||||
def check_config(self, task, session):
|
||||
self.config['preserve_mtimes'].get(bool)
|
||||
|
||||
# album.path for old albums that were replaced by a new reimported album
|
||||
replaced_album_paths = None
|
||||
def reimported_item(self, item):
|
||||
return item.id in self.reimported_item_ids
|
||||
|
||||
def reimported_album(self, album):
|
||||
return album.path in self.replaced_album_paths
|
||||
|
||||
def reimported_item(item):
|
||||
return item.id in reimported_item_ids
|
||||
def record_if_inplace(self, task, session):
|
||||
if not (session.config['copy'] or session.config['move'] or
|
||||
session.config['link']):
|
||||
self._log.debug(u"In place import detected, recording mtimes from "
|
||||
u"source paths")
|
||||
for item in task.items:
|
||||
self.record_import_mtime(item, item.path, item.path)
|
||||
|
||||
def record_reimported(self, task, session):
|
||||
self.reimported_item_ids = set(item.id for item, replaced_items
|
||||
in task.replaced_items.iteritems()
|
||||
if replaced_items)
|
||||
self.replaced_album_paths = set(task.replaced_albums.keys())
|
||||
|
||||
def reimported_album(album):
|
||||
return album.path in replaced_album_paths
|
||||
def write_file_mtime(self, path, mtime):
|
||||
"""Write the given mtime to the destination path.
|
||||
"""
|
||||
stat = os.stat(util.syspath(path))
|
||||
os.utime(util.syspath(path), (stat.st_atime, mtime))
|
||||
|
||||
def write_item_mtime(self, item, mtime):
|
||||
"""Write the given mtime to an item's `mtime` field and to the mtime
|
||||
of the item's file.
|
||||
"""
|
||||
if mtime is None:
|
||||
self._log.warn(u"No mtime to be preserved for item '{0}'",
|
||||
util.displayable_path(item.path))
|
||||
return
|
||||
|
||||
@ImportAddedPlugin.listen('import_task_start')
|
||||
def record_if_inplace(task, session):
|
||||
if not (session.config['copy'] or session.config['move'] or
|
||||
session.config['link']):
|
||||
log.debug(u"In place import detected, recording mtimes from source"
|
||||
u" paths")
|
||||
for item in task.items:
|
||||
record_import_mtime(item, item.path, item.path)
|
||||
# The file's mtime on disk must be in sync with the item's mtime
|
||||
self.write_file_mtime(util.syspath(item.path), mtime)
|
||||
item.mtime = mtime
|
||||
|
||||
def record_import_mtime(self, item, source, destination):
|
||||
"""Record the file mtime of an item's path before its import.
|
||||
"""
|
||||
mtime = os.stat(util.syspath(source)).st_mtime
|
||||
self.item_mtime[destination] = mtime
|
||||
self._log.debug(u"Recorded mtime {0} for item '{1}' imported from "
|
||||
u"'{2}'", mtime, util.displayable_path(destination),
|
||||
util.displayable_path(source))
|
||||
|
||||
@ImportAddedPlugin.listen('import_task_files')
|
||||
def record_reimported(task, session):
|
||||
global reimported_item_ids, replaced_album_paths
|
||||
reimported_item_ids = set([item.id for item, replaced_items
|
||||
in task.replaced_items.iteritems()
|
||||
if replaced_items])
|
||||
replaced_album_paths = set(task.replaced_albums.keys())
|
||||
def update_album_times(self, lib, album):
|
||||
if self.reimported_album(album):
|
||||
self._log.debug(u"Album '{0}' is reimported, skipping import of "
|
||||
u"added dates for the album and its items.",
|
||||
util.displayable_path(album.path))
|
||||
return
|
||||
|
||||
album_mtimes = []
|
||||
for item in album.items():
|
||||
mtime = self.item_mtime.pop(item.path, None)
|
||||
if mtime:
|
||||
album_mtimes.append(mtime)
|
||||
if self.config['preserve_mtimes'].get(bool):
|
||||
self.write_item_mtime(item, mtime)
|
||||
item.store()
|
||||
album.added = min(album_mtimes)
|
||||
self._log.debug(u"Import of album '{0}', selected album.added={1} "
|
||||
u"from item file mtimes.", album.album, album.added)
|
||||
album.store()
|
||||
|
||||
def write_file_mtime(path, mtime):
|
||||
"""Write the given mtime to the destination path.
|
||||
"""
|
||||
stat = os.stat(util.syspath(path))
|
||||
os.utime(util.syspath(path),
|
||||
(stat.st_atime, mtime))
|
||||
|
||||
|
||||
def write_item_mtime(item, mtime):
|
||||
"""Write the given mtime to an item's `mtime` field and to the mtime of the
|
||||
item's file.
|
||||
"""
|
||||
if mtime is None:
|
||||
log.warn(u"No mtime to be preserved for item '{0}'"
|
||||
.format(util.displayable_path(item.path)))
|
||||
return
|
||||
|
||||
# The file's mtime on disk must be in sync with the item's mtime
|
||||
write_file_mtime(util.syspath(item.path), mtime)
|
||||
item.mtime = mtime
|
||||
|
||||
|
||||
# key: item path in the library
|
||||
# value: the file mtime of the file the item was imported from
|
||||
item_mtime = dict()
|
||||
|
||||
|
||||
@ImportAddedPlugin.listen('before_item_moved')
|
||||
@ImportAddedPlugin.listen('item_copied')
|
||||
@ImportAddedPlugin.listen('item_linked')
|
||||
def record_import_mtime(item, source, destination):
|
||||
"""Record the file mtime of an item's path before its import.
|
||||
"""
|
||||
mtime = os.stat(util.syspath(source)).st_mtime
|
||||
item_mtime[destination] = mtime
|
||||
log.debug(u"Recorded mtime {0} for item '{1}' imported from '{2}'".format(
|
||||
mtime, util.displayable_path(destination),
|
||||
util.displayable_path(source)))
|
||||
|
||||
|
||||
@ImportAddedPlugin.listen('album_imported')
|
||||
def update_album_times(lib, album):
|
||||
if reimported_album(album):
|
||||
log.debug(u"Album '{0}' is reimported, skipping import of added dates"
|
||||
u" for the album and its items."
|
||||
.format(util.displayable_path(album.path)))
|
||||
return
|
||||
|
||||
album_mtimes = []
|
||||
for item in album.items():
|
||||
mtime = item_mtime.pop(item.path, None)
|
||||
def update_item_times(self, lib, item):
|
||||
if self.reimported_item(item):
|
||||
self._log.debug(u"Item '{0}' is reimported, skipping import of "
|
||||
u"added date.", util.displayable_path(item.path))
|
||||
return
|
||||
mtime = self.item_mtime.pop(item.path, None)
|
||||
if mtime:
|
||||
album_mtimes.append(mtime)
|
||||
if config['importadded']['preserve_mtimes'].get(bool):
|
||||
write_item_mtime(item, mtime)
|
||||
item.store()
|
||||
album.added = min(album_mtimes)
|
||||
log.debug(u"Import of album '{0}', selected album.added={1} from item"
|
||||
u" file mtimes.".format(album.album, album.added))
|
||||
album.store()
|
||||
|
||||
|
||||
@ImportAddedPlugin.listen('item_imported')
|
||||
def update_item_times(lib, item):
|
||||
if reimported_item(item):
|
||||
log.debug(u"Item '{0}' is reimported, skipping import of added "
|
||||
u"date.".format(util.displayable_path(item.path)))
|
||||
return
|
||||
mtime = item_mtime.pop(item.path, None)
|
||||
if mtime:
|
||||
item.added = mtime
|
||||
if config['importadded']['preserve_mtimes'].get(bool):
|
||||
write_item_mtime(item, mtime)
|
||||
log.debug(u"Import of item '{0}', selected item.added={1}"
|
||||
.format(util.displayable_path(item.path), item.added))
|
||||
item.store()
|
||||
item.added = mtime
|
||||
if self.config['preserve_mtimes'].get(bool):
|
||||
self.write_item_mtime(item, mtime)
|
||||
self._log.debug(u"Import of item '{0}', selected item.added={1}",
|
||||
util.displayable_path(item.path), item.added)
|
||||
item.store()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Fabrice Laporte.
|
||||
# Copyright 2015, Fabrice Laporte.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -19,40 +19,12 @@ one wants to manually add music to a player by its path.
|
|||
import datetime
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import normpath, syspath, bytestring_path
|
||||
from beets import config
|
||||
|
||||
M3U_DEFAULT_NAME = 'imported.m3u'
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class ImportFeedsPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(ImportFeedsPlugin, self).__init__()
|
||||
|
||||
self.config.add({
|
||||
'formats': [],
|
||||
'm3u_name': u'imported.m3u',
|
||||
'dir': None,
|
||||
'relative_to': None,
|
||||
'absolute_path': False,
|
||||
})
|
||||
|
||||
feeds_dir = self.config['dir'].get()
|
||||
if feeds_dir:
|
||||
feeds_dir = os.path.expanduser(bytestring_path(feeds_dir))
|
||||
self.config['dir'] = feeds_dir
|
||||
if not os.path.exists(syspath(feeds_dir)):
|
||||
os.makedirs(syspath(feeds_dir))
|
||||
|
||||
relative_to = self.config['relative_to'].get()
|
||||
if relative_to:
|
||||
self.config['relative_to'] = normpath(relative_to)
|
||||
else:
|
||||
self.config['relative_to'] = feeds_dir
|
||||
|
||||
|
||||
def _get_feeds_dir(lib):
|
||||
|
|
@ -90,62 +62,85 @@ def _write_m3u(m3u_path, items_paths):
|
|||
f.write(path + '\n')
|
||||
|
||||
|
||||
def _record_items(lib, basename, items):
|
||||
"""Records relative paths to the given items for each feed format
|
||||
"""
|
||||
feedsdir = bytestring_path(config['importfeeds']['dir'].as_filename())
|
||||
formats = config['importfeeds']['formats'].as_str_seq()
|
||||
relative_to = config['importfeeds']['relative_to'].get() \
|
||||
or config['importfeeds']['dir'].as_filename()
|
||||
relative_to = bytestring_path(relative_to)
|
||||
class ImportFeedsPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(ImportFeedsPlugin, self).__init__()
|
||||
|
||||
paths = []
|
||||
for item in items:
|
||||
if config['importfeeds']['absolute_path']:
|
||||
paths.append(item.path)
|
||||
self.config.add({
|
||||
'formats': [],
|
||||
'm3u_name': u'imported.m3u',
|
||||
'dir': None,
|
||||
'relative_to': None,
|
||||
'absolute_path': False,
|
||||
})
|
||||
|
||||
feeds_dir = self.config['dir'].get()
|
||||
if feeds_dir:
|
||||
feeds_dir = os.path.expanduser(bytestring_path(feeds_dir))
|
||||
self.config['dir'] = feeds_dir
|
||||
if not os.path.exists(syspath(feeds_dir)):
|
||||
os.makedirs(syspath(feeds_dir))
|
||||
|
||||
relative_to = self.config['relative_to'].get()
|
||||
if relative_to:
|
||||
self.config['relative_to'] = normpath(relative_to)
|
||||
else:
|
||||
try:
|
||||
relpath = os.path.relpath(item.path, relative_to)
|
||||
except ValueError:
|
||||
# On Windows, it is sometimes not possible to construct a
|
||||
# relative path (if the files are on different disks).
|
||||
relpath = item.path
|
||||
paths.append(relpath)
|
||||
self.config['relative_to'] = feeds_dir
|
||||
|
||||
if 'm3u' in formats:
|
||||
basename = bytestring_path(
|
||||
config['importfeeds']['m3u_name'].get(unicode)
|
||||
)
|
||||
m3u_path = os.path.join(feedsdir, basename)
|
||||
_write_m3u(m3u_path, paths)
|
||||
self.register_listener('library_opened', self.library_opened)
|
||||
self.register_listener('album_imported', self.album_imported)
|
||||
self.register_listener('item_imported', self.item_imported)
|
||||
|
||||
if 'm3u_multi' in formats:
|
||||
m3u_path = _build_m3u_filename(basename)
|
||||
_write_m3u(m3u_path, paths)
|
||||
def _record_items(self, lib, basename, items):
|
||||
"""Records relative paths to the given items for each feed format
|
||||
"""
|
||||
feedsdir = bytestring_path(self.config['dir'].as_filename())
|
||||
formats = self.config['formats'].as_str_seq()
|
||||
relative_to = self.config['relative_to'].get() \
|
||||
or self.config['dir'].as_filename()
|
||||
relative_to = bytestring_path(relative_to)
|
||||
|
||||
if 'link' in formats:
|
||||
for path in paths:
|
||||
dest = os.path.join(feedsdir, os.path.basename(path))
|
||||
if not os.path.exists(syspath(dest)):
|
||||
os.symlink(syspath(path), syspath(dest))
|
||||
paths = []
|
||||
for item in items:
|
||||
if self.config['absolute_path']:
|
||||
paths.append(item.path)
|
||||
else:
|
||||
try:
|
||||
relpath = os.path.relpath(item.path, relative_to)
|
||||
except ValueError:
|
||||
# On Windows, it is sometimes not possible to construct a
|
||||
# relative path (if the files are on different disks).
|
||||
relpath = item.path
|
||||
paths.append(relpath)
|
||||
|
||||
if 'echo' in formats:
|
||||
log.info("Location of imported music:")
|
||||
for path in paths:
|
||||
log.info(" " + path)
|
||||
if 'm3u' in formats:
|
||||
basename = bytestring_path(
|
||||
self.config['m3u_name'].get(unicode)
|
||||
)
|
||||
m3u_path = os.path.join(feedsdir, basename)
|
||||
_write_m3u(m3u_path, paths)
|
||||
|
||||
if 'm3u_multi' in formats:
|
||||
m3u_path = _build_m3u_filename(basename)
|
||||
_write_m3u(m3u_path, paths)
|
||||
|
||||
@ImportFeedsPlugin.listen('library_opened')
|
||||
def library_opened(lib):
|
||||
if config['importfeeds']['dir'].get() is None:
|
||||
config['importfeeds']['dir'] = _get_feeds_dir(lib)
|
||||
if 'link' in formats:
|
||||
for path in paths:
|
||||
dest = os.path.join(feedsdir, os.path.basename(path))
|
||||
if not os.path.exists(syspath(dest)):
|
||||
os.symlink(syspath(path), syspath(dest))
|
||||
|
||||
if 'echo' in formats:
|
||||
self._log.info("Location of imported music:")
|
||||
for path in paths:
|
||||
self._log.info(" {0}", path)
|
||||
|
||||
@ImportFeedsPlugin.listen('album_imported')
|
||||
def album_imported(lib, album):
|
||||
_record_items(lib, album.album, album.items())
|
||||
def library_opened(self, lib):
|
||||
if self.config['dir'].get() is None:
|
||||
self.config['dir'] = _get_feeds_dir(lib)
|
||||
|
||||
def album_imported(self, lib, album):
|
||||
self._record_items(lib, album.album, album.items())
|
||||
|
||||
@ImportFeedsPlugin.listen('item_imported')
|
||||
def item_imported(lib, item):
|
||||
_record_items(lib, item.title, [item])
|
||||
def item_imported(self, lib, item):
|
||||
self._record_items(lib, item.title, [item])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
|
|
@ -24,49 +23,6 @@ from beets import mediafile
|
|||
from beets.util import displayable_path, normpath, syspath
|
||||
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def run(lib, opts, args):
|
||||
"""Print tag info or library data for each file referenced by args.
|
||||
|
||||
Main entry point for the `beet info ARGS...` command.
|
||||
|
||||
If an argument is a path pointing to an existing file, then the tags
|
||||
of that file are printed. All other arguments are considered
|
||||
queries, and for each item matching all those queries the tags from
|
||||
the file are printed.
|
||||
|
||||
If `opts.summarize` is true, the function merges all tags into one
|
||||
dictionary and only prints that. If two files have different values
|
||||
for the same tag, the value is set to '[various]'
|
||||
"""
|
||||
if opts.library:
|
||||
data_collector = library_data
|
||||
else:
|
||||
data_collector = tag_data
|
||||
|
||||
first = True
|
||||
summary = {}
|
||||
for data_emitter in data_collector(lib, ui.decargs(args)):
|
||||
try:
|
||||
data = data_emitter()
|
||||
except mediafile.UnreadableFileError as ex:
|
||||
log.error(u'cannot read file: {0}'.format(ex.message))
|
||||
continue
|
||||
|
||||
if opts.summarize:
|
||||
update_summary(summary, data)
|
||||
else:
|
||||
if not first:
|
||||
ui.print_()
|
||||
print_data(data)
|
||||
first = False
|
||||
|
||||
if opts.summarize:
|
||||
print_data(summary)
|
||||
|
||||
|
||||
def tag_data(lib, args):
|
||||
query = []
|
||||
for arg in args:
|
||||
|
|
@ -143,9 +99,48 @@ class InfoPlugin(BeetsPlugin):
|
|||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('info', help='show file metadata')
|
||||
cmd.func = run
|
||||
cmd.func = self.run
|
||||
cmd.parser.add_option('-l', '--library', action='store_true',
|
||||
help='show library fields instead of tags')
|
||||
cmd.parser.add_option('-s', '--summarize', action='store_true',
|
||||
help='summarize the tags of all files')
|
||||
return [cmd]
|
||||
|
||||
def run(self, lib, opts, args):
|
||||
"""Print tag info or library data for each file referenced by args.
|
||||
|
||||
Main entry point for the `beet info ARGS...` command.
|
||||
|
||||
If an argument is a path pointing to an existing file, then the tags
|
||||
of that file are printed. All other arguments are considered
|
||||
queries, and for each item matching all those queries the tags from
|
||||
the file are printed.
|
||||
|
||||
If `opts.summarize` is true, the function merges all tags into one
|
||||
dictionary and only prints that. If two files have different values
|
||||
for the same tag, the value is set to '[various]'
|
||||
"""
|
||||
if opts.library:
|
||||
data_collector = library_data
|
||||
else:
|
||||
data_collector = tag_data
|
||||
|
||||
first = True
|
||||
summary = {}
|
||||
for data_emitter in data_collector(lib, ui.decargs(args)):
|
||||
try:
|
||||
data = data_emitter()
|
||||
except mediafile.UnreadableFileError as ex:
|
||||
self._log.error(u'cannot read file: {0}', ex)
|
||||
continue
|
||||
|
||||
if opts.summarize:
|
||||
update_summary(summary, data)
|
||||
else:
|
||||
if not first:
|
||||
ui.print_()
|
||||
print_data(data)
|
||||
first = False
|
||||
|
||||
if opts.summarize:
|
||||
print_data(summary)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,15 +14,12 @@
|
|||
|
||||
"""Allows inline path template customization code in the config file.
|
||||
"""
|
||||
import logging
|
||||
import traceback
|
||||
import itertools
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import config
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
FUNC_NAME = u'__INLINE_FUNC__'
|
||||
|
||||
|
||||
|
|
@ -50,56 +47,6 @@ def _compile_func(body):
|
|||
return env[FUNC_NAME]
|
||||
|
||||
|
||||
def compile_inline(python_code, album):
|
||||
"""Given a Python expression or function body, compile it as a path
|
||||
field function. The returned function takes a single argument, an
|
||||
Item, and returns a Unicode string. If the expression cannot be
|
||||
compiled, then an error is logged and this function returns None.
|
||||
"""
|
||||
# First, try compiling as a single function.
|
||||
try:
|
||||
code = compile(u'({0})'.format(python_code), 'inline', 'eval')
|
||||
except SyntaxError:
|
||||
# Fall back to a function body.
|
||||
try:
|
||||
func = _compile_func(python_code)
|
||||
except SyntaxError:
|
||||
log.error(u'syntax error in inline field definition:\n{0}'.format(
|
||||
traceback.format_exc()
|
||||
))
|
||||
return
|
||||
else:
|
||||
is_expr = False
|
||||
else:
|
||||
is_expr = True
|
||||
|
||||
def _dict_for(obj):
|
||||
out = dict(obj)
|
||||
if album:
|
||||
out['items'] = list(obj.items())
|
||||
return out
|
||||
|
||||
if is_expr:
|
||||
# For expressions, just evaluate and return the result.
|
||||
def _expr_func(obj):
|
||||
values = _dict_for(obj)
|
||||
try:
|
||||
return eval(code, values)
|
||||
except Exception as exc:
|
||||
raise InlineError(python_code, exc)
|
||||
return _expr_func
|
||||
else:
|
||||
# For function bodies, invoke the function with values as global
|
||||
# variables.
|
||||
def _func_func(obj):
|
||||
func.__globals__.update(_dict_for(obj))
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc:
|
||||
raise InlineError(python_code, exc)
|
||||
return _func_func
|
||||
|
||||
|
||||
class InlinePlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(InlinePlugin, self).__init__()
|
||||
|
|
@ -113,14 +60,62 @@ class InlinePlugin(BeetsPlugin):
|
|||
# Item fields.
|
||||
for key, view in itertools.chain(config['item_fields'].items(),
|
||||
config['pathfields'].items()):
|
||||
log.debug(u'inline: adding item field {0}'.format(key))
|
||||
func = compile_inline(view.get(unicode), False)
|
||||
self._log.debug(u'adding item field {0}', key)
|
||||
func = self.compile_inline(view.get(unicode), False)
|
||||
if func is not None:
|
||||
self.template_fields[key] = func
|
||||
|
||||
# Album fields.
|
||||
for key, view in config['album_fields'].items():
|
||||
log.debug(u'inline: adding album field {0}'.format(key))
|
||||
func = compile_inline(view.get(unicode), True)
|
||||
self._log.debug(u'adding album field {0}', key)
|
||||
func = self.compile_inline(view.get(unicode), True)
|
||||
if func is not None:
|
||||
self.album_template_fields[key] = func
|
||||
|
||||
def compile_inline(self, python_code, album):
|
||||
"""Given a Python expression or function body, compile it as a path
|
||||
field function. The returned function takes a single argument, an
|
||||
Item, and returns a Unicode string. If the expression cannot be
|
||||
compiled, then an error is logged and this function returns None.
|
||||
"""
|
||||
# First, try compiling as a single function.
|
||||
try:
|
||||
code = compile(u'({0})'.format(python_code), 'inline', 'eval')
|
||||
except SyntaxError:
|
||||
# Fall back to a function body.
|
||||
try:
|
||||
func = _compile_func(python_code)
|
||||
except SyntaxError:
|
||||
self._log.error(u'syntax error in inline field definition:\n'
|
||||
u'{0}', traceback.format_exc())
|
||||
return
|
||||
else:
|
||||
is_expr = False
|
||||
else:
|
||||
is_expr = True
|
||||
|
||||
def _dict_for(obj):
|
||||
out = dict(obj)
|
||||
if album:
|
||||
out['items'] = list(obj.items())
|
||||
return out
|
||||
|
||||
if is_expr:
|
||||
# For expressions, just evaluate and return the result.
|
||||
def _expr_func(obj):
|
||||
values = _dict_for(obj)
|
||||
try:
|
||||
return eval(code, values)
|
||||
except Exception as exc:
|
||||
raise InlineError(python_code, exc)
|
||||
return _expr_func
|
||||
else:
|
||||
# For function bodies, invoke the function with values as global
|
||||
# variables.
|
||||
def _func_func(obj):
|
||||
func.__globals__.update(_dict_for(obj))
|
||||
try:
|
||||
return func()
|
||||
except Exception as exc:
|
||||
raise InlineError(python_code, exc)
|
||||
return _func_func
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
"""Uses the `KeyFinder` program to add the `initial_key` field.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from beets import ui
|
||||
|
|
@ -23,9 +22,6 @@ from beets import util
|
|||
from beets.plugins import BeetsPlugin
|
||||
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class KeyFinderPlugin(BeetsPlugin):
|
||||
|
||||
def __init__(self):
|
||||
|
|
@ -62,11 +58,11 @@ class KeyFinderPlugin(BeetsPlugin):
|
|||
try:
|
||||
key = util.command_output([bin, '-f', item.path])
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
log.error(u'KeyFinder execution failed: {0}'.format(exc))
|
||||
self._log.error(u'execution failed: {0}', exc)
|
||||
continue
|
||||
|
||||
item['initial_key'] = key
|
||||
log.debug(u'added computed initial key {0} for {1}'
|
||||
.format(key, util.displayable_path(item.path)))
|
||||
self._log.debug(u'added computed initial key {0} for {1}',
|
||||
key, util.displayable_path(item.path))
|
||||
item.try_write()
|
||||
item.store()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -20,7 +20,6 @@ and has been edited to remove some questionable entries.
|
|||
The scraper script used is available here:
|
||||
https://gist.github.com/1241307
|
||||
"""
|
||||
import logging
|
||||
import pylast
|
||||
import os
|
||||
import yaml
|
||||
|
|
@ -31,7 +30,6 @@ from beets.util import normpath, plurality
|
|||
from beets import config
|
||||
from beets import library
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
|
||||
|
||||
|
|
@ -53,40 +51,8 @@ def deduplicate(seq):
|
|||
return [x for x in seq if x not in seen and not seen.add(x)]
|
||||
|
||||
|
||||
# Core genre identification routine.
|
||||
|
||||
def _tags_for(obj, min_weight=None):
|
||||
"""Given a pylast entity (album or track), return a list of
|
||||
tag names for that entity. Return an empty list if the entity is
|
||||
not found or another error occurs.
|
||||
|
||||
If `min_weight` is specified, tags are filtered by weight.
|
||||
"""
|
||||
try:
|
||||
# Work around an inconsistency in pylast where
|
||||
# Album.get_top_tags() does not return TopItem instances.
|
||||
# https://code.google.com/p/pylast/issues/detail?id=85
|
||||
if isinstance(obj, pylast.Album):
|
||||
res = super(pylast.Album, obj).get_top_tags()
|
||||
else:
|
||||
res = obj.get_top_tags()
|
||||
except PYLAST_EXCEPTIONS as exc:
|
||||
log.debug(u'last.fm error: {0}'.format(exc))
|
||||
return []
|
||||
|
||||
# Filter by weight (optionally).
|
||||
if min_weight:
|
||||
res = [el for el in res if (el.weight or 0) >= min_weight]
|
||||
|
||||
# Get strings from tags.
|
||||
res = [el.item.get_name().lower() for el in res]
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# Canonicalization tree processing.
|
||||
|
||||
|
||||
def flatten_tree(elem, path, branches):
|
||||
"""Flatten nested lists/dictionaries into lists of strings
|
||||
(branches).
|
||||
|
|
@ -225,7 +191,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
can be found. Ex. 'Electronic, House, Dance'
|
||||
"""
|
||||
min_weight = self.config['min_weight'].get(int)
|
||||
return self._resolve_genres(_tags_for(lastfm_obj, min_weight))
|
||||
return self._resolve_genres(self._tags_for(lastfm_obj, min_weight))
|
||||
|
||||
def _is_allowed(self, genre):
|
||||
"""Determine whether the genre is present in the whitelist,
|
||||
|
|
@ -371,9 +337,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
|
||||
for album in lib.albums(ui.decargs(args)):
|
||||
album.genre, src = self._get_genre(album)
|
||||
log.info(u'genre for album {0} - {1} ({2}): {3}'.format(
|
||||
album.albumartist, album.album, src, album.genre
|
||||
))
|
||||
self._log.info(u'genre for album {0.albumartist} - {0.album} '
|
||||
u'({1}): {0.genre}', album, src)
|
||||
album.store()
|
||||
|
||||
for item in album.items():
|
||||
|
|
@ -382,9 +347,8 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
if 'track' in self.sources:
|
||||
item.genre, src = self._get_genre(item)
|
||||
item.store()
|
||||
log.info(u'genre for track {0} - {1} ({2}): {3}'
|
||||
.format(item.artist, item.title, src,
|
||||
item.genre))
|
||||
self._log.info(u'genre for track {0.artist} - {0.tit'
|
||||
u'le} ({1}): {0.genre}', item, src)
|
||||
|
||||
if write:
|
||||
item.try_write()
|
||||
|
|
@ -397,20 +361,50 @@ class LastGenrePlugin(plugins.BeetsPlugin):
|
|||
if task.is_album:
|
||||
album = task.album
|
||||
album.genre, src = self._get_genre(album)
|
||||
log.debug(u'added last.fm album genre ({0}): {1}'.format(
|
||||
src, album.genre))
|
||||
self._log.debug(u'added last.fm album genre ({0}): {1}',
|
||||
src, album.genre)
|
||||
album.store()
|
||||
|
||||
if 'track' in self.sources:
|
||||
for item in album.items():
|
||||
item.genre, src = self._get_genre(item)
|
||||
log.debug(u'added last.fm item genre ({0}): {1}'.format(
|
||||
src, item.genre))
|
||||
self._log.debug(u'added last.fm item genre ({0}): {1}',
|
||||
src, item.genre)
|
||||
item.store()
|
||||
|
||||
else:
|
||||
item = task.item
|
||||
item.genre, src = self._get_genre(item)
|
||||
log.debug(u'added last.fm item genre ({0}): {1}'.format(
|
||||
src, item.genre))
|
||||
self._log.debug(u'added last.fm item genre ({0}): {1}',
|
||||
src, item.genre)
|
||||
item.store()
|
||||
|
||||
def _tags_for(self, obj, min_weight=None):
|
||||
"""Core genre identification routine.
|
||||
|
||||
Given a pylast entity (album or track), return a list of
|
||||
tag names for that entity. Return an empty list if the entity is
|
||||
not found or another error occurs.
|
||||
|
||||
If `min_weight` is specified, tags are filtered by weight.
|
||||
"""
|
||||
try:
|
||||
# Work around an inconsistency in pylast where
|
||||
# Album.get_top_tags() does not return TopItem instances.
|
||||
# https://code.google.com/p/pylast/issues/detail?id=85
|
||||
if isinstance(obj, pylast.Album):
|
||||
res = super(pylast.Album, obj).get_top_tags()
|
||||
else:
|
||||
res = obj.get_top_tags()
|
||||
except PYLAST_EXCEPTIONS as exc:
|
||||
self._log.debug(u'last.fm error: {0}', exc)
|
||||
return []
|
||||
|
||||
# Filter by weight (optionally).
|
||||
if min_weight:
|
||||
res = [el for el in res if (el.weight or 0) >= min_weight]
|
||||
|
||||
# Get strings from tags.
|
||||
res = [el.item.get_name().lower() for el in res]
|
||||
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -159,7 +159,9 @@
|
|||
- comedy:
|
||||
- comedy music
|
||||
- comedy rock
|
||||
- humor
|
||||
- parody music
|
||||
- stand-up
|
||||
- country:
|
||||
- alternative country:
|
||||
- cowpunk
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ coimbra fado
|
|||
coladeira
|
||||
colombianas
|
||||
combined rhythm
|
||||
comedy
|
||||
comedy rap
|
||||
comedy rock
|
||||
comic opera
|
||||
|
|
@ -647,6 +648,7 @@ hua'er
|
|||
huasteco
|
||||
huayno
|
||||
hula
|
||||
humor
|
||||
humppa
|
||||
hunguhungu
|
||||
hyangak
|
||||
|
|
@ -1352,6 +1354,7 @@ sprechgesang
|
|||
square dance
|
||||
squee
|
||||
st. louis blues
|
||||
stand-up
|
||||
steelband
|
||||
stoner metal
|
||||
stoner rock
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Rafael Bodill http://github.com/rafi
|
||||
# Copyright 2015, Rafael Bodill http://github.com/rafi
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from beets import ui
|
||||
from beets import dbcore
|
||||
|
|
@ -20,7 +19,6 @@ from beets import config
|
|||
from beets import plugins
|
||||
from beets.dbcore import types
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
API_URL = 'http://ws.audioscrobbler.com/2.0/'
|
||||
|
||||
|
||||
|
|
@ -43,20 +41,20 @@ class LastImportPlugin(plugins.BeetsPlugin):
|
|||
cmd = ui.Subcommand('lastimport', help='import last.fm play-count')
|
||||
|
||||
def func(lib, opts, args):
|
||||
import_lastfm(lib)
|
||||
import_lastfm(lib, self._log)
|
||||
|
||||
cmd.func = func
|
||||
return [cmd]
|
||||
|
||||
|
||||
def import_lastfm(lib):
|
||||
def import_lastfm(lib, log):
|
||||
user = config['lastfm']['user']
|
||||
per_page = config['lastimport']['per_page']
|
||||
|
||||
if not user:
|
||||
raise ui.UserError('You must specify a user name for lastimport')
|
||||
|
||||
log.info('Fetching last.fm library for @{0}'.format(user))
|
||||
log.info('Fetching last.fm library for @{0}', user)
|
||||
|
||||
page_total = 1
|
||||
page_current = 0
|
||||
|
|
@ -65,10 +63,9 @@ def import_lastfm(lib):
|
|||
retry_limit = config['lastimport']['retry_limit'].get(int)
|
||||
# Iterate through a yet to be known page total count
|
||||
while page_current < page_total:
|
||||
log.info('lastimport: Querying page #{0}{1}...'.format(
|
||||
page_current + 1,
|
||||
'/' + str(page_total) if page_total > 1 else ''
|
||||
))
|
||||
log.info('Querying page #{0}{1}...',
|
||||
page_current + 1,
|
||||
'/{}'.format(page_total) if page_total > 1 else '')
|
||||
|
||||
for retry in range(0, retry_limit):
|
||||
page = fetch_tracks(user, page_current + 1, per_page)
|
||||
|
|
@ -79,32 +76,28 @@ def import_lastfm(lib):
|
|||
# It means nothing to us!
|
||||
raise ui.UserError('Last.fm reported no data.')
|
||||
|
||||
found, unknown = process_tracks(lib, page['tracks']['track'])
|
||||
track = page['tracks']['track']
|
||||
found, unknown = process_tracks(lib, track, log)
|
||||
found_total += found
|
||||
unknown_total += unknown
|
||||
break
|
||||
else:
|
||||
log.error('lastimport: ERROR: unable to read page #{0}'.format(
|
||||
page_current + 1
|
||||
))
|
||||
log.error('ERROR: unable to read page #{0}',
|
||||
page_current + 1)
|
||||
if retry < retry_limit:
|
||||
log.info(
|
||||
'lastimport: Retrying page #{0}... ({1}/{2} retry)'
|
||||
.format(page_current + 1, retry + 1, retry_limit)
|
||||
'Retrying page #{0}... ({1}/{2} retry)',
|
||||
page_current + 1, retry + 1, retry_limit
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
'lastimport: FAIL: unable to fetch page #{0}, '
|
||||
'tried {1} times'.format(page_current, retry + 1)
|
||||
)
|
||||
log.error('FAIL: unable to fetch page #{0}, ',
|
||||
'tried {1} times', page_current, retry + 1)
|
||||
page_current += 1
|
||||
|
||||
log.info('lastimport: ... done!')
|
||||
log.info('lastimport: finished processing {0} song pages'.format(
|
||||
page_total
|
||||
))
|
||||
log.info('lastimport: {0} unknown play-counts'.format(unknown_total))
|
||||
log.info('lastimport: {0} play-counts imported'.format(found_total))
|
||||
log.info('... done!')
|
||||
log.info('finished processing {0} song pages', page_total)
|
||||
log.info('{0} unknown play-counts', unknown_total)
|
||||
log.info('{0} play-counts imported', found_total)
|
||||
|
||||
|
||||
def fetch_tracks(user, page, limit):
|
||||
|
|
@ -118,14 +111,11 @@ def fetch_tracks(user, page, limit):
|
|||
}).json()
|
||||
|
||||
|
||||
def process_tracks(lib, tracks):
|
||||
def process_tracks(lib, tracks, log):
|
||||
total = len(tracks)
|
||||
total_found = 0
|
||||
total_fails = 0
|
||||
log.info(
|
||||
'lastimport: Received {0} tracks in this page, processing...'
|
||||
.format(total)
|
||||
)
|
||||
log.info('Received {0} tracks in this page, processing...', total)
|
||||
|
||||
for num in xrange(0, total):
|
||||
song = ''
|
||||
|
|
@ -136,8 +126,7 @@ def process_tracks(lib, tracks):
|
|||
if 'album' in tracks[num]:
|
||||
album = tracks[num]['album'].get('name', '').strip()
|
||||
|
||||
log.debug(u'lastimport: query: {0} - {1} ({2})'
|
||||
.format(artist, title, album))
|
||||
log.debug(u'query: {0} - {1} ({2})', artist, title, album)
|
||||
|
||||
# First try to query by musicbrainz's trackid
|
||||
if trackid:
|
||||
|
|
@ -147,8 +136,8 @@ def process_tracks(lib, tracks):
|
|||
|
||||
# Otherwise try artist/title/album
|
||||
if not song:
|
||||
log.debug(u'lastimport: no match for mb_trackid {0}, trying by '
|
||||
u'artist/title/album'.format(trackid))
|
||||
log.debug(u'no match for mb_trackid {0}, trying by '
|
||||
u'artist/title/album', trackid)
|
||||
query = dbcore.AndQuery([
|
||||
dbcore.query.SubstringQuery('artist', artist),
|
||||
dbcore.query.SubstringQuery('title', title),
|
||||
|
|
@ -158,7 +147,7 @@ def process_tracks(lib, tracks):
|
|||
|
||||
# If not, try just artist/title
|
||||
if not song:
|
||||
log.debug(u'lastimport: no album match, trying by artist/title')
|
||||
log.debug(u'no album match, trying by artist/title')
|
||||
query = dbcore.AndQuery([
|
||||
dbcore.query.SubstringQuery('artist', artist),
|
||||
dbcore.query.SubstringQuery('title', title)
|
||||
|
|
@ -168,7 +157,7 @@ def process_tracks(lib, tracks):
|
|||
# Last resort, try just replacing to utf-8 quote
|
||||
if not song:
|
||||
title = title.replace("'", u'\u2019')
|
||||
log.debug(u'lastimport: no title match, trying utf-8 single quote')
|
||||
log.debug(u'no title match, trying utf-8 single quote')
|
||||
query = dbcore.AndQuery([
|
||||
dbcore.query.SubstringQuery('artist', artist),
|
||||
dbcore.query.SubstringQuery('title', title)
|
||||
|
|
@ -178,26 +167,19 @@ def process_tracks(lib, tracks):
|
|||
if song:
|
||||
count = int(song.get('play_count', 0))
|
||||
new_count = int(tracks[num]['playcount'])
|
||||
log.debug(
|
||||
u'lastimport: match: {0} - {1} ({2}) '
|
||||
u'updating: play_count {3} => {4}'.format(
|
||||
song.artist, song.title, song.album, count, new_count
|
||||
)
|
||||
)
|
||||
log.debug(u'match: {0} - {1} ({2}) '
|
||||
u'updating: play_count {3} => {4}',
|
||||
song.artist, song.title, song.album, count, new_count)
|
||||
song['play_count'] = new_count
|
||||
song.store()
|
||||
total_found += 1
|
||||
else:
|
||||
total_fails += 1
|
||||
log.info(
|
||||
u'lastimport: - No match: {0} - {1} ({2})'
|
||||
.format(artist, title, album)
|
||||
)
|
||||
log.info(u' - No match: {0} - {1} ({2})',
|
||||
artist, title, album)
|
||||
|
||||
if total_fails > 0:
|
||||
log.info(
|
||||
'lastimport: Acquired {0}/{1} play-counts ({2} unknown)'
|
||||
.format(total_found, total, total_fails)
|
||||
)
|
||||
log.info('Acquired {0}/{1} play-counts ({2} unknown)',
|
||||
total_found, total, total_fails)
|
||||
|
||||
return total_found, total_fails
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -17,23 +17,19 @@
|
|||
from __future__ import print_function
|
||||
|
||||
import re
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import unicodedata
|
||||
import urllib
|
||||
import difflib
|
||||
import itertools
|
||||
import warnings
|
||||
from HTMLParser import HTMLParseError
|
||||
|
||||
from beets import plugins
|
||||
from beets import config, ui
|
||||
|
||||
|
||||
# Global logger.
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
DIV_RE = re.compile(r'<(/?)div>?', re.I)
|
||||
COMMENT_RE = re.compile(r'<!--.*-->', re.S)
|
||||
TAG_RE = re.compile(r'<[^>]*>')
|
||||
|
|
@ -56,20 +52,6 @@ URL_CHARACTERS = {
|
|||
|
||||
# Utilities.
|
||||
|
||||
def fetch_url(url):
|
||||
"""Retrieve the content at a given URL, or return None if the source
|
||||
is unreachable.
|
||||
"""
|
||||
try:
|
||||
r = requests.get(url, verify=False)
|
||||
except requests.RequestException as exc:
|
||||
log.debug(u'lyrics request failed: {0}'.format(exc))
|
||||
return
|
||||
if r.status_code == requests.codes.ok:
|
||||
return r.text
|
||||
else:
|
||||
log.debug(u'failed to fetch: {0} ({1})'.format(url, r.status_code))
|
||||
|
||||
|
||||
def unescape(text):
|
||||
"""Resolves &#xxx; HTML entities (and some others)."""
|
||||
|
|
@ -174,131 +156,116 @@ def search_pairs(item):
|
|||
return itertools.product(artists, multi_titles)
|
||||
|
||||
|
||||
def _encode(s):
|
||||
"""Encode the string for inclusion in a URL (common to both
|
||||
LyricsWiki and Lyrics.com).
|
||||
"""
|
||||
if isinstance(s, unicode):
|
||||
for char, repl in URL_CHARACTERS.items():
|
||||
s = s.replace(char, repl)
|
||||
s = s.encode('utf8', 'ignore')
|
||||
return urllib.quote(s)
|
||||
class Backend(object):
|
||||
def __init__(self, log):
|
||||
self._log = log
|
||||
|
||||
# Musixmatch
|
||||
@staticmethod
|
||||
def _encode(s):
|
||||
"""Encode the string for inclusion in a URL"""
|
||||
if isinstance(s, unicode):
|
||||
for char, repl in URL_CHARACTERS.items():
|
||||
s = s.replace(char, repl)
|
||||
s = s.encode('utf8', 'ignore')
|
||||
return urllib.quote(s)
|
||||
|
||||
MUSIXMATCH_URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
|
||||
def build_url(self, artist, title):
|
||||
return self.URL_PATTERN % (self._encode(artist.title()),
|
||||
self._encode(title.title()))
|
||||
|
||||
|
||||
def fetch_musixmatch(artist, title):
|
||||
url = MUSIXMATCH_URL_PATTERN % (_lw_encode(artist.title()),
|
||||
_lw_encode(title.title()))
|
||||
html = fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
lyrics = extract_text_between(html, '"lyrics_body":', '"lyrics_language":')
|
||||
return lyrics.strip(',"').replace('\\n', '\n')
|
||||
|
||||
# LyricsWiki.
|
||||
|
||||
LYRICSWIKI_URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
||||
|
||||
|
||||
def _lw_encode(s):
|
||||
s = re.sub(r'\s+', '_', s)
|
||||
s = s.replace("<", "Less_Than")
|
||||
s = s.replace(">", "Greater_Than")
|
||||
s = s.replace("#", "Number_")
|
||||
s = re.sub(r'[\[\{]', '(', s)
|
||||
s = re.sub(r'[\]\}]', ')', s)
|
||||
return _encode(s)
|
||||
|
||||
|
||||
def fetch_lyricswiki(artist, title):
|
||||
"""Fetch lyrics from LyricsWiki."""
|
||||
url = LYRICSWIKI_URL_PATTERN % (_lw_encode(artist), _lw_encode(title))
|
||||
html = fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
|
||||
lyrics = extract_text_in(html, u"<div class='lyricbox'>")
|
||||
if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
|
||||
return lyrics
|
||||
|
||||
|
||||
# Lyrics.com.
|
||||
|
||||
LYRICSCOM_URL_PATTERN = 'http://www.lyrics.com/%s-lyrics-%s.html'
|
||||
LYRICSCOM_NOT_FOUND = (
|
||||
'Sorry, we do not have the lyric',
|
||||
'Submit Lyrics',
|
||||
)
|
||||
|
||||
|
||||
def _lc_encode(s):
|
||||
s = re.sub(r'[^\w\s-]', '', s)
|
||||
s = re.sub(r'\s+', '-', s)
|
||||
return _encode(s).lower()
|
||||
|
||||
|
||||
def fetch_lyricscom(artist, title):
|
||||
"""Fetch lyrics from Lyrics.com."""
|
||||
url = LYRICSCOM_URL_PATTERN % (_lc_encode(title), _lc_encode(artist))
|
||||
html = fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
lyrics = extract_text_between(html, '<div id="lyrics" class="SCREENONLY" '
|
||||
'itemprop="description">', '</div>')
|
||||
if not lyrics:
|
||||
return
|
||||
for not_found_str in LYRICSCOM_NOT_FOUND:
|
||||
if not_found_str in lyrics:
|
||||
def fetch_url(self, url):
|
||||
"""Retrieve the content at a given URL, or return None if the source
|
||||
is unreachable.
|
||||
"""
|
||||
try:
|
||||
# Disable the InsecureRequestWarning that comes from using
|
||||
# `verify=false`.
|
||||
# https://github.com/kennethreitz/requests/issues/2214
|
||||
# We're not overly worried about the NSA MITMing our lyrics scraper
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
r = requests.get(url, verify=False)
|
||||
except requests.RequestException as exc:
|
||||
self._log.debug(u'lyrics request failed: {0}', exc)
|
||||
return
|
||||
if r.status_code == requests.codes.ok:
|
||||
return r.text
|
||||
else:
|
||||
self._log.debug(u'failed to fetch: {0} ({1})', url, r.status_code)
|
||||
|
||||
parts = lyrics.split('\n---\nLyrics powered by', 1)
|
||||
if parts:
|
||||
return parts[0]
|
||||
def fetch(self, artist, title):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
# Optional Google custom search API backend.
|
||||
|
||||
def slugify(text):
|
||||
"""Normalize a string and remove non-alphanumeric characters.
|
||||
"""
|
||||
text = re.sub(r"[-'_\s]", '_', text)
|
||||
text = re.sub(r"_+", '_', text).strip('_')
|
||||
pat = "([^,\(]*)\((.*?)\)" # Remove content within parentheses
|
||||
text = re.sub(pat, '\g<1>', text).strip()
|
||||
try:
|
||||
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore')
|
||||
text = unicode(re.sub('[-\s]+', ' ', text))
|
||||
except UnicodeDecodeError:
|
||||
log.exception(u"Failing to normalize '{0}'".format(text))
|
||||
return text
|
||||
class SymbolsReplaced(Backend):
|
||||
@classmethod
|
||||
def _encode(cls, s):
|
||||
s = re.sub(r'\s+', '_', s)
|
||||
s = s.replace("<", "Less_Than")
|
||||
s = s.replace(">", "Greater_Than")
|
||||
s = s.replace("#", "Number_")
|
||||
s = re.sub(r'[\[\{]', '(', s)
|
||||
s = re.sub(r'[\]\}]', ')', s)
|
||||
return super(SymbolsReplaced, cls)._encode(s)
|
||||
|
||||
|
||||
BY_TRANS = ['by', 'par', 'de', 'von']
|
||||
LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte']
|
||||
class MusiXmatch(SymbolsReplaced):
|
||||
URL_PATTERN = 'https://www.musixmatch.com/lyrics/%s/%s'
|
||||
|
||||
def fetch(self, artist, title):
|
||||
url = self.build_url(artist, title)
|
||||
html = self.fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
lyrics = extract_text_between(html,
|
||||
'"lyrics_body":', '"lyrics_language":')
|
||||
return lyrics.strip(',"').replace('\\n', '\n')
|
||||
|
||||
|
||||
def is_page_candidate(urlLink, urlTitle, title, artist):
|
||||
"""Return True if the URL title makes it a good candidate to be a
|
||||
page that contains lyrics of title by artist.
|
||||
"""
|
||||
title = slugify(title.lower())
|
||||
artist = slugify(artist.lower())
|
||||
sitename = re.search(u"//([^/]+)/.*", slugify(urlLink.lower())).group(1)
|
||||
urlTitle = slugify(urlTitle.lower())
|
||||
# Check if URL title contains song title (exact match)
|
||||
if urlTitle.find(title) != -1:
|
||||
return True
|
||||
# or try extracting song title from URL title and check if
|
||||
# they are close enough
|
||||
tokens = [by + '_' + artist for by in BY_TRANS] + \
|
||||
[artist, sitename, sitename.replace('www.', '')] + LYRICS_TRANS
|
||||
songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle)
|
||||
songTitle = songTitle.strip('_|')
|
||||
typoRatio = .9
|
||||
return difflib.SequenceMatcher(None, songTitle, title).ratio() >= typoRatio
|
||||
class LyricsWiki(SymbolsReplaced):
|
||||
"""Fetch lyrics from LyricsWiki."""
|
||||
URL_PATTERN = 'http://lyrics.wikia.com/%s:%s'
|
||||
|
||||
def fetch(self, artist, title):
|
||||
url = self.build_url(artist, title)
|
||||
html = self.fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
lyrics = extract_text_in(html, u"<div class='lyricbox'>")
|
||||
if lyrics and 'Unfortunately, we are not licensed' not in lyrics:
|
||||
return lyrics
|
||||
|
||||
|
||||
class LyricsCom(Backend):
|
||||
"""Fetch lyrics from Lyrics.com."""
|
||||
URL_PATTERN = 'http://www.lyrics.com/%s-lyrics-%s.html'
|
||||
NOT_FOUND = (
|
||||
'Sorry, we do not have the lyric',
|
||||
'Submit Lyrics',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _encode(cls, s):
|
||||
s = re.sub(r'[^\w\s-]', '', s)
|
||||
s = re.sub(r'\s+', '-', s)
|
||||
return super(LyricsCom, cls)._encode(s).lower()
|
||||
|
||||
def fetch(self, artist, title):
|
||||
url = self.build_url(artist, title)
|
||||
html = self.fetch_url(url)
|
||||
if not html:
|
||||
return
|
||||
lyrics = extract_text_between(html, '<div id="lyrics" class="SCREENO'
|
||||
'NLY" itemprop="description">', '</div>')
|
||||
if not lyrics:
|
||||
return
|
||||
for not_found_str in self.NOT_FOUND:
|
||||
if not_found_str in lyrics:
|
||||
return
|
||||
|
||||
parts = lyrics.split('\n---\nLyrics powered by', 1)
|
||||
if parts:
|
||||
return parts[0]
|
||||
|
||||
|
||||
def remove_credits(text):
|
||||
|
|
@ -315,36 +282,6 @@ def remove_credits(text):
|
|||
return text
|
||||
|
||||
|
||||
def is_lyrics(text, artist=None):
|
||||
"""Determine whether the text seems to be valid lyrics.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
badTriggersOcc = []
|
||||
nbLines = text.count('\n')
|
||||
if nbLines <= 1:
|
||||
log.debug(u"Ignoring too short lyrics '{0}'".format(text))
|
||||
return False
|
||||
elif nbLines < 5:
|
||||
badTriggersOcc.append('too_short')
|
||||
else:
|
||||
# Lyrics look legit, remove credits to avoid being penalized further
|
||||
# down
|
||||
text = remove_credits(text)
|
||||
|
||||
badTriggers = ['lyrics', 'copyright', 'property', 'links']
|
||||
if artist:
|
||||
badTriggersOcc += [artist]
|
||||
|
||||
for item in badTriggers:
|
||||
badTriggersOcc += [item] * len(re.findall(r'\W%s\W' % item,
|
||||
text, re.I))
|
||||
|
||||
if badTriggersOcc:
|
||||
log.debug(u'Bad triggers detected: {0}'.format(badTriggersOcc))
|
||||
return len(badTriggersOcc) < 2
|
||||
|
||||
|
||||
def _scrape_strip_cruft(html, plain_text_out=False):
|
||||
"""Clean up HTML
|
||||
"""
|
||||
|
|
@ -396,50 +333,119 @@ def scrape_lyrics_from_html(html):
|
|||
return soup
|
||||
|
||||
|
||||
def fetch_google(artist, title):
|
||||
"""Fetch lyrics from Google search results.
|
||||
"""
|
||||
query = u"%s %s" % (artist, title)
|
||||
api_key = config['lyrics']['google_API_key'].get(unicode)
|
||||
engine_id = config['lyrics']['google_engine_ID'].get(unicode)
|
||||
url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' % \
|
||||
(api_key, engine_id, urllib.quote(query.encode('utf8')))
|
||||
class Google(Backend):
|
||||
"""Fetch lyrics from Google search results."""
|
||||
def is_lyrics(self, text, artist=None):
|
||||
"""Determine whether the text seems to be valid lyrics.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
badTriggersOcc = []
|
||||
nbLines = text.count('\n')
|
||||
if nbLines <= 1:
|
||||
self._log.debug(u"Ignoring too short lyrics '{0}'", text)
|
||||
return False
|
||||
elif nbLines < 5:
|
||||
badTriggersOcc.append('too_short')
|
||||
else:
|
||||
# Lyrics look legit, remove credits to avoid being penalized
|
||||
# further down
|
||||
text = remove_credits(text)
|
||||
|
||||
data = urllib.urlopen(url)
|
||||
data = json.load(data)
|
||||
if 'error' in data:
|
||||
reason = data['error']['errors'][0]['reason']
|
||||
log.debug(u'google lyrics backend error: {0}'.format(reason))
|
||||
return
|
||||
badTriggers = ['lyrics', 'copyright', 'property', 'links']
|
||||
if artist:
|
||||
badTriggersOcc += [artist]
|
||||
|
||||
if 'items' in data.keys():
|
||||
for item in data['items']:
|
||||
urlLink = item['link']
|
||||
urlTitle = item.get('title', u'')
|
||||
if not is_page_candidate(urlLink, urlTitle, title, artist):
|
||||
continue
|
||||
html = fetch_url(urlLink)
|
||||
lyrics = scrape_lyrics_from_html(html)
|
||||
if not lyrics:
|
||||
continue
|
||||
for item in badTriggers:
|
||||
badTriggersOcc += [item] * len(re.findall(r'\W%s\W' % item,
|
||||
text, re.I))
|
||||
|
||||
if is_lyrics(lyrics, artist):
|
||||
log.debug(u'got lyrics from {0}'.format(item['displayLink']))
|
||||
return lyrics
|
||||
if badTriggersOcc:
|
||||
self._log.debug(u'Bad triggers detected: {0}', badTriggersOcc)
|
||||
return len(badTriggersOcc) < 2
|
||||
|
||||
def slugify(self, text):
|
||||
"""Normalize a string and remove non-alphanumeric characters.
|
||||
"""
|
||||
text = re.sub(r"[-'_\s]", '_', text)
|
||||
text = re.sub(r"_+", '_', text).strip('_')
|
||||
pat = "([^,\(]*)\((.*?)\)" # Remove content within parentheses
|
||||
text = re.sub(pat, '\g<1>', text).strip()
|
||||
try:
|
||||
text = unicodedata.normalize('NFKD', text).encode('ascii',
|
||||
'ignore')
|
||||
text = unicode(re.sub('[-\s]+', ' ', text))
|
||||
except UnicodeDecodeError:
|
||||
self._log.exception(u"Failing to normalize '{0}'", text)
|
||||
return text
|
||||
|
||||
# Plugin logic.
|
||||
BY_TRANS = ['by', 'par', 'de', 'von']
|
||||
LYRICS_TRANS = ['lyrics', 'paroles', 'letras', 'liedtexte']
|
||||
|
||||
SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch']
|
||||
SOURCE_BACKENDS = {
|
||||
'google': fetch_google,
|
||||
'lyricwiki': fetch_lyricswiki,
|
||||
'lyrics.com': fetch_lyricscom,
|
||||
'musixmatch': fetch_musixmatch,
|
||||
}
|
||||
def is_page_candidate(self, urlLink, urlTitle, title, artist):
|
||||
"""Return True if the URL title makes it a good candidate to be a
|
||||
page that contains lyrics of title by artist.
|
||||
"""
|
||||
title = self.slugify(title.lower())
|
||||
artist = self.slugify(artist.lower())
|
||||
sitename = re.search(u"//([^/]+)/.*",
|
||||
self.slugify(urlLink.lower())).group(1)
|
||||
urlTitle = self.slugify(urlTitle.lower())
|
||||
# Check if URL title contains song title (exact match)
|
||||
if urlTitle.find(title) != -1:
|
||||
return True
|
||||
# or try extracting song title from URL title and check if
|
||||
# they are close enough
|
||||
tokens = [by + '_' + artist for by in self.BY_TRANS] + \
|
||||
[artist, sitename, sitename.replace('www.', '')] + \
|
||||
self.LYRICS_TRANS
|
||||
songTitle = re.sub(u'(%s)' % u'|'.join(tokens), u'', urlTitle)
|
||||
songTitle = songTitle.strip('_|')
|
||||
typoRatio = .9
|
||||
ratio = difflib.SequenceMatcher(None, songTitle, title).ratio()
|
||||
return ratio >= typoRatio
|
||||
|
||||
def fetch(self, artist, title):
|
||||
query = u"%s %s" % (artist, title)
|
||||
api_key = self.config['google_API_key'].get(unicode)
|
||||
engine_id = self.config['google_engine_ID'].get(unicode)
|
||||
url = u'https://www.googleapis.com/customsearch/v1?key=%s&cx=%s&q=%s' % \
|
||||
(api_key, engine_id, urllib.quote(query.encode('utf8')))
|
||||
|
||||
data = urllib.urlopen(url)
|
||||
data = json.load(data)
|
||||
if 'error' in data:
|
||||
reason = data['error']['errors'][0]['reason']
|
||||
self._log.debug(u'google lyrics backend error: {0}', reason)
|
||||
return
|
||||
|
||||
if 'items' in data.keys():
|
||||
for item in data['items']:
|
||||
urlLink = item['link']
|
||||
urlTitle = item.get('title', u'')
|
||||
if not self.is_page_candidate(urlLink, urlTitle,
|
||||
title, artist):
|
||||
continue
|
||||
html = self.fetch_url(urlLink)
|
||||
lyrics = scrape_lyrics_from_html(html)
|
||||
if not lyrics:
|
||||
continue
|
||||
|
||||
if self.is_lyrics(lyrics, artist):
|
||||
self._log.debug(u'got lyrics from {0}',
|
||||
item['displayLink'])
|
||||
return lyrics
|
||||
|
||||
|
||||
class LyricsPlugin(plugins.BeetsPlugin):
|
||||
SOURCES = ['google', 'lyricwiki', 'lyrics.com', 'musixmatch']
|
||||
SOURCE_BACKENDS = {
|
||||
'google': Google,
|
||||
'lyricwiki': LyricsWiki,
|
||||
'lyrics.com': LyricsCom,
|
||||
'musixmatch': MusiXmatch,
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super(LyricsPlugin, self).__init__()
|
||||
self.import_stages = [self.imported]
|
||||
|
|
@ -449,18 +455,18 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
'google_engine_ID': u'009217259823014548361:lndtuqkycfu',
|
||||
'fallback': None,
|
||||
'force': False,
|
||||
'sources': SOURCES,
|
||||
'sources': self.SOURCES,
|
||||
})
|
||||
|
||||
available_sources = list(SOURCES)
|
||||
available_sources = list(self.SOURCES)
|
||||
if not self.config['google_API_key'].get() and \
|
||||
'google' in SOURCES:
|
||||
'google' in self.SOURCES:
|
||||
available_sources.remove('google')
|
||||
self.config['sources'] = plugins.sanitize_choices(
|
||||
self.config['sources'].as_str_seq(), available_sources)
|
||||
self.backends = []
|
||||
for key in self.config['sources'].as_str_seq():
|
||||
self.backends.append(SOURCE_BACKENDS[key])
|
||||
self.backends.append(self.SOURCE_BACKENDS[key](self._log))
|
||||
|
||||
def commands(self):
|
||||
cmd = ui.Subcommand('lyrics', help='fetch song lyrics')
|
||||
|
|
@ -477,7 +483,7 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
write = config['import']['write'].get(bool)
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
self.fetch_item_lyrics(
|
||||
lib, logging.INFO, item, write,
|
||||
lib, item, write,
|
||||
opts.force_refetch or self.config['force'],
|
||||
)
|
||||
if opts.printlyr and item.lyrics:
|
||||
|
|
@ -491,19 +497,16 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
"""
|
||||
if self.config['auto']:
|
||||
for item in task.imported_items():
|
||||
self.fetch_item_lyrics(session.lib, logging.DEBUG, item,
|
||||
self.fetch_item_lyrics(session.lib, item,
|
||||
False, self.config['force'])
|
||||
|
||||
def fetch_item_lyrics(self, lib, loglevel, item, write, force):
|
||||
def fetch_item_lyrics(self, lib, item, write, force):
|
||||
"""Fetch and store lyrics for a single item. If ``write``, then the
|
||||
lyrics will also be written to the file itself. The ``loglevel``
|
||||
parameter controls the visibility of the function's status log
|
||||
messages.
|
||||
"""
|
||||
lyrics will also be written to the file itself."""
|
||||
# Skip if the item already has lyrics.
|
||||
if not force and item.lyrics:
|
||||
log.log(loglevel, u'lyrics already present: {0} - {1}'
|
||||
.format(item.artist, item.title))
|
||||
self._log.info(u'lyrics already present: {0.artist} - {0.title}',
|
||||
item)
|
||||
return
|
||||
|
||||
lyrics = None
|
||||
|
|
@ -515,11 +518,9 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
lyrics = u"\n\n---\n\n".join([l for l in lyrics if l])
|
||||
|
||||
if lyrics:
|
||||
log.log(loglevel, u'fetched lyrics: {0} - {1}'
|
||||
.format(item.artist, item.title))
|
||||
self._log.info(u'fetched lyrics: {0.artist} - {0.title}', item)
|
||||
else:
|
||||
log.log(loglevel, u'lyrics not found: {0} - {1}'
|
||||
.format(item.artist, item.title))
|
||||
self._log.info(u'lyrics not found: {0.artist} - {0.title}', item)
|
||||
fallback = self.config['fallback'].get()
|
||||
if fallback:
|
||||
lyrics = fallback
|
||||
|
|
@ -537,8 +538,8 @@ class LyricsPlugin(plugins.BeetsPlugin):
|
|||
None if no lyrics were found.
|
||||
"""
|
||||
for backend in self.backends:
|
||||
lyrics = backend(artist, title)
|
||||
lyrics = backend.fetch(artist, title)
|
||||
if lyrics:
|
||||
log.debug(u'got lyrics from backend: {0}'
|
||||
.format(backend.__name__))
|
||||
self._log.debug(u'got lyrics from backend: {0}',
|
||||
backend.__class__.__name__)
|
||||
return _scrape_strip_cruft(lyrics, True)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@
|
|||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
from __future__ import print_function
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand
|
||||
from beets import ui
|
||||
|
|
@ -21,13 +19,10 @@ from beets import config
|
|||
import musicbrainzngs
|
||||
|
||||
import re
|
||||
import logging
|
||||
|
||||
SUBMISSION_CHUNK_SIZE = 200
|
||||
UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$'
|
||||
|
||||
log = logging.getLogger('beets.bpd')
|
||||
|
||||
|
||||
def mb_call(func, *args, **kwargs):
|
||||
"""Call a MusicBrainz API function and catch exceptions.
|
||||
|
|
@ -54,48 +49,6 @@ def submit_albums(collection_id, release_ids):
|
|||
)
|
||||
|
||||
|
||||
def update_album_list(album_list):
|
||||
"""Update the MusicBrainz colleciton from a list of Beets albums
|
||||
"""
|
||||
# Get the available collections.
|
||||
collections = mb_call(musicbrainzngs.get_collections)
|
||||
if not collections['collection-list']:
|
||||
raise ui.UserError('no collections exist for user')
|
||||
|
||||
# Get the first release collection. MusicBrainz also has event
|
||||
# collections, so we need to avoid adding to those.
|
||||
for collection in collections['collection-list']:
|
||||
if 'release-count' in collection:
|
||||
collection_id = collection['id']
|
||||
break
|
||||
else:
|
||||
raise ui.UserError('No collection found.')
|
||||
|
||||
# Get a list of all the album IDs.
|
||||
album_ids = []
|
||||
for album in album_list:
|
||||
aid = album.mb_albumid
|
||||
if aid:
|
||||
if re.match(UUID_REGEX, aid):
|
||||
album_ids.append(aid)
|
||||
else:
|
||||
log.info(u'skipping invalid MBID: {0}'.format(aid))
|
||||
|
||||
# Submit to MusicBrainz.
|
||||
print('Updating MusicBrainz collection {0}...'.format(collection_id))
|
||||
submit_albums(collection_id, album_ids)
|
||||
print('...MusicBrainz collection updated.')
|
||||
|
||||
|
||||
def update_collection(lib, opts, args):
|
||||
update_album_list(lib.albums())
|
||||
|
||||
|
||||
update_mb_collection_cmd = Subcommand('mbupdate',
|
||||
help='Update MusicBrainz collection')
|
||||
update_mb_collection_cmd.func = update_collection
|
||||
|
||||
|
||||
class MusicBrainzCollectionPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(MusicBrainzCollectionPlugin, self).__init__()
|
||||
|
|
@ -108,10 +61,47 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
|
|||
self.import_stages = [self.imported]
|
||||
|
||||
def commands(self):
|
||||
return [update_mb_collection_cmd]
|
||||
mbupdate = Subcommand('mbupdate', help='Update MusicBrainz collection')
|
||||
mbupdate.func = self.update_collection
|
||||
return [mbupdate]
|
||||
|
||||
def update_collection(self, lib, opts, args):
|
||||
self.update_album_list(lib.albums())
|
||||
|
||||
def imported(self, session, task):
|
||||
"""Add each imported album to the collection.
|
||||
"""
|
||||
if task.is_album:
|
||||
update_album_list([task.album])
|
||||
self.update_album_list([task.album])
|
||||
|
||||
def update_album_list(self, album_list):
|
||||
"""Update the MusicBrainz colleciton from a list of Beets albums
|
||||
"""
|
||||
# Get the available collections.
|
||||
collections = mb_call(musicbrainzngs.get_collections)
|
||||
if not collections['collection-list']:
|
||||
raise ui.UserError('no collections exist for user')
|
||||
|
||||
# Get the first release collection. MusicBrainz also has event
|
||||
# collections, so we need to avoid adding to those.
|
||||
for collection in collections['collection-list']:
|
||||
if 'release-count' in collection:
|
||||
collection_id = collection['id']
|
||||
break
|
||||
else:
|
||||
raise ui.UserError('No collection found.')
|
||||
|
||||
# Get a list of all the album IDs.
|
||||
album_ids = []
|
||||
for album in album_list:
|
||||
aid = album.mb_albumid
|
||||
if aid:
|
||||
if re.match(UUID_REGEX, aid):
|
||||
album_ids.append(aid)
|
||||
else:
|
||||
self._log.info(u'skipping invalid MBID: {0}', aid)
|
||||
|
||||
# Submit to MusicBrainz.
|
||||
self._log.info('Updating MusicBrainz collection {0}...', collection_id)
|
||||
submit_albums(collection_id, album_ids)
|
||||
self._log.info('...MusicBrainz collection updated.')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Jakob Schnitzer.
|
||||
# Copyright 2015, Jakob Schnitzer.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,104 +14,12 @@
|
|||
|
||||
"""Update library's tags using MusicBrainz.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import autotag, library, ui, util
|
||||
from beets.autotag import hooks
|
||||
from beets import config
|
||||
from collections import defaultdict
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def mbsync_singletons(lib, query, move, pretend, write):
|
||||
"""Retrieve and apply info from the autotagger for items matched by
|
||||
query.
|
||||
"""
|
||||
for item in lib.items(query + ['singleton:true']):
|
||||
if not item.mb_trackid:
|
||||
log.info(u'Skipping singleton {0}: has no mb_trackid'
|
||||
.format(item.title))
|
||||
continue
|
||||
|
||||
# Get the MusicBrainz recording info.
|
||||
track_info = hooks.track_for_mbid(item.mb_trackid)
|
||||
if not track_info:
|
||||
log.info(u'Recording ID not found: {0}'.format(item.mb_trackid))
|
||||
continue
|
||||
|
||||
# Apply.
|
||||
with lib.transaction():
|
||||
autotag.apply_item_metadata(item, track_info)
|
||||
apply_item_changes(lib, item, move, pretend, write)
|
||||
|
||||
|
||||
def mbsync_albums(lib, query, move, pretend, write):
|
||||
"""Retrieve and apply info from the autotagger for albums matched by
|
||||
query and their items.
|
||||
"""
|
||||
# Process matching albums.
|
||||
for a in lib.albums(query):
|
||||
if not a.mb_albumid:
|
||||
log.info(u'Skipping album {0}: has no mb_albumid'.format(a.id))
|
||||
continue
|
||||
|
||||
items = list(a.items())
|
||||
|
||||
# Get the MusicBrainz album information.
|
||||
album_info = hooks.album_for_mbid(a.mb_albumid)
|
||||
if not album_info:
|
||||
log.info(u'Release ID not found: {0}'.format(a.mb_albumid))
|
||||
continue
|
||||
|
||||
# Map recording MBIDs to their information. Recordings can appear
|
||||
# multiple times on a release, so each MBID maps to a list of TrackInfo
|
||||
# objects.
|
||||
track_index = defaultdict(list)
|
||||
for track_info in album_info.tracks:
|
||||
track_index[track_info.track_id].append(track_info)
|
||||
|
||||
# Construct a track mapping according to MBIDs. This should work
|
||||
# for albums that have missing or extra tracks. If there are multiple
|
||||
# copies of a recording, they are disambiguated using their disc and
|
||||
# track number.
|
||||
mapping = {}
|
||||
for item in items:
|
||||
candidates = track_index[item.mb_trackid]
|
||||
if len(candidates) == 1:
|
||||
mapping[item] = candidates[0]
|
||||
else:
|
||||
for c in candidates:
|
||||
if c.medium_index == item.track and c.medium == item.disc:
|
||||
mapping[item] = c
|
||||
break
|
||||
|
||||
# Apply.
|
||||
with lib.transaction():
|
||||
autotag.apply_metadata(album_info, mapping)
|
||||
changed = False
|
||||
for item in items:
|
||||
item_changed = ui.show_model_changes(item)
|
||||
changed |= item_changed
|
||||
if item_changed:
|
||||
apply_item_changes(lib, item, move, pretend, write)
|
||||
|
||||
if not changed:
|
||||
# No change to any item.
|
||||
continue
|
||||
|
||||
if not pretend:
|
||||
# Update album structure to reflect an item in it.
|
||||
for key in library.Album.item_keys:
|
||||
a[key] = items[0][key]
|
||||
a.store()
|
||||
|
||||
# Move album art (and any inconsistent items).
|
||||
if move and lib.directory in util.ancestry(items[0].path):
|
||||
log.debug(u'moving album {0}'.format(a.id))
|
||||
a.move()
|
||||
|
||||
|
||||
def apply_item_changes(lib, item, move, pretend, write):
|
||||
"""Store, move and write the item according to the arguments.
|
||||
|
|
@ -126,18 +34,6 @@ def apply_item_changes(lib, item, move, pretend, write):
|
|||
item.store()
|
||||
|
||||
|
||||
def mbsync_func(lib, opts, args):
|
||||
"""Command handler for the mbsync function.
|
||||
"""
|
||||
move = opts.move
|
||||
pretend = opts.pretend
|
||||
write = opts.write
|
||||
query = ui.decargs(args)
|
||||
|
||||
mbsync_singletons(lib, query, move, pretend, write)
|
||||
mbsync_albums(lib, query, move, pretend, write)
|
||||
|
||||
|
||||
class MBSyncPlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(MBSyncPlugin, self).__init__()
|
||||
|
|
@ -153,5 +49,103 @@ class MBSyncPlugin(BeetsPlugin):
|
|||
cmd.parser.add_option('-W', '--nowrite', action='store_false',
|
||||
default=config['import']['write'], dest='write',
|
||||
help="don't write updated metadata to files")
|
||||
cmd.func = mbsync_func
|
||||
cmd.func = self.func
|
||||
return [cmd]
|
||||
|
||||
def func(self, lib, opts, args):
|
||||
"""Command handler for the mbsync function.
|
||||
"""
|
||||
move = opts.move
|
||||
pretend = opts.pretend
|
||||
write = opts.write
|
||||
query = ui.decargs(args)
|
||||
|
||||
self.singletons(lib, query, move, pretend, write)
|
||||
self.albums(lib, query, move, pretend, write)
|
||||
|
||||
def singletons(self, lib, query, move, pretend, write):
|
||||
"""Retrieve and apply info from the autotagger for items matched by
|
||||
query.
|
||||
"""
|
||||
for item in lib.items(query + ['singleton:true']):
|
||||
if not item.mb_trackid:
|
||||
self._log.info(u'Skipping singleton {0}: has no mb_trackid',
|
||||
item.title)
|
||||
continue
|
||||
|
||||
# Get the MusicBrainz recording info.
|
||||
track_info = hooks.track_for_mbid(item.mb_trackid)
|
||||
if not track_info:
|
||||
self._log.info(u'Recording ID not found: {0}', item.mb_trackid)
|
||||
continue
|
||||
|
||||
# Apply.
|
||||
with lib.transaction():
|
||||
autotag.apply_item_metadata(item, track_info)
|
||||
apply_item_changes(lib, item, move, pretend, write)
|
||||
|
||||
def albums(self, lib, query, move, pretend, write):
|
||||
"""Retrieve and apply info from the autotagger for albums matched by
|
||||
query and their items.
|
||||
"""
|
||||
# Process matching albums.
|
||||
for a in lib.albums(query):
|
||||
if not a.mb_albumid:
|
||||
self._log.info(u'Skipping album {0}: has no mb_albumid', a.id)
|
||||
continue
|
||||
|
||||
items = list(a.items())
|
||||
|
||||
# Get the MusicBrainz album information.
|
||||
album_info = hooks.album_for_mbid(a.mb_albumid)
|
||||
if not album_info:
|
||||
self._log.info(u'Release ID not found: {0}', a.mb_albumid)
|
||||
continue
|
||||
|
||||
# Map recording MBIDs to their information. Recordings can appear
|
||||
# multiple times on a release, so each MBID maps to a list of
|
||||
# TrackInfo objects.
|
||||
track_index = defaultdict(list)
|
||||
for track_info in album_info.tracks:
|
||||
track_index[track_info.track_id].append(track_info)
|
||||
|
||||
# Construct a track mapping according to MBIDs. This should work
|
||||
# for albums that have missing or extra tracks. If there are
|
||||
# multiple copies of a recording, they are disambiguated using
|
||||
# their disc and track number.
|
||||
mapping = {}
|
||||
for item in items:
|
||||
candidates = track_index[item.mb_trackid]
|
||||
if len(candidates) == 1:
|
||||
mapping[item] = candidates[0]
|
||||
else:
|
||||
for c in candidates:
|
||||
if (c.medium_index == item.track and
|
||||
c.medium == item.disc):
|
||||
mapping[item] = c
|
||||
break
|
||||
|
||||
# Apply.
|
||||
with lib.transaction():
|
||||
autotag.apply_metadata(album_info, mapping)
|
||||
changed = False
|
||||
for item in items:
|
||||
item_changed = ui.show_model_changes(item)
|
||||
changed |= item_changed
|
||||
if item_changed:
|
||||
apply_item_changes(lib, item, move, pretend, write)
|
||||
|
||||
if not changed:
|
||||
# No change to any item.
|
||||
continue
|
||||
|
||||
if not pretend:
|
||||
# Update album structure to reflect an item in it.
|
||||
for key in library.Album.item_keys:
|
||||
a[key] = items[0][key]
|
||||
a.store()
|
||||
|
||||
# Move album art (and any inconsistent items).
|
||||
if move and lib.directory in util.ancestry(items[0].path):
|
||||
self._log.debug(u'moving album {0}', a.id)
|
||||
a.move()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Pedro Silva.
|
||||
# Copyright 2015, Pedro Silva.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,16 +14,11 @@
|
|||
|
||||
"""List missing tracks.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from beets.autotag import hooks
|
||||
from beets.library import Item
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs, print_obj, Subcommand
|
||||
|
||||
PLUGIN = 'missing'
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def _missing_count(album):
|
||||
"""Return number of missing items in `album`.
|
||||
|
|
@ -31,25 +26,6 @@ def _missing_count(album):
|
|||
return (album.tracktotal or 0) - len(album.items())
|
||||
|
||||
|
||||
def _missing(album):
|
||||
"""Query MusicBrainz to determine items missing from `album`.
|
||||
"""
|
||||
item_mbids = map(lambda x: x.mb_trackid, album.items())
|
||||
|
||||
if len([i for i in album.items()]) < album.tracktotal:
|
||||
# fetch missing items
|
||||
# TODO: Implement caching that without breaking other stuff
|
||||
album_info = hooks.album_for_mbid(album.mb_albumid)
|
||||
for track_info in getattr(album_info, 'tracks', []):
|
||||
if track_info.track_id not in item_mbids:
|
||||
item = _item(track_info, album_info, album.id)
|
||||
log.debug(u'{0}: track {1} in album {2}'
|
||||
.format(PLUGIN,
|
||||
track_info.track_id,
|
||||
album_info.album_id))
|
||||
yield item
|
||||
|
||||
|
||||
def _item(track_info, album_info, album_id):
|
||||
"""Build and return `item` from `track_info` and `album info`
|
||||
objects. `item` is missing what fields cannot be obtained from
|
||||
|
|
@ -152,8 +128,24 @@ class MissingPlugin(BeetsPlugin):
|
|||
print_obj(album, lib, fmt=fmt)
|
||||
|
||||
else:
|
||||
for item in _missing(album):
|
||||
for item in self._missing(album):
|
||||
print_obj(item, lib, fmt=fmt)
|
||||
|
||||
self._command.func = _miss
|
||||
return [self._command]
|
||||
|
||||
def _missing(self, album):
|
||||
"""Query MusicBrainz to determine items missing from `album`.
|
||||
"""
|
||||
item_mbids = map(lambda x: x.mb_trackid, album.items())
|
||||
|
||||
if len([i for i in album.items()]) < album.tracktotal:
|
||||
# fetch missing items
|
||||
# TODO: Implement caching that without breaking other stuff
|
||||
album_info = hooks.album_for_mbid(album.mb_albumid)
|
||||
for track_info in getattr(album_info, 'tracks', []):
|
||||
if track_info.track_id not in item_mbids:
|
||||
item = _item(track_info, album_info, album.id)
|
||||
self._log.debug(u'track {1} in album {2}',
|
||||
track_info.track_id, album_info.album_id)
|
||||
yield item
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# coding=utf-8
|
||||
# This file is part of beets.
|
||||
# Copyright 2013, Peter Schnebel and Johann Klähn.
|
||||
# Copyright 2015, Peter Schnebel and Johann Klähn.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
import logging
|
||||
import mpd
|
||||
import socket
|
||||
import select
|
||||
|
|
@ -27,8 +26,6 @@ from beets import library
|
|||
from beets.util import displayable_path
|
||||
from beets.dbcore import types
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# If we lose the connection, how many times do we want to retry and how
|
||||
# much time should we wait between retries?
|
||||
RETRIES = 10
|
||||
|
|
@ -56,9 +53,11 @@ class MPDClient(mpd.MPDClient):
|
|||
|
||||
|
||||
class MPDClientWrapper(object):
|
||||
def __init__(self):
|
||||
def __init__(self, log):
|
||||
self._log = log
|
||||
|
||||
self.music_directory = (
|
||||
config['mpdstats']['music_directory'].get(unicode))
|
||||
self.config['music_directory'].get(unicode))
|
||||
|
||||
self.client = MPDClient()
|
||||
|
||||
|
|
@ -71,7 +70,7 @@ class MPDClientWrapper(object):
|
|||
if host[0] in ['/', '~']:
|
||||
host = os.path.expanduser(host)
|
||||
|
||||
log.info(u'mpdstats: connecting to {0}:{1}'.format(host, port))
|
||||
self._log.info(u'connecting to {0}:{1}', host, port)
|
||||
try:
|
||||
self.client.connect(host, port)
|
||||
except socket.error as e:
|
||||
|
|
@ -99,7 +98,7 @@ class MPDClientWrapper(object):
|
|||
try:
|
||||
return getattr(self.client, command)()
|
||||
except (select.error, mpd.ConnectionError) as err:
|
||||
log.error(u'mpdstats: {0}'.format(err))
|
||||
self._log.error(u'{0}', err)
|
||||
|
||||
if retries <= 0:
|
||||
# if we exited without breaking, we couldn't reconnect in time :(
|
||||
|
|
@ -141,15 +140,16 @@ class MPDClientWrapper(object):
|
|||
|
||||
|
||||
class MPDStats(object):
|
||||
def __init__(self, lib):
|
||||
def __init__(self, lib, log):
|
||||
self.lib = lib
|
||||
self._log = log
|
||||
|
||||
self.do_rating = config['mpdstats']['rating'].get(bool)
|
||||
self.rating_mix = config['mpdstats']['rating_mix'].get(float)
|
||||
self.do_rating = self.config['rating'].get(bool)
|
||||
self.rating_mix = self.config['rating_mix'].get(float)
|
||||
self.time_threshold = 10.0 # TODO: maybe add config option?
|
||||
|
||||
self.now_playing = None
|
||||
self.mpd = MPDClientWrapper()
|
||||
self.mpd = MPDClientWrapper(log)
|
||||
|
||||
def rating(self, play_count, skip_count, rating, skipped):
|
||||
"""Calculate a new rating for a song based on play count, skip count,
|
||||
|
|
@ -171,12 +171,9 @@ class MPDStats(object):
|
|||
if item:
|
||||
return item
|
||||
else:
|
||||
log.info(u'mpdstats: item not found: {0}'.format(
|
||||
displayable_path(path)
|
||||
))
|
||||
self._log.info(u'item not found: {0}', displayable_path(path))
|
||||
|
||||
@staticmethod
|
||||
def update_item(item, attribute, value=None, increment=None):
|
||||
def update_item(self, item, attribute, value=None, increment=None):
|
||||
"""Update the beets item. Set attribute to value or increment the value
|
||||
of attribute. If the increment argument is used the value is cast to
|
||||
the corresponding type.
|
||||
|
|
@ -192,11 +189,10 @@ class MPDStats(object):
|
|||
item[attribute] = value
|
||||
item.store()
|
||||
|
||||
log.debug(u'mpdstats: updated: {0} = {1} [{2}]'.format(
|
||||
attribute,
|
||||
item[attribute],
|
||||
displayable_path(item.path),
|
||||
))
|
||||
self._log.debug(u'updated: {0} = {1} [{2}]',
|
||||
attribute,
|
||||
item[attribute],
|
||||
displayable_path(item.path))
|
||||
|
||||
def update_rating(self, item, skipped):
|
||||
"""Update the rating for a beets item.
|
||||
|
|
@ -215,6 +211,8 @@ class MPDStats(object):
|
|||
To this end the difference between the song's supposed end time
|
||||
and the current time is calculated. If it's greater than a threshold,
|
||||
the song is considered skipped.
|
||||
|
||||
Returns whether the change was manual (skipped previous song or not)
|
||||
"""
|
||||
diff = abs(song['remaining'] - (time.time() - song['started']))
|
||||
|
||||
|
|
@ -228,24 +226,22 @@ class MPDStats(object):
|
|||
if self.do_rating:
|
||||
self.update_rating(song['beets_item'], skipped)
|
||||
|
||||
return skipped
|
||||
|
||||
def handle_played(self, song):
|
||||
"""Updates the play count of a song.
|
||||
"""
|
||||
self.update_item(song['beets_item'], 'play_count', increment=1)
|
||||
log.info(u'mpdstats: played {0}'.format(
|
||||
displayable_path(song['path'])
|
||||
))
|
||||
self._log.info(u'played {0}', displayable_path(song['path']))
|
||||
|
||||
def handle_skipped(self, song):
|
||||
"""Updates the skip count of a song.
|
||||
"""
|
||||
self.update_item(song['beets_item'], 'skip_count', increment=1)
|
||||
log.info(u'mpdstats: skipped {0}'.format(
|
||||
displayable_path(song['path'])
|
||||
))
|
||||
self._log.info(u'skipped {0}', displayable_path(song['path']))
|
||||
|
||||
def on_stop(self, status):
|
||||
log.info(u'mpdstats: stop')
|
||||
self._log.info(u'stop')
|
||||
|
||||
if self.now_playing:
|
||||
self.handle_song_change(self.now_playing)
|
||||
|
|
@ -253,7 +249,7 @@ class MPDStats(object):
|
|||
self.now_playing = None
|
||||
|
||||
def on_pause(self, status):
|
||||
log.info(u'mpdstats: pause')
|
||||
self._log.info(u'pause')
|
||||
self.now_playing = None
|
||||
|
||||
def on_play(self, status):
|
||||
|
|
@ -264,30 +260,31 @@ class MPDStats(object):
|
|||
return
|
||||
|
||||
if is_url(path):
|
||||
log.info(u'mpdstats: playing stream {0}'.format(
|
||||
displayable_path(path)
|
||||
))
|
||||
self._log.info(u'playing stream {0}', displayable_path(path))
|
||||
return
|
||||
|
||||
played, duration = map(int, status['time'].split(':', 1))
|
||||
remaining = duration - played
|
||||
|
||||
if self.now_playing and self.now_playing['path'] != path:
|
||||
self.handle_song_change(self.now_playing)
|
||||
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
|
||||
|
||||
log.info(u'mpdstats: playing {0}'.format(
|
||||
displayable_path(path)
|
||||
))
|
||||
if not going_to_happen_twice:
|
||||
self._log.info(u'playing {0}', displayable_path(path))
|
||||
|
||||
self.now_playing = {
|
||||
'started': time.time(),
|
||||
'remaining': remaining,
|
||||
'path': path,
|
||||
'beets_item': self.get_item(path),
|
||||
}
|
||||
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()))
|
||||
self.update_item(self.now_playing['beets_item'],
|
||||
'last_played', value=int(time.time()))
|
||||
|
||||
def run(self):
|
||||
self.mpd.connect()
|
||||
|
|
@ -302,8 +299,7 @@ class MPDStats(object):
|
|||
if handler:
|
||||
handler(status)
|
||||
else:
|
||||
log.debug(u'mpdstats: unhandled status "{0}"'.
|
||||
format(status))
|
||||
self._log.debug(u'unhandled status "{0}"', status)
|
||||
|
||||
events = self.mpd.events()
|
||||
|
||||
|
|
@ -356,7 +352,7 @@ class MPDStatsPlugin(plugins.BeetsPlugin):
|
|||
config['mpd']['password'] = opts.password.decode('utf8')
|
||||
|
||||
try:
|
||||
MPDStats(lib).run()
|
||||
MPDStats(lib, self._log).run()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -20,17 +20,11 @@ Put something like the following in your config.yaml to configure:
|
|||
port: 6600
|
||||
password: seekrit
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
import os
|
||||
import socket
|
||||
from beets import config
|
||||
|
||||
# Global variable so that mpdupdate can detect database changes and run only
|
||||
# once before beets exits.
|
||||
database_changed = False
|
||||
|
||||
|
||||
# No need to introduce a dependency on an MPD library for such a
|
||||
# simple use case. Here's a simple socket abstraction to make things
|
||||
|
|
@ -66,37 +60,6 @@ class BufferedSocket(object):
|
|||
self.sock.close()
|
||||
|
||||
|
||||
def update_mpd(host='localhost', port=6600, password=None):
|
||||
"""Sends the "update" command to the MPD server indicated,
|
||||
possibly authenticating with a password first.
|
||||
"""
|
||||
print('Updating MPD database...')
|
||||
|
||||
s = BufferedSocket(host, port)
|
||||
resp = s.readline()
|
||||
if 'OK MPD' not in resp:
|
||||
print('MPD connection failed:', repr(resp))
|
||||
return
|
||||
|
||||
if password:
|
||||
s.send('password "%s"\n' % password)
|
||||
resp = s.readline()
|
||||
if 'OK' not in resp:
|
||||
print('Authentication failed:', repr(resp))
|
||||
s.send('close\n')
|
||||
s.close()
|
||||
return
|
||||
|
||||
s.send('update\n')
|
||||
resp = s.readline()
|
||||
if 'updating_db' not in resp:
|
||||
print('Update failed:', repr(resp))
|
||||
|
||||
s.send('close\n')
|
||||
s.close()
|
||||
print('... updated.')
|
||||
|
||||
|
||||
class MPDUpdatePlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(MPDUpdatePlugin, self).__init__()
|
||||
|
|
@ -112,18 +75,44 @@ class MPDUpdatePlugin(BeetsPlugin):
|
|||
if self.config[key].exists():
|
||||
config['mpd'][key] = self.config[key].get()
|
||||
|
||||
self.register_listener('database_change', self.db_change)
|
||||
|
||||
@MPDUpdatePlugin.listen('database_change')
|
||||
def handle_change(lib=None):
|
||||
global database_changed
|
||||
database_changed = True
|
||||
def db_change(self, lib):
|
||||
self.register_listener('cli_exit', self.update)
|
||||
|
||||
|
||||
@MPDUpdatePlugin.listen('cli_exit')
|
||||
def update(lib=None):
|
||||
if database_changed:
|
||||
update_mpd(
|
||||
def update(self, lib):
|
||||
self.update_mpd(
|
||||
config['mpd']['host'].get(unicode),
|
||||
config['mpd']['port'].get(int),
|
||||
config['mpd']['password'].get(unicode),
|
||||
)
|
||||
|
||||
def update_mpd(self, host='localhost', port=6600, password=None):
|
||||
"""Sends the "update" command to the MPD server indicated,
|
||||
possibly authenticating with a password first.
|
||||
"""
|
||||
self._log.info('Updating MPD database...')
|
||||
|
||||
s = BufferedSocket(host, port)
|
||||
resp = s.readline()
|
||||
if 'OK MPD' not in resp:
|
||||
self._log.warning(u'MPD connection failed: {0!r}', resp)
|
||||
return
|
||||
|
||||
if password:
|
||||
s.send('password "%s"\n' % password)
|
||||
resp = s.readline()
|
||||
if 'OK' not in resp:
|
||||
self._log.warning(u'Authentication failed: {0!r}', resp)
|
||||
s.send('close\n')
|
||||
s.close()
|
||||
return
|
||||
|
||||
s.send('update\n')
|
||||
resp = s.readline()
|
||||
if 'updating_db' not in resp:
|
||||
self._log.warning(u'Update failed: {0!r}', resp)
|
||||
|
||||
s.send('close\n')
|
||||
s.close()
|
||||
self._log.info('Database updated.')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, David Hamp-Gonsalves
|
||||
# Copyright 2015, David Hamp-Gonsalves
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
|
||||
"""Send the results of a query to the configured music player as a playlist.
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import Subcommand
|
||||
from beets import config
|
||||
|
|
@ -21,14 +23,11 @@ from beets import ui
|
|||
from beets import util
|
||||
from os.path import relpath
|
||||
import platform
|
||||
import logging
|
||||
import shlex
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def play_music(lib, opts, args):
|
||||
def play_music(lib, opts, args, log):
|
||||
"""Execute query, create temporary playlist and execute player
|
||||
command passing that playlist.
|
||||
"""
|
||||
|
|
@ -101,12 +100,11 @@ def play_music(lib, opts, args):
|
|||
# Invoke the command and log the output.
|
||||
output = util.command_output(command)
|
||||
if output:
|
||||
log.debug(u'Output of {0}: {1}'.format(
|
||||
util.displayable_path(command[0]),
|
||||
output.decode('utf8', 'ignore'),
|
||||
))
|
||||
log.debug(u'Output of {0}: {1}',
|
||||
util.displayable_path(command[0]),
|
||||
output.decode('utf8', 'ignore'))
|
||||
else:
|
||||
log.debug(u'play: no output')
|
||||
log.debug(u'no output')
|
||||
|
||||
ui.print_(u'Playing {0} {1}.'.format(len(selection), item_type))
|
||||
|
||||
|
|
@ -134,5 +132,5 @@ class PlayPlugin(BeetsPlugin):
|
|||
action='store_true', default=False,
|
||||
help='query and load albums rather than tracks'
|
||||
)
|
||||
play_command.func = play_music
|
||||
play_command.func = partial(play_music, log=self._log)
|
||||
return [play_command]
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ from beets import config
|
|||
from beets.plugins import BeetsPlugin
|
||||
|
||||
|
||||
# Global variable to detect if database is changed that the update
|
||||
# is only run once before beets exists.
|
||||
database_changed = False
|
||||
|
||||
|
||||
def get_music_section(host, port):
|
||||
"""Getting the section key for the music library in Plex.
|
||||
"""
|
||||
|
|
@ -55,30 +50,23 @@ class PlexUpdate(BeetsPlugin):
|
|||
u'host': u'localhost',
|
||||
u'port': 32400})
|
||||
|
||||
self.register_listener('database_change', self.listen_for_db_change)
|
||||
|
||||
@PlexUpdate.listen('database_change')
|
||||
def listen_for_db_change(lib=None):
|
||||
"""Listens for beets db change and set global database_changed
|
||||
variable to True.
|
||||
"""
|
||||
global database_changed
|
||||
database_changed = True
|
||||
def listen_for_db_change(self, lib):
|
||||
"""Listens for beets db change and register the update for the end"""
|
||||
self.register_listener('cli_exit', self.update)
|
||||
|
||||
|
||||
@PlexUpdate.listen('cli_exit')
|
||||
def update(lib=None):
|
||||
"""When the client exists and the database_changed variable is True
|
||||
trying to send refresh request to Plex server.
|
||||
"""
|
||||
if database_changed:
|
||||
print('Updating Plex library...')
|
||||
def update(self, lib):
|
||||
"""When the client exists try to send refresh request to Plex server.
|
||||
"""
|
||||
self._log.info('Updating Plex library...')
|
||||
|
||||
# Try to send update request.
|
||||
try:
|
||||
update_plex(
|
||||
config['plex']['host'].get(),
|
||||
config['plex']['port'].get())
|
||||
print('... started.')
|
||||
self._log.info('... started.')
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
print('Update failed.')
|
||||
self._log.warning('Update failed.')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Philippe Mongeau.
|
||||
# Copyright 2015, Philippe Mongeau.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson.
|
||||
# Copyright 2015, Fabrice Laporte, Yevgeny Bezman, and Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import os
|
||||
import collections
|
||||
|
|
@ -20,13 +19,12 @@ import itertools
|
|||
import sys
|
||||
import warnings
|
||||
|
||||
from beets import logging
|
||||
from beets import ui
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.util import syspath, command_output, displayable_path
|
||||
from beets import config
|
||||
|
||||
log = logging.getLogger('beets.replaygain')
|
||||
|
||||
|
||||
# Utilities.
|
||||
|
||||
|
|
@ -67,10 +65,11 @@ AlbumGain = collections.namedtuple("AlbumGain", "album_gain track_gains")
|
|||
class Backend(object):
|
||||
"""An abstract class representing engine for calculating RG values.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
def __init__(self, config, log):
|
||||
"""Initialize the backend with the configuration view for the
|
||||
plugin.
|
||||
"""
|
||||
self._log = log
|
||||
|
||||
def compute_track_gain(self, items):
|
||||
raise NotImplementedError()
|
||||
|
|
@ -85,7 +84,8 @@ class Backend(object):
|
|||
|
||||
|
||||
class CommandBackend(Backend):
|
||||
def __init__(self, config):
|
||||
def __init__(self, config, log):
|
||||
super(CommandBackend, self).__init__(config, log)
|
||||
config.add({
|
||||
'command': u"",
|
||||
'noclip': True,
|
||||
|
|
@ -135,7 +135,7 @@ class CommandBackend(Backend):
|
|||
|
||||
supported_items = filter(self.format_supported, album.items())
|
||||
if len(supported_items) != len(album.items()):
|
||||
log.debug(u'replaygain: tracks are of unsupported format')
|
||||
self._log.debug(u'tracks are of unsupported format')
|
||||
return AlbumGain(None, [])
|
||||
|
||||
output = self.compute_gain(supported_items, True)
|
||||
|
|
@ -180,11 +180,10 @@ class CommandBackend(Backend):
|
|||
cmd = cmd + ['-d', str(self.gain_offset)]
|
||||
cmd = cmd + [syspath(i.path) for i in items]
|
||||
|
||||
log.debug(u'replaygain: analyzing {0} files'.format(len(items)))
|
||||
log.debug(u"replaygain: executing {0}"
|
||||
.format(" ".join(map(displayable_path, cmd))))
|
||||
self._log.debug(u'analyzing {0} files', len(items))
|
||||
self._log.debug(u"executing {0}", " ".join(map(displayable_path, cmd)))
|
||||
output = call(cmd)
|
||||
log.debug(u'replaygain: analysis finished')
|
||||
self._log.debug(u'analysis finished')
|
||||
results = self.parse_tool_output(output,
|
||||
len(items) + (1 if is_album else 0))
|
||||
|
||||
|
|
@ -199,7 +198,7 @@ class CommandBackend(Backend):
|
|||
for line in text.split('\n')[1:num_lines + 1]:
|
||||
parts = line.split('\t')
|
||||
if len(parts) != 6 or parts[0] == 'File':
|
||||
log.debug(u'replaygain: bad tool output: {0}'.format(text))
|
||||
self._log.debug(u'bad tool output: {0}', text)
|
||||
raise ReplayGainError('mp3gain failed')
|
||||
d = {
|
||||
'file': parts[0],
|
||||
|
|
@ -468,7 +467,8 @@ class AudioToolsBackend(Backend):
|
|||
<http://audiotools.sourceforge.net/>`_ and its capabilities to read more
|
||||
file formats and compute ReplayGain values using it replaygain module.
|
||||
"""
|
||||
def __init__(self, config):
|
||||
def __init__(self, config, log):
|
||||
super(CommandBackend, self).__init__(config, log)
|
||||
self._import_audiotools()
|
||||
|
||||
def _import_audiotools(self):
|
||||
|
|
@ -548,14 +548,8 @@ class AudioToolsBackend(Backend):
|
|||
# be obtained from an audiofile instance.
|
||||
rg_track_gain, rg_track_peak = rg.title_gain(audiofile.to_pcm())
|
||||
|
||||
log.debug(
|
||||
u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}'.format(
|
||||
item.artist,
|
||||
item.title,
|
||||
rg_track_gain,
|
||||
rg_track_peak
|
||||
)
|
||||
)
|
||||
self._log.debug(u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}',
|
||||
item.artist, item.title, rg_track_gain, rg_track_peak)
|
||||
return Gain(gain=rg_track_gain, peak=rg_track_peak)
|
||||
|
||||
def compute_album_gain(self, album):
|
||||
|
|
@ -563,12 +557,7 @@ class AudioToolsBackend(Backend):
|
|||
|
||||
:rtype: :class:`AlbumGain`
|
||||
"""
|
||||
log.debug(
|
||||
u'Analysing album {0} - {1}'.format(
|
||||
album.albumartist,
|
||||
album.album
|
||||
)
|
||||
)
|
||||
self._log.debug(u'Analysing album {0.albumartist} - {0.album}', album)
|
||||
|
||||
# The first item is taken and opened to get the sample rate to
|
||||
# initialize the replaygain object. The object is used for all the
|
||||
|
|
@ -584,26 +573,16 @@ class AudioToolsBackend(Backend):
|
|||
track_gains.append(
|
||||
Gain(gain=rg_track_gain, peak=rg_track_peak)
|
||||
)
|
||||
log.debug(
|
||||
u'ReplayGain for track {0} - {1}: {2:.2f}, {3:.2f}'.format(
|
||||
item.artist,
|
||||
item.title,
|
||||
rg_track_gain,
|
||||
rg_track_peak
|
||||
)
|
||||
)
|
||||
self._log.debug(u'ReplayGain for track {0.artist} - {0.title}: '
|
||||
u'{1:.2f}, {2:.2f}',
|
||||
item, rg_track_gain, rg_track_peak)
|
||||
|
||||
# After getting the values for all tracks, it's possible to get the
|
||||
# album values.
|
||||
rg_album_gain, rg_album_peak = rg.album_gain()
|
||||
log.debug(
|
||||
u'ReplayGain for Album {0} - {1}: {2:.2f}, {3:.2f}'.format(
|
||||
album.albumartist,
|
||||
album.album,
|
||||
rg_album_gain,
|
||||
rg_album_peak
|
||||
)
|
||||
)
|
||||
self._log.debug(u'ReplayGain for album {0.albumartist} - {0.album}: '
|
||||
u'{1:.2f}, {2:.2f}',
|
||||
album, rg_album_gain, rg_album_peak)
|
||||
|
||||
return AlbumGain(
|
||||
Gain(gain=rg_album_gain, peak=rg_album_peak),
|
||||
|
|
@ -649,7 +628,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
|
||||
try:
|
||||
self.backend_instance = self.backends[backend_name](
|
||||
self.config
|
||||
self.config, self._log
|
||||
)
|
||||
except (ReplayGainError, FatalReplayGainError) as e:
|
||||
raise ui.UserError(
|
||||
|
|
@ -674,19 +653,16 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
item.rg_track_peak = track_gain.peak
|
||||
item.store()
|
||||
|
||||
log.debug(u'replaygain: applied track gain {0}, peak {1}'.format(
|
||||
item.rg_track_gain,
|
||||
item.rg_track_peak
|
||||
))
|
||||
self._log.debug(u'applied track gain {0}, peak {1}',
|
||||
item.rg_track_gain, item.rg_track_peak)
|
||||
|
||||
def store_album_gain(self, album, album_gain):
|
||||
album.rg_album_gain = album_gain.gain
|
||||
album.rg_album_peak = album_gain.peak
|
||||
album.store()
|
||||
|
||||
log.debug(u'replaygain: applied album gain {0}, peak {1}'.format(
|
||||
album.rg_album_gain,
|
||||
album.rg_album_peak))
|
||||
self._log.debug(u'applied album gain {0}, peak {1}',
|
||||
album.rg_album_gain, album.rg_album_peak)
|
||||
|
||||
def handle_album(self, album, write):
|
||||
"""Compute album and track replay gain store it in all of the
|
||||
|
|
@ -697,12 +673,11 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
items, nothing is done.
|
||||
"""
|
||||
if not self.album_requires_gain(album):
|
||||
log.info(u'Skipping album {0} - {1}'.format(album.albumartist,
|
||||
album.album))
|
||||
self._log.info(u'Skipping album {0} - {1}',
|
||||
album.albumartist, album.album)
|
||||
return
|
||||
|
||||
log.info(u'analyzing {0} - {1}'.format(album.albumartist,
|
||||
album.album))
|
||||
self._log.info(u'analyzing {0} - {1}', album.albumartist, album.album)
|
||||
|
||||
try:
|
||||
album_gain = self.backend_instance.compute_album_gain(album)
|
||||
|
|
@ -721,7 +696,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
if write:
|
||||
item.try_write()
|
||||
except ReplayGainError as e:
|
||||
log.info(u"ReplayGain error: {0}".format(e))
|
||||
self._log.info(u"ReplayGain error: {0}", e)
|
||||
except FatalReplayGainError as e:
|
||||
raise ui.UserError(
|
||||
u"Fatal replay gain error: {0}".format(e)
|
||||
|
|
@ -735,12 +710,10 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
in the item, nothing is done.
|
||||
"""
|
||||
if not self.track_requires_gain(item):
|
||||
log.info(u'Skipping track {0} - {1}'
|
||||
.format(item.artist, item.title))
|
||||
self._log.info(u'Skipping track {0.artist} - {0.title}', item)
|
||||
return
|
||||
|
||||
log.info(u'analyzing {0} - {1}'
|
||||
.format(item.artist, item.title))
|
||||
self._log.info(u'analyzing {0} - {1}', item.artist, item.title)
|
||||
|
||||
try:
|
||||
track_gains = self.backend_instance.compute_track_gain([item])
|
||||
|
|
@ -755,7 +728,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
if write:
|
||||
item.try_write()
|
||||
except ReplayGainError as e:
|
||||
log.info(u"ReplayGain error: {0}".format(e))
|
||||
self._log.info(u"ReplayGain error: {0}", e)
|
||||
except FatalReplayGainError as e:
|
||||
raise ui.UserError(
|
||||
u"Fatal replay gain error: {0}".format(e)
|
||||
|
|
@ -767,7 +740,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
if not self.automatic:
|
||||
return
|
||||
|
||||
log.setLevel(logging.WARN)
|
||||
self._log.setLevel(logging.WARN)
|
||||
|
||||
if task.is_album:
|
||||
self.handle_album(task.album, False)
|
||||
|
|
@ -778,7 +751,7 @@ class ReplayGainPlugin(BeetsPlugin):
|
|||
"""Return the "replaygain" ui subcommand.
|
||||
"""
|
||||
def func(lib, opts, args):
|
||||
log.setLevel(logging.INFO)
|
||||
self._log.setLevel(logging.INFO)
|
||||
|
||||
write = config['import']['write'].get(bool)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -16,15 +16,12 @@
|
|||
formats.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
from beets import library
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
def rewriter(field, rules):
|
||||
"""Create a template field function that rewrites the given field
|
||||
|
|
@ -59,7 +56,7 @@ class RewritePlugin(BeetsPlugin):
|
|||
if fieldname not in library.Item._fields:
|
||||
raise ui.UserError("invalid field name (%s) in rewriter" %
|
||||
fieldname)
|
||||
log.debug(u'adding template field {0}'.format(key))
|
||||
self._log.debug(u'adding template field {0}', key)
|
||||
pattern = re.compile(pattern.lower())
|
||||
rules[fieldname].append((pattern, value))
|
||||
if fieldname == 'artist':
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
"""Cleans extraneous metadata from files' tags via a command or
|
||||
automatically whenever tags are written.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import ui
|
||||
|
|
@ -23,8 +22,6 @@ from beets import util
|
|||
from beets import config
|
||||
from beets import mediafile
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
_MUTAGEN_FORMATS = {
|
||||
'asf': 'ASF',
|
||||
'apev2': 'APEv2File',
|
||||
|
|
@ -54,6 +51,7 @@ class ScrubPlugin(BeetsPlugin):
|
|||
self.config.add({
|
||||
'auto': True,
|
||||
})
|
||||
self.register_listener("write", self.write_item)
|
||||
|
||||
def commands(self):
|
||||
def scrub_func(lib, opts, args):
|
||||
|
|
@ -64,8 +62,8 @@ class ScrubPlugin(BeetsPlugin):
|
|||
|
||||
# Walk through matching files and remove tags.
|
||||
for item in lib.items(ui.decargs(args)):
|
||||
log.info(u'scrubbing: {0}'.format(
|
||||
util.displayable_path(item.path)))
|
||||
self._log.info(u'scrubbing: {0}',
|
||||
util.displayable_path(item.path))
|
||||
|
||||
# Get album art if we need to restore it.
|
||||
if opts.write:
|
||||
|
|
@ -74,14 +72,14 @@ class ScrubPlugin(BeetsPlugin):
|
|||
art = mf.art
|
||||
|
||||
# Remove all tags.
|
||||
_scrub(item.path)
|
||||
self._scrub(item.path)
|
||||
|
||||
# Restore tags, if enabled.
|
||||
if opts.write:
|
||||
log.debug(u'writing new tags after scrub')
|
||||
self._log.debug(u'writing new tags after scrub')
|
||||
item.try_write()
|
||||
if art:
|
||||
log.info(u'restoring art')
|
||||
self._log.info(u'restoring art')
|
||||
mf = mediafile.MediaFile(item.path)
|
||||
mf.art = art
|
||||
mf.save()
|
||||
|
|
@ -96,50 +94,46 @@ class ScrubPlugin(BeetsPlugin):
|
|||
|
||||
return [scrub_cmd]
|
||||
|
||||
@staticmethod
|
||||
def _mutagen_classes():
|
||||
"""Get a list of file type classes from the Mutagen module.
|
||||
"""
|
||||
classes = []
|
||||
for modname, clsname in _MUTAGEN_FORMATS.items():
|
||||
mod = __import__('mutagen.{0}'.format(modname),
|
||||
fromlist=[clsname])
|
||||
classes.append(getattr(mod, clsname))
|
||||
return classes
|
||||
|
||||
def _mutagen_classes():
|
||||
"""Get a list of file type classes from the Mutagen module.
|
||||
"""
|
||||
classes = []
|
||||
for modname, clsname in _MUTAGEN_FORMATS.items():
|
||||
mod = __import__('mutagen.{0}'.format(modname),
|
||||
fromlist=[clsname])
|
||||
classes.append(getattr(mod, clsname))
|
||||
return classes
|
||||
def _scrub(self, path):
|
||||
"""Remove all tags from a file.
|
||||
"""
|
||||
for cls in self._mutagen_classes():
|
||||
# Try opening the file with this type, but just skip in the
|
||||
# event of any error.
|
||||
try:
|
||||
f = cls(util.syspath(path))
|
||||
except Exception:
|
||||
continue
|
||||
if f.tags is None:
|
||||
continue
|
||||
|
||||
# Remove the tag for this type.
|
||||
try:
|
||||
f.delete()
|
||||
except NotImplementedError:
|
||||
# Some Mutagen metadata subclasses (namely, ASFTag) do not
|
||||
# support .delete(), presumably because it is impossible to
|
||||
# remove them. In this case, we just remove all the tags.
|
||||
for tag in f.keys():
|
||||
del f[tag]
|
||||
f.save()
|
||||
except IOError as exc:
|
||||
self._log.error(u'could not scrub {0}: {1}',
|
||||
util.displayable_path(path), exc)
|
||||
|
||||
def _scrub(path):
|
||||
"""Remove all tags from a file.
|
||||
"""
|
||||
for cls in _mutagen_classes():
|
||||
# Try opening the file with this type, but just skip in the
|
||||
# event of any error.
|
||||
try:
|
||||
f = cls(util.syspath(path))
|
||||
except Exception:
|
||||
continue
|
||||
if f.tags is None:
|
||||
continue
|
||||
|
||||
# Remove the tag for this type.
|
||||
try:
|
||||
f.delete()
|
||||
except NotImplementedError:
|
||||
# Some Mutagen metadata subclasses (namely, ASFTag) do not
|
||||
# support .delete(), presumably because it is impossible to
|
||||
# remove them. In this case, we just remove all the tags.
|
||||
for tag in f.keys():
|
||||
del f[tag]
|
||||
f.save()
|
||||
except IOError as exc:
|
||||
log.error(u'could not scrub {0}: {1}'.format(
|
||||
util.displayable_path(path), exc,
|
||||
))
|
||||
|
||||
|
||||
# Automatically embed art into imported albums.
|
||||
@ScrubPlugin.listen('write')
|
||||
def write_item(path):
|
||||
if not scrubbing and config['scrub']['auto']:
|
||||
log.debug(u'auto-scrubbing {0}'.format(util.displayable_path(path)))
|
||||
_scrub(path)
|
||||
def write_item(self, path):
|
||||
"""Automatically embed art into imported albums."""
|
||||
if not scrubbing and self.config['auto']:
|
||||
self._log.debug(u'auto-scrubbing {0}', util.displayable_path(path))
|
||||
self._scrub(path)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Dang Mai <contact@dangmai.net>.
|
||||
# Copyright 2015, Dang Mai <contact@dangmai.net>.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,76 +15,23 @@
|
|||
"""Generates smart playlists based on beets queries.
|
||||
"""
|
||||
from __future__ import print_function
|
||||
from itertools import chain
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets import config, ui, library
|
||||
from beets import ui
|
||||
from beets.util import normpath, syspath
|
||||
import os
|
||||
|
||||
# Global variable so that smartplaylist can detect database changes and run
|
||||
# only once before beets exits.
|
||||
database_changed = False
|
||||
|
||||
|
||||
def _items_for_query(lib, playlist, album=False):
|
||||
"""Get the matching items for a playlist's configured queries.
|
||||
`album` indicates whether to process the item-level query or the
|
||||
album-level query (if any).
|
||||
def _items_for_query(lib, queries, album):
|
||||
"""Get the matching items for a query.
|
||||
`album` indicates whether the queries are item-level or album-level.
|
||||
"""
|
||||
key = 'album_query' if album else 'query'
|
||||
if key not in playlist:
|
||||
return []
|
||||
|
||||
# Parse quer(ies). If it's a list, perform the queries and manually
|
||||
# concatenate the results
|
||||
query_strings = playlist[key]
|
||||
if not isinstance(query_strings, (list, tuple)):
|
||||
query_strings = [query_strings]
|
||||
model = library.Album if album else library.Item
|
||||
results = []
|
||||
for q in query_strings:
|
||||
query, sort = library.parse_query_string(q, model)
|
||||
if album:
|
||||
new = lib.albums(query, sort)
|
||||
else:
|
||||
new = lib.items(query, sort)
|
||||
results.extend(new)
|
||||
return results
|
||||
|
||||
|
||||
def update_playlists(lib):
|
||||
ui.print_("Updating smart playlists...")
|
||||
playlists = config['smartplaylist']['playlists'].get(list)
|
||||
playlist_dir = config['smartplaylist']['playlist_dir'].as_filename()
|
||||
relative_to = config['smartplaylist']['relative_to'].get()
|
||||
if relative_to:
|
||||
relative_to = normpath(relative_to)
|
||||
|
||||
for playlist in playlists:
|
||||
items = []
|
||||
items.extend(_items_for_query(lib, playlist, True))
|
||||
items.extend(_items_for_query(lib, playlist, False))
|
||||
|
||||
m3us = {}
|
||||
basename = playlist['name'].encode('utf8')
|
||||
# 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:
|
||||
m3u_name = item.evaluate_template(basename, True)
|
||||
if not (m3u_name in m3us):
|
||||
m3us[m3u_name] = []
|
||||
item_path = item.path
|
||||
if relative_to:
|
||||
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, m3u))
|
||||
with open(syspath(m3u_path), 'w') as f:
|
||||
for path in m3us[m3u]:
|
||||
f.write(path + '\n')
|
||||
ui.print_("... Done")
|
||||
request = lib.albums if album else lib.items
|
||||
if isinstance(queries, basestring):
|
||||
return request(queries)
|
||||
else:
|
||||
return chain.from_iterable(map(request, queries))
|
||||
|
||||
|
||||
class SmartPlaylistPlugin(BeetsPlugin):
|
||||
|
|
@ -97,23 +44,54 @@ class SmartPlaylistPlugin(BeetsPlugin):
|
|||
'playlists': []
|
||||
})
|
||||
|
||||
if self.config['auto']:
|
||||
self.register_listener('database_change', self.db_change)
|
||||
|
||||
def commands(self):
|
||||
def update(lib, opts, args):
|
||||
update_playlists(lib)
|
||||
self.update_playlists(lib)
|
||||
spl_update = ui.Subcommand('splupdate',
|
||||
help='update the smart playlists')
|
||||
spl_update.func = update
|
||||
return [spl_update]
|
||||
|
||||
def db_change(self, lib):
|
||||
self.register_listener('cli_exit', self.update_playlists)
|
||||
|
||||
@SmartPlaylistPlugin.listen('database_change')
|
||||
def handle_change(lib):
|
||||
global database_changed
|
||||
database_changed = True
|
||||
def update_playlists(self, lib):
|
||||
self._log.info("Updating smart playlists...")
|
||||
playlists = self.config['playlists'].get(list)
|
||||
playlist_dir = self.config['playlist_dir'].as_filename()
|
||||
relative_to = self.config['relative_to'].get()
|
||||
if relative_to:
|
||||
relative_to = normpath(relative_to)
|
||||
|
||||
for playlist in playlists:
|
||||
self._log.debug(u"Creating playlist {0.name}", playlist)
|
||||
items = []
|
||||
if 'album_query' in playlist:
|
||||
items.extend(_items_for_query(lib, playlist['album_query'],
|
||||
True))
|
||||
if 'query' in playlist:
|
||||
items.extend(_items_for_query(lib, playlist['query'], False))
|
||||
|
||||
@SmartPlaylistPlugin.listen('cli_exit')
|
||||
def update(lib):
|
||||
auto = config['smartplaylist']['auto']
|
||||
if database_changed and auto:
|
||||
update_playlists(lib)
|
||||
m3us = {}
|
||||
basename = playlist['name'].encode('utf8')
|
||||
# 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:
|
||||
m3u_name = item.evaluate_template(basename, True)
|
||||
if m3u_name not in m3us:
|
||||
m3us[m3u_name] = []
|
||||
item_path = item.path
|
||||
if relative_to:
|
||||
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, m3u))
|
||||
with open(syspath(m3u_path), 'w') as f:
|
||||
for path in m3us[m3u]:
|
||||
f.write(path + '\n')
|
||||
self._log.info("{0} playlists updated", len(playlists))
|
||||
|
|
|
|||
|
|
@ -2,14 +2,11 @@ from __future__ import print_function
|
|||
import re
|
||||
import webbrowser
|
||||
import requests
|
||||
import logging
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.ui import decargs
|
||||
from beets import ui
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class SpotifyPlugin(BeetsPlugin):
|
||||
|
||||
|
|
@ -63,8 +60,8 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
self.config['show_failures'].set(True)
|
||||
|
||||
if self.config['mode'].get() not in ['list', 'open']:
|
||||
log.warn(u'{0} is not a valid mode'
|
||||
.format(self.config['mode'].get()))
|
||||
self._log.warn(u'{0} is not a valid mode',
|
||||
self.config['mode'].get())
|
||||
return False
|
||||
|
||||
self.opts = opts
|
||||
|
|
@ -78,10 +75,11 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
items = lib.items(query)
|
||||
|
||||
if not items:
|
||||
log.debug(u'Your beets query returned no items, skipping spotify')
|
||||
self._log.debug(u'Your beets query returned no items, '
|
||||
u'skipping spotify')
|
||||
return
|
||||
|
||||
log.info(u'Processing {0} tracks...'.format(len(items)))
|
||||
self._log.info(u'Processing {0} tracks...', len(items))
|
||||
|
||||
for item in items:
|
||||
|
||||
|
|
@ -109,12 +107,12 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
r = requests.get(self.base_url, params={
|
||||
"q": search_url, "type": "track"
|
||||
})
|
||||
log.debug(r.url)
|
||||
self._log.debug(r.url)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except HTTPError as e:
|
||||
log.debug(u'URL returned a {0} error'
|
||||
.format(e.response.status_code))
|
||||
self._log.debug(u'URL returned a {0} error',
|
||||
e.response.status_code)
|
||||
failures.append(search_url)
|
||||
continue
|
||||
|
||||
|
|
@ -130,33 +128,33 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
# Simplest, take the first result
|
||||
chosen_result = None
|
||||
if len(r_data) == 1 or self.config['tiebreak'].get() == "first":
|
||||
log.debug(u'Spotify track(s) found, count: {0}'
|
||||
.format(len(r_data)))
|
||||
self._log.debug(u'Spotify track(s) found, count: {0}',
|
||||
len(r_data))
|
||||
chosen_result = r_data[0]
|
||||
elif len(r_data) > 1:
|
||||
# Use the popularity filter
|
||||
log.debug(u'Most popular track chosen, count: {0}'
|
||||
.format(len(r_data)))
|
||||
self._log.debug(u'Most popular track chosen, count: {0}',
|
||||
len(r_data))
|
||||
chosen_result = max(r_data, key=lambda x: x['popularity'])
|
||||
|
||||
if chosen_result:
|
||||
results.append(chosen_result)
|
||||
else:
|
||||
log.debug(u'No spotify track found: {0}'.format(search_url))
|
||||
self._log.debug(u'No spotify track found: {0}', search_url)
|
||||
failures.append(search_url)
|
||||
|
||||
failure_count = len(failures)
|
||||
if failure_count > 0:
|
||||
if self.config['show_failures'].get():
|
||||
log.info(u'{0} track(s) did not match a Spotify ID:'
|
||||
.format(failure_count))
|
||||
self._log.info(u'{0} track(s) did not match a Spotify ID:',
|
||||
failure_count)
|
||||
for track in failures:
|
||||
log.info(u'track: {0}'.format(track))
|
||||
log.info(u'')
|
||||
self._log.info(u'track: {0}', track)
|
||||
self._log.info(u'')
|
||||
else:
|
||||
log.warn(u'{0} track(s) did not match a Spotify ID;\n'
|
||||
u'use --show-failures to display'
|
||||
.format(failure_count))
|
||||
self._log.warn(u'{0} track(s) did not match a Spotify ID;\n'
|
||||
u'use --show-failures to display',
|
||||
failure_count)
|
||||
|
||||
return results
|
||||
|
||||
|
|
@ -164,7 +162,7 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
if results:
|
||||
ids = map(lambda x: x['id'], results)
|
||||
if self.config['mode'].get() == "open":
|
||||
log.info(u'Attempting to open Spotify with playlist')
|
||||
self._log.info(u'Attempting to open Spotify with playlist')
|
||||
spotify_url = self.playlist_partial + ",".join(ids)
|
||||
webbrowser.open(spotify_url)
|
||||
|
||||
|
|
@ -172,4 +170,4 @@ class SpotifyPlugin(BeetsPlugin):
|
|||
for item in ids:
|
||||
print(unicode.encode(self.open_url + item))
|
||||
else:
|
||||
log.warn(u'No Spotify tracks found from beets query')
|
||||
self._log.warn(u'No Spotify tracks found from beets query')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
# Copyright 2015, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
"""Moves patterns in path formats (suitable for moving articles)."""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
__author__ = 'baobab@heresiarch.info'
|
||||
|
|
@ -29,7 +28,6 @@ FORMAT = u'{0}, {1}'
|
|||
class ThePlugin(BeetsPlugin):
|
||||
|
||||
_instance = None
|
||||
_log = logging.getLogger('beets')
|
||||
|
||||
the = True
|
||||
a = True
|
||||
|
|
@ -56,17 +54,17 @@ class ThePlugin(BeetsPlugin):
|
|||
try:
|
||||
re.compile(p)
|
||||
except re.error:
|
||||
self._log.error(u'[the] invalid pattern: {0}'.format(p))
|
||||
self._log.error(u'invalid pattern: {0}', p)
|
||||
else:
|
||||
if not (p.startswith('^') or p.endswith('$')):
|
||||
self._log.warn(u'[the] warning: \"{0}\" will not '
|
||||
'match string start/end'.format(p))
|
||||
self._log.warn(u'warning: \"{0}\" will not '
|
||||
u'match string start/end', p)
|
||||
if self.config['a']:
|
||||
self.patterns = [PATTERN_A] + self.patterns
|
||||
if self.config['the']:
|
||||
self.patterns = [PATTERN_THE] + self.patterns
|
||||
if not self.patterns:
|
||||
self._log.warn(u'[the] no patterns defined!')
|
||||
self._log.warn(u'no patterns defined!')
|
||||
|
||||
def unthe(self, text, pattern):
|
||||
"""Moves pattern in the path format string or strips it
|
||||
|
|
@ -99,7 +97,7 @@ class ThePlugin(BeetsPlugin):
|
|||
r = self.unthe(text, p)
|
||||
if r != text:
|
||||
break
|
||||
self._log.debug(u'[the] \"{0}\" -> \"{1}\"'.format(text, r))
|
||||
self._log.debug(u'\"{0}\" -> \"{1}\"', text, r)
|
||||
return r
|
||||
else:
|
||||
return u''
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
6
beetsplug/web/static/jquery.js
vendored
6
beetsplug/web/static/jquery.js
vendored
|
|
@ -2,13 +2,13 @@
|
|||
* jQuery JavaScript Library v1.7.1
|
||||
* http://jquery.com/
|
||||
*
|
||||
* Copyright 2013, John Resig
|
||||
* Copyright 2015, John Resig
|
||||
* Dual licensed under the MIT or GPL Version 2 licenses.
|
||||
* http://jquery.org/license
|
||||
*
|
||||
* Includes Sizzle.js
|
||||
* http://sizzlejs.com/
|
||||
* Copyright 2013, The Dojo Foundation
|
||||
* Copyright 2015, The Dojo Foundation
|
||||
* Released under the MIT, BSD, and GPL Licenses.
|
||||
*
|
||||
* Date: Mon Nov 21 21:11:03 2011 -0500
|
||||
|
|
@ -3851,7 +3851,7 @@ jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblcl
|
|||
|
||||
/*!
|
||||
* Sizzle CSS Selector Engine
|
||||
* Copyright 2013, The Dojo Foundation
|
||||
* Copyright 2015, The Dojo Foundation
|
||||
* Released under the MIT, BSD, and GPL Licenses.
|
||||
* More information: http://sizzlejs.com/
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
# Copyright 2015, Blemjhoo Tezoulbr <baobab@heresiarch.info>.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
""" Clears tag fields in media files."""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from beets.plugins import BeetsPlugin
|
||||
from beets.mediafile import MediaFile
|
||||
from beets.importer import action
|
||||
|
|
@ -24,8 +23,6 @@ from beets.util import confit
|
|||
__author__ = 'baobab@heresiarch.info'
|
||||
__version__ = '0.10'
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
class ZeroPlugin(BeetsPlugin):
|
||||
|
||||
|
|
@ -48,11 +45,11 @@ class ZeroPlugin(BeetsPlugin):
|
|||
|
||||
for field in self.config['fields'].as_str_seq():
|
||||
if field in ('id', 'path', 'album_id'):
|
||||
log.warn(u'[zero] field \'{0}\' ignored, zeroing '
|
||||
u'it would be dangerous'.format(field))
|
||||
self._log.warn(u'field \'{0}\' ignored, zeroing '
|
||||
u'it would be dangerous', field)
|
||||
continue
|
||||
if field not in MediaFile.fields():
|
||||
log.error(u'[zero] invalid field: {0}'.format(field))
|
||||
self._log.error(u'invalid field: {0}', field)
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -64,7 +61,7 @@ class ZeroPlugin(BeetsPlugin):
|
|||
def import_task_choice_event(self, session, task):
|
||||
"""Listen for import_task_choice event."""
|
||||
if task.choice_flag == action.ASIS and not self.warned:
|
||||
log.warn(u'[zero] cannot zero in \"as-is\" mode')
|
||||
self._log.warn(u'cannot zero in \"as-is\" mode')
|
||||
self.warned = True
|
||||
# TODO request write in as-is mode
|
||||
|
||||
|
|
@ -85,7 +82,7 @@ class ZeroPlugin(BeetsPlugin):
|
|||
by `self.patterns`.
|
||||
"""
|
||||
if not self.patterns:
|
||||
log.warn(u'[zero] no fields, nothing to do')
|
||||
self._log.warn(u'no fields, nothing to do')
|
||||
return
|
||||
|
||||
for field, patterns in self.patterns.items():
|
||||
|
|
@ -97,5 +94,5 @@ class ZeroPlugin(BeetsPlugin):
|
|||
match = patterns is True
|
||||
|
||||
if match:
|
||||
log.debug(u'[zero] {0}: {1} -> None'.format(field, value))
|
||||
self._log.debug(u'{0}: {1} -> None', field, value)
|
||||
tags[field] = None
|
||||
|
|
|
|||
|
|
@ -1,38 +1,73 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
1.3.10 (in development)
|
||||
1.3.11 (in development)
|
||||
-----------------------
|
||||
|
||||
Fixes:
|
||||
|
||||
* :doc:`/plugins/lyrics`: Silence a warning about insecure requests in the new
|
||||
MusixMatch backend. :bug:`1204`
|
||||
* :doc:`/plugins/lastgenre`: Add *comedy*, *humor*, and *stand-up* to the
|
||||
built-in whitelist/canonicalization tree. :bug:`1206`
|
||||
* Fix a crash when ``beet`` is invoked without arguments. :bug:`1205`
|
||||
:bug:`1207`
|
||||
* :doc:`/plugins/fetchart`: Do not attempt to import directories as album art.
|
||||
:bug:`1177` :bug:`1211`
|
||||
* :doc:`/plugins/mpdstats`: Avoid double-counting some play events. :bug:`773`
|
||||
:bug:`1212`
|
||||
* Fix a crash when the importer deals with Unicode metadata in ``--pretend``
|
||||
mode. :bug:`1214`
|
||||
|
||||
For developers: The logging system in beets has been overhauled. Plugins now
|
||||
each have their own logger, which helps by automatically adjusting the
|
||||
verbosity level in import mode and by prefixing the plugin's name. Also,
|
||||
logging calls can (and should!) use modern ``{}``-style string formatting
|
||||
lazily. See :ref:`plugin-logging` in the plugin API docs.
|
||||
|
||||
|
||||
1.3.10 (January 5, 2015)
|
||||
------------------------
|
||||
|
||||
This version adds a healthy helping of new features and fixes a critical
|
||||
MPEG-4--related bug. There are more lyrics sources, there new plugins for
|
||||
managing permissions and integrating with `Plex`_, and the importer has a new
|
||||
``--pretend`` flag that shows which music *would* be imported.
|
||||
|
||||
One backwards-compatibility note: the :doc:`/plugins/lyrics` now requires the
|
||||
`requests`_ library. If you use this plugin, you will need to install the
|
||||
library by typing ``pip install requests`` or the equivalent for your OS.
|
||||
|
||||
New:
|
||||
Also, as an advance warning, this will be one of the last releases to support
|
||||
Python 2.6. If you have a system that cannot run Python 2.7, please consider
|
||||
upgrading soon.
|
||||
|
||||
The new features are:
|
||||
|
||||
* :doc:`/plugins/lyrics`: Add `Musixmatch`_ source and introduce a new
|
||||
``sources`` config option that lets you choose exactly where to look for
|
||||
lyrics and in which order.
|
||||
* :doc:`/plugins/lyrics`: Add brazilian and hispanic sources to Google custom
|
||||
search engine.
|
||||
* A new :doc:`/plugins/permissions` makes it easy to fix permissions on music
|
||||
files as they are imported. Thanks to :user:`xsteadfastx`. :bug:`1098`
|
||||
* A new :doc:`/plugins/plexupdate` lets you notify a `Plex`_ server when the
|
||||
database changes. Thanks again to xsteadfastx. :bug:`1120`
|
||||
* The :ref:`import-cmd` command now has a ``--pretend`` flag that lists the
|
||||
files that will be imported. Thanks to :user:`mried`. :bug:`1162`
|
||||
* :doc:`/plugins/lyrics`: Add `Musixmatch`_ source and introduce a new
|
||||
``sources`` config option that lets you choose exactly where to look for
|
||||
lyrics and in which order.
|
||||
* :doc:`/plugins/lyrics`: Add Brazilian and Spanish sources to Google custom
|
||||
search engine.
|
||||
* Add a warning when importing a directory that contains no music. :bug:`1116`
|
||||
:bug:`1127`
|
||||
* :doc:`/plugins/zero`: Can now remove embedded images. :bug:`1129` :bug:`1100`
|
||||
* The :ref:`config-cmd` command can now be used to edit the configuration even
|
||||
when it has syntax errors. :bug:`1123` :bug:`1128`
|
||||
* :doc:`/plugins/lyrics`: Added a new ``force`` config option. :bug:`1150`
|
||||
* The :ref:`import-cmd` command now has a ``--pretend`` flag that lists the
|
||||
files that will be imported. Thanks to :user:`mried`. :bug:`1162`
|
||||
|
||||
Fixed:
|
||||
As usual, there are loads of little fixes and improvements:
|
||||
|
||||
* :doc:`/plugins/lyrics`: Avoid fetching truncated lyrics from the Google
|
||||
backed by merging text blocks separated by empty ``<div>`` before scraping.
|
||||
* Fix a new crash with the latest version of Mutagen (1.26).
|
||||
* :doc:`/plugins/lyrics`: Avoid fetching truncated lyrics from the Google
|
||||
backed by merging text blocks separated by empty ``<div>`` tags before
|
||||
scraping.
|
||||
* We now print a better error message when the database file is corrupted.
|
||||
* :doc:`/plugins/discogs`: Only prompt for authentication when running the
|
||||
:ref:`import-cmd` command. :bug:`1123`
|
||||
|
|
@ -68,6 +103,8 @@ Fixed:
|
|||
twice in the artist string. Thanks to Marc Addeo. :bug:`1179` :bug:`1181`
|
||||
* :doc:`/plugins/lastgenre`: Match songs more robustly when they contain
|
||||
dashes. Thanks to :user:`djl`. :bug:`1156`
|
||||
* The :ref:`config-cmd` command can now use ``$EDITOR`` variables with
|
||||
arguments.
|
||||
|
||||
.. _API changes: http://developer.echonest.com/forums/thread/3650
|
||||
.. _Plex: https://plex.tv/
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ project = u'beets'
|
|||
copyright = u'2012, Adrian Sampson'
|
||||
|
||||
version = '1.3'
|
||||
release = '1.3.10'
|
||||
release = '1.3.11'
|
||||
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
|
|
|
|||
|
|
@ -112,8 +112,23 @@ an example::
|
|||
def loaded():
|
||||
print 'Plugin loaded!'
|
||||
|
||||
Pass the name of the event in question to the ``listen`` decorator. The events
|
||||
currently available are:
|
||||
Pass the name of the event in question to the ``listen`` decorator.
|
||||
|
||||
Note that if you want to access an attribute of your plugin (e.g. ``config`` or
|
||||
``log``) you'll have to define a method and not a function. Here is the usual
|
||||
registration process in this case::
|
||||
|
||||
from beets.plugins import BeetsPlugin
|
||||
|
||||
class SomePlugin(BeetsPlugin):
|
||||
def __init__(self):
|
||||
super(SomePlugin, self).__init__()
|
||||
self.register_listener('pluginload', self.loaded)
|
||||
|
||||
def loaded(self):
|
||||
self._log.info('Plugin loaded!')
|
||||
|
||||
The events currently available are:
|
||||
|
||||
* *pluginload*: called after all the plugins have been loaded after the ``beet``
|
||||
command starts
|
||||
|
|
@ -334,11 +349,11 @@ method.
|
|||
|
||||
Here's an example plugin that provides a meaningless new field "foo"::
|
||||
|
||||
class FooPlugin(BeetsPlugin):
|
||||
class fooplugin(beetsplugin):
|
||||
def __init__(self):
|
||||
field = mediafile.MediaField(
|
||||
mediafile.MP3DescStorageStyle(u'foo')
|
||||
mediafile.StorageStyle(u'foo')
|
||||
field = mediafile.mediafield(
|
||||
mediafile.mp3descstoragestyle(u'foo')
|
||||
mediafile.storagestyle(u'foo')
|
||||
)
|
||||
self.add_media_field('foo', field)
|
||||
|
||||
|
|
@ -448,3 +463,30 @@ Specifying types has several advantages:
|
|||
from the command line.
|
||||
|
||||
* User input for flexible fields may be validated and converted.
|
||||
|
||||
|
||||
.. _plugin-logging:
|
||||
|
||||
Logging
|
||||
^^^^^^^
|
||||
|
||||
Each plugin object has a ``_log`` attribute, which is a ``Logger`` from the
|
||||
`standard Python logging module`_. The logger is set up to `PEP 3101`_,
|
||||
str.format-style string formatting. So you can write logging calls like this::
|
||||
|
||||
self._log.debug(u'Processing {0.title} by {0.artist}', item)
|
||||
|
||||
.. _PEP 3101: https://www.python.org/dev/peps/pep-3101/
|
||||
.. _standard Python logging module: https://docs.python.org/2/library/logging.html
|
||||
|
||||
The per-plugin loggers have two convenient features:
|
||||
|
||||
* When beets is in verbose mode, messages are prefixed with the plugin name to
|
||||
make them easier to see.
|
||||
* Messages at the ``INFO`` logging level are hidden when the plugin is running
|
||||
in an importer stage (see above). This addresses a common pattern where
|
||||
plugins need to use the same code for a command and an import stage, but the
|
||||
command needs to print more messages than the import stage. (For example,
|
||||
you'll want to log "found lyrics for this song" when you're run explicitly
|
||||
as a command, but you don't want to noisily interrupt the importer interface
|
||||
when running automatically.)
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ to albums that have a ``for_travel`` extensible field set to 1::
|
|||
album_query: 'for_travel:1'
|
||||
query: 'for_travel:1'
|
||||
|
||||
By default, all playlists are automatically regenerated after every beets
|
||||
command that changes the library database. To force regeneration, you can invoke it manually from the
|
||||
command line::
|
||||
By default, all playlists are automatically regenerated at the end of the
|
||||
session if the library database was changed. To force regeneration, you can
|
||||
invoke it manually from the command line::
|
||||
|
||||
$ beet splupdate
|
||||
|
||||
|
|
|
|||
4
setup.py
4
setup.py
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -45,7 +45,7 @@ if 'sdist' in sys.argv:
|
|||
|
||||
setup(
|
||||
name='beets',
|
||||
version='1.3.10',
|
||||
version='1.3.11',
|
||||
description='music tagger and library organizer',
|
||||
author='Adrian Sampson',
|
||||
author_email='adrian@radbox.org',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -16,7 +16,6 @@
|
|||
import time
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import tempfile
|
||||
import shutil
|
||||
from contextlib import contextmanager
|
||||
|
|
@ -30,7 +29,7 @@ except ImportError:
|
|||
# Mangle the search path to include the beets sources.
|
||||
sys.path.insert(0, '..')
|
||||
import beets.library
|
||||
from beets import importer
|
||||
from beets import importer, logging
|
||||
from beets.ui import commands
|
||||
import beets
|
||||
|
||||
|
|
@ -116,9 +115,9 @@ def album(lib=None):
|
|||
|
||||
|
||||
# Dummy import session.
|
||||
def import_session(lib=None, logfile=None, paths=[], query=[], cli=False):
|
||||
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
|
||||
cls = commands.TerminalImportSession if cli else importer.ImportSession
|
||||
return cls(lib, logfile, paths, query)
|
||||
return cls(lib, loghandler, paths, query)
|
||||
|
||||
|
||||
# A test harness for all beets tests.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -35,13 +35,13 @@ import os
|
|||
import os.path
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
from tempfile import mkdtemp, mkstemp
|
||||
from contextlib import contextmanager
|
||||
from StringIO import StringIO
|
||||
from enum import Enum
|
||||
|
||||
import beets
|
||||
from beets import logging
|
||||
from beets import config
|
||||
import beets.plugins
|
||||
from beets.library import Library, Item, Album
|
||||
|
|
@ -255,7 +255,7 @@ class TestHelper(object):
|
|||
config['import']['autotag'] = False
|
||||
config['import']['resume'] = False
|
||||
|
||||
return TestImportSession(self.lib, logfile=None, query=None,
|
||||
return TestImportSession(self.lib, loghandler=None, query=None,
|
||||
paths=[import_dir])
|
||||
|
||||
# Library fixtures methods
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte
|
||||
# Copyright 2015, Fabrice Laporte
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -26,9 +26,19 @@ from beets.autotag import AlbumInfo, AlbumMatch
|
|||
from beets import library
|
||||
from beets import importer
|
||||
from beets import config
|
||||
from beets import logging
|
||||
|
||||
|
||||
class FetchImageTest(_common.TestCase):
|
||||
logger = logging.getLogger('beets.test_art')
|
||||
|
||||
|
||||
class UseThePlugin(_common.TestCase):
|
||||
def setUp(self):
|
||||
super(UseThePlugin, self).setUp()
|
||||
self.plugin = fetchart.FetchArtPlugin()
|
||||
|
||||
|
||||
class FetchImageTest(UseThePlugin):
|
||||
@responses.activate
|
||||
def run(self, *args, **kwargs):
|
||||
super(FetchImageTest, self).run(*args, **kwargs)
|
||||
|
|
@ -39,12 +49,12 @@ class FetchImageTest(_common.TestCase):
|
|||
|
||||
def test_invalid_type_returns_none(self):
|
||||
self.mock_response('image/watercolour')
|
||||
artpath = fetchart._fetch_image('http://example.com')
|
||||
artpath = self.plugin._fetch_image('http://example.com')
|
||||
self.assertEqual(artpath, None)
|
||||
|
||||
def test_jpeg_type_returns_path(self):
|
||||
self.mock_response('image/jpeg')
|
||||
artpath = fetchart._fetch_image('http://example.com')
|
||||
artpath = self.plugin._fetch_image('http://example.com')
|
||||
self.assertNotEqual(artpath, None)
|
||||
|
||||
|
||||
|
|
@ -54,41 +64,42 @@ class FSArtTest(_common.TestCase):
|
|||
self.dpath = os.path.join(self.temp_dir, 'arttest')
|
||||
os.mkdir(self.dpath)
|
||||
|
||||
self.source = fetchart.FileSystem(logger)
|
||||
|
||||
def test_finds_jpg_in_directory(self):
|
||||
_common.touch(os.path.join(self.dpath, 'a.jpg'))
|
||||
fn = fetchart.art_in_path(self.dpath, ('art',), False)
|
||||
fn = self.source.get(self.dpath, ('art',), False)
|
||||
self.assertEqual(fn, os.path.join(self.dpath, 'a.jpg'))
|
||||
|
||||
def test_appropriately_named_file_takes_precedence(self):
|
||||
_common.touch(os.path.join(self.dpath, 'a.jpg'))
|
||||
_common.touch(os.path.join(self.dpath, 'art.jpg'))
|
||||
fn = fetchart.art_in_path(self.dpath, ('art',), False)
|
||||
fn = self.source.get(self.dpath, ('art',), False)
|
||||
self.assertEqual(fn, os.path.join(self.dpath, 'art.jpg'))
|
||||
|
||||
def test_non_image_file_not_identified(self):
|
||||
_common.touch(os.path.join(self.dpath, 'a.txt'))
|
||||
fn = fetchart.art_in_path(self.dpath, ('art',), False)
|
||||
fn = self.source.get(self.dpath, ('art',), False)
|
||||
self.assertEqual(fn, None)
|
||||
|
||||
def test_cautious_skips_fallback(self):
|
||||
_common.touch(os.path.join(self.dpath, 'a.jpg'))
|
||||
fn = fetchart.art_in_path(self.dpath, ('art',), True)
|
||||
fn = self.source.get(self.dpath, ('art',), True)
|
||||
self.assertEqual(fn, None)
|
||||
|
||||
def test_empty_dir(self):
|
||||
fn = fetchart.art_in_path(self.dpath, ('art',), True)
|
||||
fn = self.source.get(self.dpath, ('art',), True)
|
||||
self.assertEqual(fn, None)
|
||||
|
||||
def test_precedence_amongst_correct_files(self):
|
||||
_common.touch(os.path.join(self.dpath, 'back.jpg'))
|
||||
_common.touch(os.path.join(self.dpath, 'front.jpg'))
|
||||
_common.touch(os.path.join(self.dpath, 'front-cover.jpg'))
|
||||
fn = fetchart.art_in_path(self.dpath,
|
||||
('cover', 'front', 'back'), False)
|
||||
fn = self.source.get(self.dpath, ('cover', 'front', 'back'), False)
|
||||
self.assertEqual(fn, os.path.join(self.dpath, 'front-cover.jpg'))
|
||||
|
||||
|
||||
class CombinedTest(_common.TestCase):
|
||||
class CombinedTest(UseThePlugin):
|
||||
ASIN = 'xxxx'
|
||||
MBID = 'releaseid'
|
||||
AMAZON_URL = 'http://images.amazon.com/images/P/{0}.01.LZZZZZZZ.jpg' \
|
||||
|
|
@ -103,9 +114,6 @@ class CombinedTest(_common.TestCase):
|
|||
self.dpath = os.path.join(self.temp_dir, 'arttest')
|
||||
os.mkdir(self.dpath)
|
||||
|
||||
# Set up configuration.
|
||||
self.plugin = fetchart.FetchArtPlugin()
|
||||
|
||||
@responses.activate
|
||||
def run(self, *args, **kwargs):
|
||||
super(CombinedTest, self).run(*args, **kwargs)
|
||||
|
|
@ -116,61 +124,61 @@ class CombinedTest(_common.TestCase):
|
|||
def test_main_interface_returns_amazon_art(self):
|
||||
self.mock_response(self.AMAZON_URL)
|
||||
album = _common.Bag(asin=self.ASIN)
|
||||
artpath = fetchart.art_for_album(album, None)
|
||||
artpath = self.plugin.art_for_album(album, None)
|
||||
self.assertNotEqual(artpath, None)
|
||||
|
||||
def test_main_interface_returns_none_for_missing_asin_and_path(self):
|
||||
album = _common.Bag()
|
||||
artpath = fetchart.art_for_album(album, None)
|
||||
artpath = self.plugin.art_for_album(album, None)
|
||||
self.assertEqual(artpath, None)
|
||||
|
||||
def test_main_interface_gives_precedence_to_fs_art(self):
|
||||
_common.touch(os.path.join(self.dpath, 'art.jpg'))
|
||||
self.mock_response(self.AMAZON_URL)
|
||||
album = _common.Bag(asin=self.ASIN)
|
||||
artpath = fetchart.art_for_album(album, [self.dpath])
|
||||
artpath = self.plugin.art_for_album(album, [self.dpath])
|
||||
self.assertEqual(artpath, os.path.join(self.dpath, 'art.jpg'))
|
||||
|
||||
def test_main_interface_falls_back_to_amazon(self):
|
||||
self.mock_response(self.AMAZON_URL)
|
||||
album = _common.Bag(asin=self.ASIN)
|
||||
artpath = fetchart.art_for_album(album, [self.dpath])
|
||||
artpath = self.plugin.art_for_album(album, [self.dpath])
|
||||
self.assertNotEqual(artpath, None)
|
||||
self.assertFalse(artpath.startswith(self.dpath))
|
||||
|
||||
def test_main_interface_tries_amazon_before_aao(self):
|
||||
self.mock_response(self.AMAZON_URL)
|
||||
album = _common.Bag(asin=self.ASIN)
|
||||
fetchart.art_for_album(album, [self.dpath])
|
||||
self.plugin.art_for_album(album, [self.dpath])
|
||||
self.assertEqual(len(responses.calls), 1)
|
||||
self.assertEqual(responses.calls[0].request.url, self.AMAZON_URL)
|
||||
|
||||
def test_main_interface_falls_back_to_aao(self):
|
||||
self.mock_response(self.AMAZON_URL, content_type='text/html')
|
||||
album = _common.Bag(asin=self.ASIN)
|
||||
fetchart.art_for_album(album, [self.dpath])
|
||||
self.plugin.art_for_album(album, [self.dpath])
|
||||
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)
|
||||
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
|
||||
artpath = fetchart.art_for_album(album, None)
|
||||
artpath = self.plugin.art_for_album(album, None)
|
||||
self.assertNotEqual(artpath, None)
|
||||
self.assertEqual(len(responses.calls), 1)
|
||||
self.assertEqual(responses.calls[0].request.url, self.CAA_URL)
|
||||
|
||||
def test_local_only_does_not_access_network(self):
|
||||
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
|
||||
artpath = fetchart.art_for_album(album, [self.dpath],
|
||||
local_only=True)
|
||||
artpath = self.plugin.art_for_album(album, [self.dpath],
|
||||
local_only=True)
|
||||
self.assertEqual(artpath, None)
|
||||
self.assertEqual(len(responses.calls), 0)
|
||||
|
||||
def test_local_only_gets_fs_image(self):
|
||||
_common.touch(os.path.join(self.dpath, 'art.jpg'))
|
||||
album = _common.Bag(mb_albumid=self.MBID, asin=self.ASIN)
|
||||
artpath = fetchart.art_for_album(album, [self.dpath],
|
||||
None, local_only=True)
|
||||
artpath = self.plugin.art_for_album(album, [self.dpath],
|
||||
local_only=True)
|
||||
self.assertEqual(artpath, os.path.join(self.dpath, 'art.jpg'))
|
||||
self.assertEqual(len(responses.calls), 0)
|
||||
|
||||
|
|
@ -179,6 +187,10 @@ class AAOTest(_common.TestCase):
|
|||
ASIN = 'xxxx'
|
||||
AAO_URL = 'http://www.albumart.org/index_detail.php?asin={0}'.format(ASIN)
|
||||
|
||||
def setUp(self):
|
||||
super(AAOTest, self).setUp()
|
||||
self.source = fetchart.AlbumArtOrg(logger)
|
||||
|
||||
@responses.activate
|
||||
def run(self, *args, **kwargs):
|
||||
super(AAOTest, self).run(*args, **kwargs)
|
||||
|
|
@ -197,13 +209,13 @@ class AAOTest(_common.TestCase):
|
|||
"""
|
||||
self.mock_response(self.AAO_URL, body)
|
||||
album = _common.Bag(asin=self.ASIN)
|
||||
res = fetchart.aao_art(album)
|
||||
res = self.source.get(album)
|
||||
self.assertEqual(list(res)[0], '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)
|
||||
res = fetchart.aao_art(album)
|
||||
res = self.source.get(album)
|
||||
self.assertEqual(list(res), [])
|
||||
|
||||
|
||||
|
|
@ -211,6 +223,10 @@ class GoogleImageTest(_common.TestCase):
|
|||
|
||||
_google_url = 'https://ajax.googleapis.com/ajax/services/search/images'
|
||||
|
||||
def setUp(self):
|
||||
super(GoogleImageTest, self).setUp()
|
||||
self.source = fetchart.GoogleImages(logger)
|
||||
|
||||
@responses.activate
|
||||
def run(self, *args, **kwargs):
|
||||
super(GoogleImageTest, self).run(*args, **kwargs)
|
||||
|
|
@ -224,31 +240,31 @@ class GoogleImageTest(_common.TestCase):
|
|||
json = """{"responseData": {"results":
|
||||
[{"unescapedUrl": "url_to_the_image"}]}}"""
|
||||
self.mock_response(self._google_url, json)
|
||||
result_url = fetchart.google_art(album)
|
||||
result_url = self.source.get(album)
|
||||
self.assertEqual(list(result_url)[0], 'url_to_the_image')
|
||||
|
||||
def test_google_art_dont_finds_image(self):
|
||||
album = _common.Bag(albumartist="some artist", album="some album")
|
||||
json = """bla blup"""
|
||||
self.mock_response(self._google_url, json)
|
||||
result_url = fetchart.google_art(album)
|
||||
result_url = self.source.get(album)
|
||||
self.assertEqual(list(result_url), [])
|
||||
|
||||
|
||||
class ArtImporterTest(_common.TestCase):
|
||||
class ArtImporterTest(UseThePlugin):
|
||||
def setUp(self):
|
||||
super(ArtImporterTest, self).setUp()
|
||||
|
||||
# Mock the album art fetcher to always return our test file.
|
||||
self.art_file = os.path.join(self.temp_dir, 'tmpcover.jpg')
|
||||
_common.touch(self.art_file)
|
||||
self.old_afa = fetchart.art_for_album
|
||||
self.old_afa = self.plugin.art_for_album
|
||||
self.afa_response = self.art_file
|
||||
|
||||
def art_for_album(i, p, maxwidth=None, local_only=False):
|
||||
def art_for_album(i, p, local_only=False):
|
||||
return self.afa_response
|
||||
|
||||
fetchart.art_for_album = art_for_album
|
||||
self.plugin.art_for_album = art_for_album
|
||||
|
||||
# Test library.
|
||||
self.libpath = os.path.join(self.temp_dir, 'tmplib.blb')
|
||||
|
|
@ -263,8 +279,7 @@ class ArtImporterTest(_common.TestCase):
|
|||
self.album = self.lib.add_album([self.i])
|
||||
self.lib._connection().commit()
|
||||
|
||||
# The plugin and import configuration.
|
||||
self.plugin = fetchart.FetchArtPlugin()
|
||||
# The import configuration.
|
||||
self.session = _common.import_session(self.lib)
|
||||
|
||||
# Import task for the coroutine.
|
||||
|
|
@ -283,7 +298,7 @@ class ArtImporterTest(_common.TestCase):
|
|||
def tearDown(self):
|
||||
self.lib._connection().close()
|
||||
super(ArtImporterTest, self).tearDown()
|
||||
fetchart.art_for_album = self.old_afa
|
||||
self.plugin.art_for_album = self.old_afa
|
||||
|
||||
def _fetch_art(self, should_exist):
|
||||
"""Execute the fetch_art coroutine for the task and return the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte.
|
||||
# Copyright 2015, Fabrice Laporte.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -15,7 +15,6 @@
|
|||
|
||||
"""Tests for the 'bucket' plugin."""
|
||||
|
||||
from nose.tools import raises
|
||||
from _common import unittest
|
||||
from beetsplug import bucket
|
||||
from beets import config, ui
|
||||
|
|
@ -129,26 +128,24 @@ class BucketPluginTest(unittest.TestCase, TestHelper):
|
|||
self.assertEqual(self.plugin._tmpl_bucket('…and Oceans'), 'A - D')
|
||||
self.assertEqual(self.plugin._tmpl_bucket('Eagles'), 'E - L')
|
||||
|
||||
@raises(ui.UserError)
|
||||
def test_bad_alpha_range_def(self):
|
||||
"""If bad alpha range definition, a UserError is raised"""
|
||||
self._setup_config(bucket_alpha=['$%'])
|
||||
self.assertEqual(self.plugin._tmpl_bucket('errol'), 'E')
|
||||
"""If bad alpha range definition, a UserError is raised."""
|
||||
with self.assertRaises(ui.UserError):
|
||||
self._setup_config(bucket_alpha=['$%'])
|
||||
|
||||
@raises(ui.UserError)
|
||||
def test_bad_year_range_def_no4digits(self):
|
||||
"""If bad year range definition, a UserError is raised.
|
||||
Range origin must be expressed on 4 digits."""
|
||||
self._setup_config(bucket_year=['62-64'])
|
||||
# from year must be expressed on 4 digits
|
||||
self.assertEqual(self.plugin._tmpl_bucket('1963'), '62-64')
|
||||
Range origin must be expressed on 4 digits.
|
||||
"""
|
||||
with self.assertRaises(ui.UserError):
|
||||
self._setup_config(bucket_year=['62-64'])
|
||||
|
||||
@raises(ui.UserError)
|
||||
def test_bad_year_range_def_nodigits(self):
|
||||
"""If bad year range definition, a UserError is raised.
|
||||
At least the range origin must be declared."""
|
||||
self._setup_config(bucket_year=['nodigits'])
|
||||
self.assertEqual(self.plugin._tmpl_bucket('1963'), '62-64')
|
||||
At least the range origin must be declared.
|
||||
"""
|
||||
with self.assertRaises(ui.UserError):
|
||||
self._setup_config(bucket_year=['nodigits'])
|
||||
|
||||
|
||||
def suite():
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes
|
||||
# Copyright 2015, Thomas Scholtes
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -18,7 +18,7 @@ from _common import unittest
|
|||
from helper import TestHelper, capture_log
|
||||
|
||||
from beets.mediafile import MediaFile
|
||||
from beets import config
|
||||
from beets import config, logging
|
||||
from beets.util import syspath
|
||||
from beets.util.artresizer import ArtResizer
|
||||
|
||||
|
|
@ -76,9 +76,10 @@ class EmbedartCliTest(unittest.TestCase, TestHelper):
|
|||
|
||||
def test_art_file_missing(self):
|
||||
self.add_album_fixture()
|
||||
with capture_log() as logs:
|
||||
logging.getLogger('beets.embedart').setLevel(logging.DEBUG)
|
||||
with capture_log('beets.embedart') as logs:
|
||||
self.run_command('embedart', '-f', '/doesnotexist')
|
||||
self.assertIn(u'embedart: could not read image file:', ''.join(logs))
|
||||
self.assertIn(u'could not read image file:', ''.join(logs))
|
||||
|
||||
@require_artresizer_compare
|
||||
def test_reject_different_art(self):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
import os.path
|
||||
import os
|
||||
from _common import unittest
|
||||
from helper import TestHelper
|
||||
|
||||
|
|
@ -22,25 +22,31 @@ class FetchartCliTest(unittest.TestCase, TestHelper):
|
|||
def setUp(self):
|
||||
self.setup_beets()
|
||||
self.load_plugins('fetchart')
|
||||
self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg'
|
||||
self.config['art_filename'] = 'mycover'
|
||||
self.album = self.add_album()
|
||||
|
||||
def tearDown(self):
|
||||
self.unload_plugins()
|
||||
self.teardown_beets()
|
||||
|
||||
def test_set_art_from_folder(self):
|
||||
self.config['fetchart']['cover_names'] = 'c\xc3\xb6ver.jpg'
|
||||
self.config['art_filename'] = 'mycover'
|
||||
album = self.add_album()
|
||||
self.touch('c\xc3\xb6ver.jpg', dir=album.path, content='IMAGE')
|
||||
self.touch('c\xc3\xb6ver.jpg', dir=self.album.path, content='IMAGE')
|
||||
|
||||
self.run_command('fetchart')
|
||||
cover_path = os.path.join(album.path, 'mycover.jpg')
|
||||
cover_path = os.path.join(self.album.path, 'mycover.jpg')
|
||||
|
||||
album.load()
|
||||
self.assertEqual(album['artpath'], cover_path)
|
||||
self.album.load()
|
||||
self.assertEqual(self.album['artpath'], cover_path)
|
||||
with open(cover_path, 'r') as f:
|
||||
self.assertEqual(f.read(), 'IMAGE')
|
||||
|
||||
def test_filesystem_does_not_pick_up_folder(self):
|
||||
os.makedirs(os.path.join(self.album.path, 'mycover.jpg'))
|
||||
self.run_command('fetchart')
|
||||
self.album.load()
|
||||
self.assertEqual(self.album['artpath'], None)
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte.
|
||||
# Copyright 2015, Fabrice Laporte.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Stig Inge Lea Bjornsen.
|
||||
# Copyright 2015, Stig Inge Lea Bjornsen.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -33,6 +34,7 @@ from beets.mediafile import MediaFile
|
|||
from beets import autotag
|
||||
from beets.autotag import AlbumInfo, TrackInfo, AlbumMatch
|
||||
from beets import config
|
||||
from beets import logging
|
||||
|
||||
|
||||
class AutotagStub(object):
|
||||
|
|
@ -209,7 +211,7 @@ class ImportHelper(TestHelper):
|
|||
config['import']['link'] = link
|
||||
|
||||
self.importer = TestImportSession(
|
||||
self.lib, logfile=None, query=None,
|
||||
self.lib, loghandler=None, query=None,
|
||||
paths=[import_dir or self.import_dir]
|
||||
)
|
||||
|
||||
|
|
@ -1219,15 +1221,17 @@ class ImportDuplicateSingletonTest(unittest.TestCase, TestHelper):
|
|||
class TagLogTest(_common.TestCase):
|
||||
def test_tag_log_line(self):
|
||||
sio = StringIO.StringIO()
|
||||
session = _common.import_session(logfile=sio)
|
||||
handler = logging.StreamHandler(sio)
|
||||
session = _common.import_session(loghandler=handler)
|
||||
session.tag_log('status', 'path')
|
||||
assert 'status path' in sio.getvalue()
|
||||
self.assertIn('status path', sio.getvalue())
|
||||
|
||||
def test_tag_log_unicode(self):
|
||||
sio = StringIO.StringIO()
|
||||
session = _common.import_session(logfile=sio)
|
||||
session.tag_log('status', 'caf\xc3\xa9')
|
||||
assert 'status caf' in sio.getvalue()
|
||||
handler = logging.StreamHandler(sio)
|
||||
session = _common.import_session(loghandler=handler)
|
||||
session.tag_log('status', u'café') # send unicode
|
||||
self.assertIn(u'status café', sio.getvalue())
|
||||
|
||||
|
||||
class ResumeImportTest(unittest.TestCase, TestHelper):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import shutil
|
|||
from _common import unittest
|
||||
from beets import config
|
||||
from beets.library import Item, Album, Library
|
||||
from beetsplug.importfeeds import album_imported, ImportFeedsPlugin
|
||||
from beetsplug.importfeeds import ImportFeedsPlugin
|
||||
|
||||
|
||||
class ImportfeedsTestTest(unittest.TestCase):
|
||||
|
|
@ -30,7 +30,7 @@ class ImportfeedsTestTest(unittest.TestCase):
|
|||
self.lib.add(album)
|
||||
self.lib.add(item)
|
||||
|
||||
album_imported(self.lib, album)
|
||||
self.importfeeds.album_imported(self.lib, album)
|
||||
playlist_path = os.path.join(self.feeds_dir,
|
||||
os.listdir(self.feeds_dir)[0])
|
||||
self.assertTrue(playlist_path.endswith('album_name.m3u'))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Thomas Scholtes.
|
||||
# Copyright 2015, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Fabrice Laporte.
|
||||
# Copyright 2015, Fabrice Laporte.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
@ -157,9 +157,10 @@ class LastGenrePluginTest(unittest.TestCase, TestHelper):
|
|||
tag2.item = MockPylastElem(u'Rap')
|
||||
return [tag1, tag2]
|
||||
|
||||
res = lastgenre._tags_for(MockPylastObj())
|
||||
plugin = lastgenre.LastGenrePlugin()
|
||||
res = plugin._tags_for(MockPylastObj())
|
||||
self.assertEqual(res, [u'pop', u'rap'])
|
||||
res = lastgenre._tags_for(MockPylastObj(), min_weight=50)
|
||||
res = plugin._tags_for(MockPylastObj(), min_weight=50)
|
||||
self.assertEqual(res, [u'pop'])
|
||||
|
||||
def test_get_genre(self):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
# Copyright 2015, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
|
|
|
|||
42
test/test_logging.py
Normal file
42
test/test_logging.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Stupid tests that ensure logging works as expected"""
|
||||
import logging as log
|
||||
from StringIO import StringIO
|
||||
|
||||
import beets.logging as blog
|
||||
from _common import unittest, TestCase
|
||||
|
||||
|
||||
class LoggingTest(TestCase):
|
||||
def test_logging_management(self):
|
||||
l1 = log.getLogger("foo123")
|
||||
l2 = blog.getLogger("foo123")
|
||||
self.assertEqual(l1, l2)
|
||||
self.assertEqual(l1.__class__, log.Logger)
|
||||
|
||||
l3 = blog.getLogger("bar123")
|
||||
l4 = log.getLogger("bar123")
|
||||
self.assertEqual(l3, l4)
|
||||
self.assertEqual(l3.__class__, blog.StrFormatLogger)
|
||||
|
||||
l5 = l3.getChild("shalala")
|
||||
self.assertEqual(l5.__class__, blog.StrFormatLogger)
|
||||
|
||||
def test_str_format_logging(self):
|
||||
l = blog.getLogger("baz123")
|
||||
stream = StringIO()
|
||||
handler = log.StreamHandler(stream)
|
||||
|
||||
l.addHandler(handler)
|
||||
l.propagate = False
|
||||
|
||||
l.warning("foo {0} {bar}", "oof", bar="baz")
|
||||
handler.flush()
|
||||
self.assertTrue(stream.getvalue(), "foo oof baz")
|
||||
|
||||
|
||||
def suite():
|
||||
return unittest.TestLoader().loadTestsFromName(__name__)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(defaultTest='suite')
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue