Compare commits

..
Author SHA1 Message Date
rembo10 b3199605be v0.5.20 2021-10-15 09:56:59 +05:30
AdeHub 58edc604b3 Merge branch 'develop' 2021-07-08 19:57:08 +12:00
AdeHub 379fd3d0b8 Merge branch 'develop' 2020-10-17 11:06:38 +13:00
AdeHub bf74f57535 Merge branch 'develop' 2020-05-28 10:49:51 +12:00
AdeHub f18334d87c Merge branch 'develop' 2020-03-07 10:30:57 +13:00
AdeHub 5283b48736 Merge branch 'develop' 2019-09-08 17:38:17 +12:00
Ade dc22bb006d Hotfix index creation from develop
Fixes #3175
2019-01-08 21:09:41 +13:00
1428 changed files with 67290 additions and 151629 deletions
-3
View File
@@ -1,3 +0,0 @@
[flake8]
exclude = .git,data,init-scripts,lib
ignore = E501
-29
View File
@@ -1,29 +0,0 @@
name: check
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, 3.10]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 .
- name: Test with nosetests
run: |
nosetests
+25
View File
@@ -0,0 +1,25 @@
# Travis CI configuration file
# http://about.travis-ci.org/docs/
language: python
sudo: false
cache:
pip: true
directories:
- lib
python:
- "2.7"
install:
- pip install -r requirements-dev.txt
script:
- pep8 headphones
- pyflakes headphones
- nosetests
after_success:
- if [[ $TRAVIS_PYTHON_VERSION == "2.7" ]]; then coveralls; fi
-4
View File
@@ -17,10 +17,6 @@
import os
import sys
if sys.version_info <= (3, 5):
sys.stdout.write("Headphones requires Python >= 3.5\n")
sys.exit(1)
# Ensure lib added to path, before any other imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/'))
+2 -2
View File
@@ -6,7 +6,7 @@ import sys
# Ensure that we use the Headphones provided libraries.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib"))
import urllib.parse
import urlparse
def can_import(module):
@@ -89,7 +89,7 @@ def main():
url = sys.argv[1]
# Check if it is a HTTPS website.
parts = urllib.parse.urlparse(url)
parts = urlparse.urlparse(url)
if parts.scheme.lower() != "https":
sys.stderr.write(
+2 -2
View File
@@ -1274,7 +1274,7 @@
<input type="checkbox" name="synoindex_enabled" id="synoindex" value="1" ${config['synoindex_enabled']} /><label for="synoindex"><span class="option">Synology NAS</span></label>
</div>
</fieldset>
<!--
<fieldset>
<div class="row checkbox left">
<input type="checkbox" class="bigcheck" name="twitter_enabled" id="twitter" value="1" ${config['twitter_enabled']} /><label for="twitter"><span class="option">Twitter</span></label>
@@ -1295,7 +1295,7 @@
</div>
</div>
</fieldset>
-->
<fieldset>
<div class="row checkbox left">
<input type="checkbox" class="bigcheck" name="slack_enabled" id="slack" value="1" ${config['slack_enabled']} /><label for="slack"><span class="option">Slack</span></label>
+4 -4
View File
@@ -1,7 +1,7 @@
<%inherit file="base.html"/>
<%!
from headphones import helpers
from html import escape as html_escape
import cgi
%>
<%def name="headerIncludes()">
@@ -62,11 +62,11 @@
%>
<tr class="grade${grade}">
<td id="dateadded">${item['DateAdded']}</td>
<td id="filename">${html_escape(item['Title'], quote=True)} [<a href="${item['URL']}">${fileid}</a>]<a href="albumPage?AlbumID=${item['AlbumID']}">[album page]</a></td>
<td id="filename">${cgi.escape(item['Title'], quote=True)} [<a href="${item['URL']}">${fileid}</a>]<a href="albumPage?AlbumID=${item['AlbumID']}">[album page]</a></td>
<td id="size">${helpers.bytes_to_mb(item['Size'])}</td>
<td title="${folder}" id="status">${item['Status']}</td>
<td id="action">[<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&redirect=history', $(this),'table')" data-success="Retrying download of '${html_escape(item['Title'], quote=True)}'">retry</a>][<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&new=True&redirect=history',$(this),'table')" data-success="Looking for a new version of '${html_escape(item['Title'], quote=True)}'">new</a>]</td>
<td id="delete"><a href="javascript:void(0)" onclick="doAjaxCall('clearhistory?date_added=${item['DateAdded']}&title=${html_escape(item['Title'], quote=True)}',$(this),'table')" data-success="${html_escape(item['Title'], quote=True)} cleared from history"><img src="interfaces/default/images/trashcan.png" height="18" width="18" id="trashcan" title="Clear this item from the history"></a>
<td id="action">[<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&redirect=history', $(this),'table')" data-success="Retrying download of '${cgi.escape(item['Title'], quote=True)}'">retry</a>][<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${item['AlbumID']}&new=True&redirect=history',$(this),'table')" data-success="Looking for a new version of '${cgi.escape(item['Title'], quote=True)}'">new</a>]</td>
<td id="delete"><a href="javascript:void(0)" onclick="doAjaxCall('clearhistory?date_added=${item['DateAdded']}&title=${cgi.escape(item['Title'], quote=True)}',$(this),'table')" data-success="${cgi.escape(item['Title'], quote=True)} cleared from history"><img src="interfaces/default/images/trashcan.png" height="18" width="18" id="trashcan" title="Clear this item from the history"></a>
</tr>
%endfor
</tbody>
+4 -4
View File
@@ -218,7 +218,7 @@ def daemonize():
pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0:
sys.exit(0)
except OSError as e:
except OSError, e:
raise RuntimeError("1st fork failed: %s [%d]", e.strerror, e.errno)
os.setsid()
@@ -232,10 +232,10 @@ def daemonize():
pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0:
sys.exit(0)
except OSError as e:
except OSError, e:
raise RuntimeError("2nd fork failed: %s [%d]", e.strerror, e.errno)
dev_null = open('/dev/null', 'r')
dev_null = file('/dev/null', 'r')
os.dup2(dev_null.fileno(), sys.stdin.fileno())
si = open('/dev/null', "r")
@@ -251,7 +251,7 @@ def daemonize():
if CREATEPID:
logger.info("Writing PID %d to %s", pid, PIDFILE)
with open(PIDFILE, 'w') as fp:
with file(PIDFILE, 'w') as fp:
fp.write("%s\n" % pid)
+8 -15
View File
@@ -28,7 +28,7 @@ def getAlbumArt(albumid):
# CAA
logger.info("Searching for artwork at CAA")
artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
artwork_path = 'http://coverartarchive.org/release-group/%s/front' % albumid
artwork = getartwork(artwork_path)
if artwork:
logger.info("Artwork found at CAA")
@@ -41,7 +41,7 @@ def getAlbumArt(albumid):
'SELECT ArtistName, AlbumTitle, ReleaseID, AlbumASIN FROM albums WHERE AlbumID=?',
[albumid]).fetchone()
if dbalbum['AlbumASIN']:
artwork_path = 'https://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % dbalbum['AlbumASIN']
artwork_path = 'http://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % dbalbum['AlbumASIN']
artwork = getartwork(artwork_path)
if artwork:
logger.info("Artwork found at Amazon")
@@ -156,19 +156,12 @@ def getartwork(artwork_path):
break
elif maxwidth and img_width > maxwidth:
# Downsize using proxy service to max width
artwork_path = '{0}?{1}'.format('http://images.weserv.nl/', urlencode({
'url': artwork_path.replace('http://', ''),
'w': maxwidth,
}))
artwork = bytes()
url = "https://images.weserv.nl"
params = {
"url": artwork_path,
"w": maxwidth
}
r = request.request_response(
url,
params=params,
timeout=20,
stream=True,
whitelist_status_code=404
)
r = request.request_response(artwork_path, timeout=20, stream=True, whitelist_status_code=404)
if r:
for chunk in r.iter_content(chunk_size=1024):
artwork += chunk
@@ -189,7 +182,7 @@ def getCachedArt(albumid):
if not artwork_path:
return
if artwork_path.startswith("http"):
if artwork_path.startswith('http://'):
artwork = request.request_content(artwork_path, timeout=20)
if not artwork:
+7 -11
View File
@@ -86,7 +86,7 @@ class Api(object):
methodToCall = getattr(self, "_" + self.cmd)
methodToCall(**self.kwargs)
if 'callback' not in self.kwargs:
if isinstance(self.data, str):
if isinstance(self.data, basestring):
return self.data
else:
return json.dumps(self.data)
@@ -106,7 +106,7 @@ class Api(object):
rows_as_dic = []
for row in rows:
row_as_dic = dict(list(zip(list(row.keys()), row)))
row_as_dic = dict(zip(row.keys(), row))
rows_as_dic.append(row_as_dic)
return rows_as_dic
@@ -474,21 +474,17 @@ class Api(object):
# Handle situations where the torrent url contains arguments that are
# parsed
if kwargs:
import urllib.request
import urllib.parse
import urllib.error
import urllib.request
import urllib.error
import urllib.parse
url = urllib.parse.quote(
url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
import urllib
import urllib2
url = urllib2.quote(
url, safe=":?/=&") + '&' + urllib.urlencode(kwargs)
try:
result = [(title, int(size), url, provider, kind)]
except ValueError:
result = [(title, float(size), url, provider, kind)]
logger.info("Making sure we can download the chosen result")
logger.info(u"Making sure we can download the chosen result")
(data, bestqual) = searcher.preprocess(result)
if data and bestqual:
+12 -17
View File
@@ -240,7 +240,7 @@ class Cache(object):
# fallback to 1st album cover if none of the above
elif 'albums' in data:
for mbid, art in list(data.get('albums', dict()).items()):
for mbid, art in data.get('albums', dict()).items():
if 'albumcover' in art:
image_url = art['albumcover'][0]['url']
break
@@ -352,7 +352,7 @@ class Cache(object):
# fallback to 1st album cover if none of the above
elif 'albums' in data:
for mbid, art in list(data.get('albums', dict()).items()):
for mbid, art in data.get('albums', dict()).items():
if 'albumcover' in art:
image_url = art['albumcover'][0]['url']
break
@@ -388,9 +388,9 @@ class Cache(object):
else:
if dbalbum['Type'] != "part of":
data = lastfm.request_lastfm("album.getinfo",
artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
api_key=LASTFM_API_KEY)
artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
api_key=LASTFM_API_KEY)
else:
# Series, use actual artist for the release-group
@@ -484,7 +484,7 @@ class Cache(object):
self.id + '_fanart_' + '.' + helpers.today() + ext)
else:
artwork_path = os.path.join(self.path_to_art_cache,
self.id + '.' + helpers.today() + ext)
self.id + '.' + helpers.today() + ext)
try:
with open(artwork_path, 'wb') as f:
f.write(artwork)
@@ -540,17 +540,12 @@ class Cache(object):
artwork_thumb = None
if 'fanart' in thumb_url:
# Create thumb using image resizing service
url = "https://images.weserv.nl"
params = {
"url": thumb_url,
"w": 300
}
artwork_thumb = request.request_content(
url,
params=params,
timeout=20,
whitelist_status_code=404
)
artwork_path = '{0}?{1}'.format('http://images.weserv.nl/', urlencode({
'url': thumb_url.replace('http://', ''),
'w': 300,
}))
artwork_thumb = request.request_content(artwork_path, timeout=20, whitelist_status_code=404)
if artwork_thumb:
with open(thumb_path, 'wb') as f:
f.write(artwork_thumb)
+4 -6
View File
@@ -18,14 +18,12 @@
#######################################
import urllib.request
import urllib.parse
import urllib.error
import urllib
from .common import USER_AGENT
from common import USER_AGENT
class HeadphonesURLopener(urllib.request.FancyURLopener):
class HeadphonesURLopener(urllib.FancyURLopener):
version = USER_AGENT
@@ -46,7 +44,7 @@ class AuthURLOpener(HeadphonesURLopener):
self.numTries = 0
# call the base class
urllib.request.FancyURLopener.__init__(self)
urllib.FancyURLopener.__init__(self)
def prompt_user_passwd(self, host, realm):
"""
+7 -8
View File
@@ -24,7 +24,6 @@ import operator
import os
import re
from headphones import version
from functools import reduce
# Identify Our Application
@@ -75,9 +74,9 @@ class Quality:
@staticmethod
def _getStatusStrings(status):
toReturn = {}
for x in list(Quality.qualityStrings.keys()):
for x in Quality.qualityStrings.keys():
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \
Quality.qualityStrings[x] + ")"
Quality.qualityStrings[x] + ")"
return toReturn
@staticmethod
@@ -94,7 +93,7 @@ class Quality:
def splitQuality(quality):
anyQualities = []
bestQualities = []
for curQual in list(Quality.qualityStrings.keys()):
for curQual in Quality.qualityStrings.keys():
if curQual & quality:
anyQualities.append(curQual)
if curQual << 16 & quality:
@@ -152,7 +151,7 @@ class Quality:
@staticmethod
def splitCompositeStatus(status):
"""Returns a tuple containing (status, quality)"""
for x in sorted(list(Quality.qualityStrings.keys()), reverse=True):
for x in sorted(Quality.qualityStrings.keys(), reverse=True):
if status > x * 100:
return (status - x * 100, x)
@@ -170,10 +169,10 @@ class Quality:
SNATCHED_PROPER = None
Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in list(Quality.qualityStrings.keys())]
Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in list(Quality.qualityStrings.keys())]
Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()]
Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()]
Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in
list(Quality.qualityStrings.keys())]
Quality.qualityStrings.keys()]
MP3 = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR], [])
LOSSLESS = Quality.combineQualities([Quality.FLAC], [])
+24 -39
View File
@@ -2,16 +2,15 @@ import itertools
import os
import re
import ast
from configparser import ConfigParser
import headphones.logger
from configobj import ConfigObj
def bool_int(value):
"""
Casts a config value into a 0 or 1
"""
if isinstance(value, str):
if isinstance(value, basestring):
if value.lower() in ('', '0', 'false', 'f', 'no', 'n', 'off'):
value = 0
return int(bool(value))
@@ -31,7 +30,6 @@ class path(str):
def __repr__(self):
return 'headphones.config.path(%s)' % self
_CONFIG_DEFINITIONS = {
'ADD_ALBUM_ART': (int, 'General', 0),
'ADVANCEDENCODER': (str, 'General', ''),
@@ -328,9 +326,8 @@ class Config(object):
def __init__(self, config_file):
""" Initialize the config with values from a file """
self._config_file = config_file
self._config = ConfigParser()
self._config.read(self._config_file)
for key in list(_CONFIG_DEFINITIONS.keys()):
self._config = ConfigObj(self._config_file, encoding='utf-8')
for key in _CONFIG_DEFINITIONS.keys():
self.check_setting(key)
self.ENCODER_MULTICORE_COUNT = max(0, self.ENCODER_MULTICORE_COUNT)
self._upgrade()
@@ -347,7 +344,7 @@ class Config(object):
def check_section(self, section):
""" Check if INI section exists, if not create it """
if not self._config.has_section(section):
if section not in self._config:
self._config[section] = {}
return True
else:
@@ -357,38 +354,28 @@ class Config(object):
""" Cast any value in the config to the right type or use the default """
key, definition_type, section, ini_key, default = self._define(key)
self.check_section(section)
# ConfigParser values are strings, so need to convert to actual list
if definition_type == list:
definition_type = ast.literal_eval
try:
my_val = definition_type(self._config[section][ini_key])
# ConfigParser interprets empty strings in the config
# literally, so we need to sanitize it. It's not really
# a config upgrade, since a user can at any time put
# some_key = ''
if my_val == '""' or my_val == "''":
my_val = ''
except Exception:
my_val = default
self._config[section][ini_key] = str(my_val)
my_val = definition_type(default)
self._config[section][ini_key] = my_val
return my_val
def write(self):
""" Make a copy of the stored config and write it to the configured file """
new_config = ConfigParser()
new_config = ConfigObj(encoding="UTF-8")
new_config.filename = self._config_file
# first copy over everything from the old config, even if it is not
# correctly defined to keep from losing data
for key, subkeys in list(self._config.items()):
for key, subkeys in self._config.items():
if key not in new_config:
new_config[key] = {}
for subkey, value in list(subkeys.items()):
for subkey, value in subkeys.items():
new_config[key][subkey] = value
# next make sure that everything we expect to have defined is so
for key in list(_CONFIG_DEFINITIONS.keys()):
for key in _CONFIG_DEFINITIONS.keys():
key, definition_type, section, ini_key, default = self._define(key)
self.check_setting(key)
if section not in new_config:
@@ -399,16 +386,15 @@ class Config(object):
headphones.logger.info("Writing configuration to file")
try:
with open(self._config_file, 'w') as configfile:
new_config.write(configfile)
new_config.write()
except IOError as e:
headphones.logger.error("Error writing configuration file: %s", e)
def get_extra_newznabs(self):
""" Return the extra newznab tuples """
extra_newznabs = list(
zip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3)
for i in range(3)])
itertools.izip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3)
for i in range(3)])
)
return extra_newznabs
@@ -426,8 +412,8 @@ class Config(object):
def get_extra_torznabs(self):
""" Return the extra torznab tuples """
extra_torznabs = list(
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4)
for i in range(4)])
itertools.izip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4)
for i in range(4)])
)
return extra_torznabs
@@ -462,21 +448,20 @@ class Config(object):
return value
else:
key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = str(value)
self._config[section][ini_key] = definition_type(value)
return self._config[section][ini_key]
def process_kwargs(self, kwargs):
"""
Given a big bunch of key value pairs, apply them to the ini.
"""
for name, value in list(kwargs.items()):
for name, value in kwargs.items():
key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = str(value)
self._config[section][ini_key] = definition_type(value)
def _upgrade(self):
"""
Bring old configs up to date. Although this is kind of a dumb
way to do it because it doesn't handle multi-step upgrades
Bring old configs up to date
"""
if self.CONFIG_VERSION == '2':
# Update the config to use direct path to the encoder rather than the encoder folder
@@ -503,12 +488,12 @@ class Config(object):
# Add Seed Ratio to Torznabs
if self.EXTRA_TORZNABS:
extra_torznabs = list(
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
for i in range(3)])
itertools.izip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
for i in range(3)])
)
new_torznabs = []
for torznab in extra_torznabs:
new_torznabs.extend([torznab[0], torznab[1], '', torznab[2]])
new_torznabs.extend([torznab[0], torznab[1], u'', torznab[2]])
if new_torznabs:
self.EXTRA_TORZNABS = new_torznabs
+3 -3
View File
@@ -2,8 +2,8 @@ import mock
from mock import MagicMock
import headphones.config
import re
from . import unittestcompat
from .unittestcompat import TestCase, TestArgs
import unittestcompat
from unittestcompat import TestCase, TestArgs
class ConfigApiTest(TestCase):
@@ -101,7 +101,7 @@ class ConfigApiTest(TestCase):
# call methods
c = headphones.config.Config(path)
# assertions:
with self.assertRaisesRegex(KeyError, exc_regex):
with self.assertRaisesRegexp(KeyError, exc_regex):
c.check_setting(setting_name)
pass
+1 -1
View File
@@ -21,7 +21,7 @@ def cry():
main_thread = t
# Loop over each thread's current frame, writing info about it
for tid, frame in sys._current_frames().items():
for tid, frame in sys._current_frames().iteritems():
thread = tmap.get(tid, main_thread)
lines = []
+10 -10
View File
@@ -87,7 +87,7 @@ def check_splitter(command):
def split_baby(split_file, split_cmd):
'''Let's split baby'''
logger.info(f"Splitting {split_file}...")
logger.info('Splitting %s...', split_file.decode(headphones.SYS_ENCODING, 'replace'))
logger.debug(subprocess.list2cmdline(split_cmd))
# Prevent Windows from opening a terminal window
@@ -108,16 +108,16 @@ def split_baby(split_file, split_cmd):
process = subprocess.Popen(split_cmd, startupinfo=startupinfo,
stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env, text=True)
stderr=subprocess.PIPE, env=env)
stdout, stderr = process.communicate()
if process.returncode:
logger.error(f"Split failed for {split_file}")
out = stdout or stderr
logger.error(f"Error details: {out}")
logger.error('Split failed for %s', split_file.decode(headphones.SYS_ENCODING, 'replace'))
out = stdout if stdout else stderr
logger.error('Error details: %s', out.decode(headphones.SYS_ENCODING, 'replace'))
return False
else:
logger.info(f"Split succeeded for {split_file}")
logger.info('Split success %s', split_file.decode(headphones.SYS_ENCODING, 'replace'))
return True
@@ -232,7 +232,7 @@ class Directory:
for i in list_dir:
if not check_match(i):
# music file
if os.path.splitext(i)[-1] in list(WAVE_FILE_TYPE_BY_EXTENSION.keys()):
if os.path.splitext(i)[-1] in WAVE_FILE_TYPE_BY_EXTENSION.keys():
track_nr = identify_track_number(i)
if track_nr:
self.content.append(WaveFile(self.path + os.sep + i, track_nr=track_nr))
@@ -378,7 +378,7 @@ class CueFile(File):
except:
raise ValueError('Cant encode CUE Sheet.')
if self.content[0] == '\ufeff':
if self.content[0] == u'\ufeff':
self.content = self.content[1:]
header = header_parser()
@@ -581,7 +581,7 @@ def split(albumpath):
# use xld profile to split cue
if headphones.CONFIG.ENCODER == 'xld' and headphones.CONFIG.MUSIC_ENCODER and headphones.CONFIG.XLDPROFILE:
from . import getXldProfile
import getXldProfile
xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
if not xldformat:
raise ValueError(
@@ -601,7 +601,7 @@ def split(albumpath):
raise ValueError('Command not found, ensure shntool or xld installed')
# Determine if file can be split
if wave.name_ext not in list(WAVE_FILE_TYPE_BY_EXTENSION.keys()):
if wave.name_ext not in WAVE_FILE_TYPE_BY_EXTENSION.keys():
raise ValueError('Cannot split, audio file has unsupported extension')
# Split with xld
+7 -6
View File
@@ -17,6 +17,7 @@
# Stolen from Sick-Beard's db.py #
###################################
from __future__ import with_statement
import time
@@ -115,7 +116,7 @@ class DBConnection:
break
except sqlite3.OperationalError as e:
except sqlite3.OperationalError, e:
if "unable to open database file" in e.message or "database is locked" in e.message:
dberror = e
if args is None:
@@ -127,7 +128,7 @@ class DBConnection:
else:
logger.error('Database error: %s', e)
raise
except sqlite3.DatabaseError as e:
except sqlite3.DatabaseError, e:
logger.error('Fatal Error executing %s :: %s', query, e)
raise
@@ -155,14 +156,14 @@ class DBConnection:
If the table is not updated then the 'WHERE changes' will be 0 and the table inserted
"""
def genParams(myDict):
return [x + " = ?" for x in list(myDict.keys())]
return [x + " = ?" for x in myDict.keys()]
update_query = "UPDATE " + tableName + " SET " + ", ".join(genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict))
insert_query = ("INSERT INTO " + tableName + " (" + ", ".join(list(valueDict.keys()) + list(keyDict.keys())) + ")" + " SELECT " + ", ".join(
["?"] * len(list(valueDict.keys()) + list(keyDict.keys()))) + " WHERE changes()=0")
insert_query = ("INSERT INTO " + tableName + " (" + ", ".join(valueDict.keys() + keyDict.keys()) + ")" + " SELECT " + ", ".join(
["?"] * len(valueDict.keys() + keyDict.keys())) + " WHERE changes()=0")
try:
self.action(update_query, list(valueDict.values()) + list(keyDict.values()), upsert_insert_qry=insert_query)
self.action(update_query, valueDict.values() + keyDict.values(), upsert_insert_qry=insert_query)
except sqlite3.IntegrityError:
logger.info('Queries failed: %s and %s', update_query, insert_query)
+49 -29
View File
@@ -34,6 +34,7 @@
# You should have received a copy of the GNU General Public License
# along with SickRage. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from headphones import logger
@@ -88,7 +89,7 @@ def addTorrent(link, data=None, name=None):
if link.lower().startswith('magnet:'):
logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link))
result = {'type': 'magnet',
'url': link}
'url': link}
retid = _add_torrent_magnet(result)
elif link.lower().startswith('http://') or link.lower().startswith('https://'):
@@ -142,8 +143,8 @@ def addTorrent(link, data=None, name=None):
except:
logger.debug('Deluge: Sending Deluge torrent with problematic name and some content')
result = {'type': 'torrent',
'name': name,
'content': torrentfile}
'name': name,
'content': torrentfile}
retid = _add_torrent_file(result)
# elif link.endswith('.torrent') or data:
@@ -174,8 +175,8 @@ def addTorrent(link, data=None, name=None):
except UnicodeDecodeError:
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name.decode('utf-8'), str(torrentfile)[:40]))
result = {'type': 'torrent',
'name': name,
'content': torrentfile}
'name': name,
'content': torrentfile}
retid = _add_torrent_file(result)
else:
@@ -207,7 +208,7 @@ def getTorrentFolder(result):
],
"id": 21})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result['total_done'] = json.loads(response.text)['result']['total_done']
tries = 0
@@ -215,7 +216,7 @@ def getTorrentFolder(result):
tries += 1
time.sleep(5)
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result['total_done'] = json.loads(response.text)['result']['total_done']
post_data = json.dumps({"method": "web.get_torrent_status",
@@ -234,7 +235,7 @@ def getTorrentFolder(result):
"id": 23})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result['save_path'] = json.loads(response.text)['result']['save_path']
result['name'] = json.loads(response.text)['result']['name']
@@ -263,7 +264,7 @@ def removeTorrent(torrentid, remove_data=False):
"id": 26})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
try:
state = json.loads(response.text)['result']['state']
@@ -282,10 +283,10 @@ def removeTorrent(torrentid, remove_data=False):
"params": [
torrentid,
remove_data
],
],
"id": 25})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result = json.loads(response.text)['result']
return result
@@ -328,12 +329,12 @@ def _get_auth():
"id": 1})
try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
except requests.ConnectionError:
try:
logger.debug('Deluge: Connection failed, let\'s try HTTPS just in case')
response = requests.post(delugeweb_url.replace('http:', 'https:'), data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
# If the previous line didn't fail, change delugeweb_url for the rest of this session
logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!')
delugeweb_url = delugeweb_url.replace('http:', 'https:')
@@ -358,7 +359,7 @@ def _get_auth():
"id": 10})
try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -375,7 +376,7 @@ def _get_auth():
"id": 11})
try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -394,7 +395,7 @@ def _get_auth():
try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -407,7 +408,7 @@ def _get_auth():
try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -432,7 +433,7 @@ def _add_torrent_magnet(result):
"params": [result['url'], {}],
"id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
@@ -452,7 +453,7 @@ def _add_torrent_url(result):
"params": [result['url'], {}],
"id": 32})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result['location'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
@@ -471,13 +472,32 @@ def _add_torrent_file(result):
# content is torrent file contents that needs to be encoded to base64
post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent',
b64encode(result['content']).decode(), {}],
b64encode(result['content'].encode('utf8')), {}],
"id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
except UnicodeDecodeError:
try:
# content is torrent file contents that needs to be encoded to base64
# this time let's try leaving the encoding as is
logger.debug('Deluge: There was a decoding issue, let\'s try again')
post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'].decode('utf8') + '.torrent',
b64encode(result['content']), {}],
"id": 22})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result']
except Exception as e:
logger.error('Deluge: Adding torrent file failed after decode: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
logger.error('; '.join(formatted_lines))
return False
except Exception as e:
logger.error('Deluge: Adding torrent file failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
@@ -501,7 +521,7 @@ def setTorrentLabel(result):
"params": [],
"id": 3})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
labels = json.loads(response.text)['result']
if labels is not None:
@@ -512,7 +532,7 @@ def setTorrentLabel(result):
"params": [label],
"id": 4})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
logger.debug('Deluge: %s label added to Deluge' % label)
except Exception as e:
logger.error('Deluge: Setting label failed: %s' % str(e))
@@ -524,7 +544,7 @@ def setTorrentLabel(result):
"params": [result['hash'], label],
"id": 5})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
logger.debug('Deluge: %s label added to torrent' % label)
else:
logger.debug('Deluge: Label plugin not detected')
@@ -548,12 +568,12 @@ def setSeedRatio(result):
"params": [result['hash'], True],
"id": 5})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
post_data = json.dumps({"method": "core.set_torrent_stop_ratio",
"params": [result['hash'], float(ratio)],
"id": 6})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error']
@@ -576,7 +596,7 @@ def setTorrentPath(result):
"params": [result['hash'], True],
"id": 7})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY
@@ -590,7 +610,7 @@ def setTorrentPath(result):
"params": [result['hash'], move_to],
"id": 8})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error']
@@ -613,7 +633,7 @@ def setTorrentPause(result):
"params": [[result['hash']]],
"id": 9})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error']
+1 -1
View File
@@ -15,7 +15,7 @@ def getXldProfile(xldProfile):
# Get xld preferences plist
try:
preferences = biplist.readPlist(expanded)
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as e:
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException), e:
logger.error("Error reading xld preferences plist: %s", e)
return (xldProfileNotFound, None, None)
+87 -97
View File
@@ -28,11 +28,9 @@ import six
from contextlib import contextmanager
import fnmatch
import functools
import re
import os
from mediafile import MediaFile, FileTypeError, UnreadableFileError
from unidecode import unidecode
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
import headphones
@@ -43,19 +41,6 @@ RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I)
RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
def cmp(x, y):
"""
Replacement for built-in function cmp that was removed in Python 3
Compare the two objects x and y and return an integer according to
the outcome. The return value is negative if x < y, zero if x == y
and strictly positive if x > y.
https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
"""
return (x > y) - (x < y)
def multikeysort(items, columns):
comparers = [
((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1))
@@ -69,7 +54,7 @@ def multikeysort(items, columns):
else:
return 0
return sorted(items, key=functools.cmp_to_key(comparer))
return sorted(items, cmp=comparer)
def checked(variable):
@@ -225,7 +210,7 @@ def pattern_substitute(pattern, dic, normalize=False):
if normalize:
new_dic = {}
for i, j in dic.items():
for i, j in dic.iteritems():
if j is not None:
try:
if sys.platform == 'darwin':
@@ -234,7 +219,7 @@ def pattern_substitute(pattern, dic, normalize=False):
j = unicodedata.normalize('NFC', j)
except TypeError:
j = unicodedata.normalize('NFC',
j.decode(headphones.SYS_ENCODING, 'replace'))
j.decode(headphones.SYS_ENCODING, 'replace'))
new_dic[i] = j
dic = new_dic
return pathrender.render(pattern, dic)[0]
@@ -244,7 +229,7 @@ def replace_all(text, dic):
if not text:
return ''
for i, j in dic.items():
for i, j in dic.iteritems():
text = text.replace(i, j)
return text
@@ -257,8 +242,8 @@ def replace_illegal_chars(string, type="file"):
return string
_CN_RE1 = re.compile(r'[^\w]+', re.UNICODE)
_CN_RE2 = re.compile(r'[\s_]+', re.UNICODE)
_CN_RE1 = re.compile(ur'[^\w]+', re.UNICODE)
_CN_RE2 = re.compile(ur'[\s_]+', re.UNICODE)
_XLATE_GRAPHICAL_AND_DIACRITICAL = {
@@ -268,33 +253,33 @@ _XLATE_GRAPHICAL_AND_DIACRITICAL = {
# ©ª«®²³¹»¼½¾ÆÐØÞßæðøþĐđĦħıIJijĸĿŀŁłŒœŦŧDŽDždžLJLjljNJNjnjǤǥDZDzdzȤȥ. This
# includes also some graphical symbols which can be easily replaced and
# usually are written by people who don't have appropriate keyboard layout.
'©': '(C)', 'ª': 'a.', '«': '<<', '®': '(R)', '²': '2', '³': '3',
'¹': '1', '»': '>>', '¼': ' 1/4 ', '½': ' 1/2 ', '¾': ' 3/4 ',
'Æ': 'AE', 'Ð': 'D', 'Ø': 'O', 'Þ': 'Th', 'ß': 'ss', 'æ': 'ae',
'ð': 'd', 'ø': 'o', 'þ': 'th', 'Đ': 'D', 'đ': 'd', 'Ħ': 'H',
'ħ': 'h', 'ı': 'i', 'IJ': 'IJ', 'ij': 'ij', 'ĸ': 'q', 'Ŀ': 'L',
'ŀ': 'l', 'Ł': 'L', 'ł': 'l', 'Œ': 'OE', 'œ': 'oe', 'Ŧ': 'T',
'ŧ': 't', 'DŽ': 'DZ', 'Dž': 'Dz', 'LJ': 'LJ', 'Lj': 'Lj',
'lj': 'lj', 'NJ': 'NJ', 'Nj': 'Nj', 'nj': 'nj',
'Ǥ': 'G', 'ǥ': 'g', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz',
'Ȥ': 'Z', 'ȥ': 'z', '': 'No.',
'º': 'o.', # normalize Nº abbrev (popular w/ classical music),
# this is 'masculine ordering indicator', not degree
u'©': '(C)', u'ª': 'a.', u'«': '<<', u'®': '(R)', u'²': '2', u'³': '3',
u'¹': '1', u'»': '>>', u'¼': ' 1/4 ', u'½': ' 1/2 ', u'¾': ' 3/4 ',
u'Æ': 'AE', u'Ð': 'D', u'Ø': 'O', u'Þ': 'Th', u'ß': 'ss', u'æ': 'ae',
u'ð': 'd', u'ø': 'o', u'þ': 'th', u'Đ': 'D', u'đ': 'd', u'Ħ': 'H',
u'ħ': 'h', u'ı': 'i', u'IJ': 'IJ', u'ij': 'ij', u'ĸ': 'q', u'Ŀ': 'L',
u'ŀ': 'l', u'Ł': 'L', u'ł': 'l', u'Œ': 'OE', u'œ': 'oe', u'Ŧ': 'T',
u'ŧ': 't', u'DŽ': 'DZ', u'Dž': 'Dz', u'LJ': 'LJ', u'Lj': 'Lj',
u'lj': 'lj', u'NJ': 'NJ', u'Nj': 'Nj', u'nj': 'nj',
u'Ǥ': 'G', u'ǥ': 'g', u'DZ': 'DZ', u'Dz': 'Dz', u'dz': 'dz',
u'Ȥ': 'Z', u'ȥ': 'z', u'': 'No.',
u'º': 'o.', # normalize Nº abbrev (popular w/ classical music),
# this is 'masculine ordering indicator', not degree
}
_XLATE_SPECIAL = {
# Translation table.
# Cover additional special characters processing normalization.
"'": '', # replace apostrophe with nothing
"": '', # replace musicbrainz style apostrophe with nothing
'&': ' and ', # expand & to ' and '
u"'": '', # replace apostrophe with nothing
u"": '', # replace musicbrainz style apostrophe with nothing
u'&': ' and ', # expand & to ' and '
}
_XLATE_MUSICBRAINZ = {
# Translation table for Musicbrainz.
"": '...', # HORIZONTAL ELLIPSIS (U+2026)
"": "'", # APOSTROPHE (U+0027)
"": "-", # EN DASH (U+2013)
u"": '...', # HORIZONTAL ELLIPSIS (U+2026)
u"": "'", # APOSTROPHE (U+0027)
u"": "-", # EN DASH (U+2013)
}
@@ -329,10 +314,10 @@ def _transliterate(u, xlate):
Perform transliteration using the specified dictionary
"""
u = unicodedata.normalize('NFD', u)
u = ''.join(['' if _is_unicode_combining(x) else x for x in u])
u = u''.join([u'' if _is_unicode_combining(x) else x for x in u])
u = _translate(u, xlate)
# at this point output is either unicode, or plain ascii
return str(u)
return unicode(u)
def clean_name(s):
@@ -342,10 +327,10 @@ def clean_name(s):
:param s: string to clean up, possibly unicode one.
:return: cleaned-up version of input string.
"""
if not isinstance(s, str):
if not isinstance(s, unicode):
# ignore extended chars if someone was dumb enough to pass non-ascii
# narrow string here, use only unicode for meaningful texts
u = str(s, 'ascii', 'replace')
u = unicode(s, 'ascii', 'replace')
else:
u = s
# 1. don't bother doing normalization NFKC, rather transliterate
@@ -356,9 +341,9 @@ def clean_name(s):
# 3. translate spacials
u = _translate(u, _XLATE_SPECIAL)
# 4. replace any non-alphanumeric character sequences by spaces
u = _CN_RE1.sub(' ', u)
u = _CN_RE1.sub(u' ', u)
# 5. coalesce interleaved space/underscore sequences
u = _CN_RE2.sub(' ', u)
u = _CN_RE2.sub(u' ', u)
# 6. trim
u = u.strip()
# 7. lowercase
@@ -372,8 +357,8 @@ def clean_musicbrainz_name(s, return_as_string=True):
:param s: string to clean up, probably unicode.
:return: cleaned-up version of input string.
"""
if not isinstance(s, str):
u = str(s, 'ascii', 'replace')
if not isinstance(s, unicode):
u = unicode(s, 'ascii', 'replace')
else:
u = s
u = _translate(u, _XLATE_MUSICBRAINZ)
@@ -467,7 +452,8 @@ def expand_subfolders(f):
if difference > 0:
logger.info(
f"Found {len(media_folders)} media folders, but depth difference between lowest and deepest media folder is {difference} (expected zero). If this is a discography or a collection of albums, make sure albums are per folder.")
"Found %d media folders, but depth difference between lowest and deepest media folder is %d (expected zero). If this is a discography or a collection of albums, make sure albums are per folder.",
len(media_folders), difference)
# While already failed, advice the user what he could try. We assume the
# directory may contain separate CD's and maybe some extra's. The
@@ -479,7 +465,8 @@ def expand_subfolders(f):
set([os.path.join(*media_folder) for media_folder in extra_media_folders]))
logger.info(
f"Please look at the following folder(s), since they cause the depth difference: {extra_media_folders}")
"Please look at the following folder(s), since they cause the depth difference: %s",
extra_media_folders)
return
# Convert back to paths and remove duplicates, which may be there after
@@ -493,7 +480,7 @@ def expand_subfolders(f):
logger.debug("Did not expand subfolder, as it resulted in one folder.")
return
logger.debug(f"Expanded subfolders in folder: {media_folders}")
logger.debug("Expanded subfolders in folder: %s", media_folders)
return media_folders
@@ -511,7 +498,7 @@ def path_match_patterns(path, patterns):
return False
def path_filter_patterns(paths, patterns, root=''):
def path_filter_patterns(paths, patterns, root=None):
"""
Scan for ignored paths based on glob patterns. Note that the whole path
will be matched, therefore paths should only contain the relative paths.
@@ -525,7 +512,8 @@ def path_filter_patterns(paths, patterns, root=''):
for path in paths[:]:
if path_match_patterns(path, patterns):
logger.debug(f"Path ignored by pattern: {os.path.join(root, path)}")
logger.debug("Path ignored by pattern: %s",
os.path.join(root or "", path))
ignored += 1
paths.remove(path)
@@ -607,7 +595,7 @@ def extract_metadata(f):
count_ratio = 0.75
if count < (count_ratio * len(results)):
logger.info(f"Counted {count} media files, but only {len(results)} have tags, ignoring.")
logger.info("Counted %d media files, but only %d have tags, ignoring.", count, len(results))
return (None, None, None)
# Count distinct values
@@ -625,7 +613,8 @@ def extract_metadata(f):
old_album = new_albums[index]
new_albums[index] = RE_CD_ALBUM.sub("", album).strip()
logger.debug(f"Stripped album number identifier: {old_album} -> {new_albums[index]}")
logger.debug("Stripped albumd number identifier: %s -> %s", old_album,
new_albums[index])
# Remove duplicates
new_albums = list(set(new_albums))
@@ -643,7 +632,7 @@ def extract_metadata(f):
if len(artists) > 1 and len(albums) == 1:
split_artists = [RE_FEATURING.split(x) for x in artists]
featurings = [len(split_artist) - 1 for split_artist in split_artists]
logger.info("Album seem to feature {sum(featurings)} different artists")
logger.info("Album seem to feature %d different artists", sum(featurings))
if sum(featurings) > 0:
# Find the artist of which the least splits have been generated.
@@ -655,11 +644,9 @@ def extract_metadata(f):
return (artist, albums[0], years[0])
# Not sure what to do here.
logger.info(
f"Found {len(artists)} artists, {len(albums)} albums and "
f"{len(years)} years in metadata, so ignoring"
)
logger.debug("Artists: {artists}, Albums: {albums}, Years: {years}")
logger.info("Found %d artists, %d albums and %d years in metadata, so ignoring", len(artists),
len(albums), len(years))
logger.debug("Artists: %s, Albums: %s, Years: %s", artists, albums, years)
return (None, None, None)
@@ -691,7 +678,8 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
else:
tempdir = tempfile.gettempdir()
logger.info(f"Preparing to copy to a temporary directory for post processing: {albumpath}")
logger.info("Preparing to copy to a temporary directory for post processing: " + albumpath.decode(
headphones.SYS_ENCODING, 'replace'))
try:
file_name = os.path.basename(os.path.normpath(albumpath))
@@ -701,7 +689,8 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
prefix = "headphones_" + os.path.splitext(file_name)[0] + "_@hp@_"
new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir)
except Exception as e:
logger.error(f"Cannot create temp directory: {tempdir}. Error: {e}")
logger.error("Cannot create temp directory: " + tempdir.decode(
headphones.SYS_ENCODING, 'replace') + ". Error: " + str(e))
return None
# Attempt to stop multiple temp dirs being created for the same albumpath
@@ -712,21 +701,17 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
if len(glob.glob(workdir + '*/')) >= 3:
logger.error(
"Looks like a temp directory has previously been created "
"for this albumpath, not continuing "
)
"Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode(
headphones.SYS_ENCODING, 'replace'))
shutil.rmtree(new_folder)
return None
except Exception as e:
logger.warn(
"Cannot determine if already copied/processed, will copy anyway. "
f"Warning: {e}"
)
logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e))
# Copy to temp dir
try:
subdir = os.path.join(new_folder, "headphones")
logger.info(f"Copying files to {subdir}")
logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace'))
if not single:
shutil.copytree(albumpath, subdir)
else:
@@ -735,10 +720,9 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
# Update the album path with the new location
return subdir
except Exception as e:
logger.warn(
f"Cannot copy/move files to temp directory: {new_folder}. "
f"Not continuing. Error: {e}"
)
logger.warn("Cannot copy/move files to temp directory: " + new_folder.decode(headphones.SYS_ENCODING,
'replace') + ". Not continuing. Error: " + str(
e))
shutil.rmtree(new_folder)
return None
@@ -783,7 +767,7 @@ def cue_split(albumpath, keep_original_folder=False):
cuesplit.split(cue_dir)
except Exception as e:
os.chdir(cwd)
logger.warn(f"Cue not split. Error: {e}")
logger.warn("Cue not split: " + str(e))
return None
os.chdir(cwd)
@@ -821,7 +805,7 @@ def extract_song_data(s):
year = match.group("year")
return (name, album, year)
else:
logger.info(f"Couldn't parse {s} into a valid default format")
logger.info("Couldn't parse %s into a valid default format", s)
# newzbin default format
pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s\((?P<year>\d+?\))', re.VERBOSE)
@@ -832,7 +816,7 @@ def extract_song_data(s):
year = match.group("year")
return (name, album, year)
else:
logger.info(f"Couldn't parse {s} into a valid Newbin format")
logger.info("Couldn't parse %s into a valid Newbin format", s)
return (name, album, year)
@@ -845,7 +829,7 @@ def smartMove(src, dest, delete=True):
dest_path = os.path.join(dest, filename)
if os.path.isfile(dest_path):
logger.info(f"Destination file exists: {dest_path}")
logger.info('Destination file exists: %s', dest_path)
title = os.path.splitext(filename)[0]
ext = os.path.splitext(filename)[1]
i = 1
@@ -854,12 +838,13 @@ def smartMove(src, dest, delete=True):
if os.path.isfile(os.path.join(dest, newfile)):
i += 1
else:
logger.info(f"Renaming to {newfile}")
logger.info('Renaming to %s', newfile)
try:
os.rename(src, os.path.join(source_dir, newfile))
filename = newfile
except Exception as e:
logger.warn(f"Error renaming {src}: {e}")
logger.warn('Error renaming %s: %s',
src.decode(headphones.SYS_ENCODING, 'replace'), e)
break
if delete:
@@ -869,9 +854,8 @@ def smartMove(src, dest, delete=True):
except Exception as e:
exists = os.path.exists(dest_path)
if exists and os.path.getsize(source_path) == os.path.getsize(dest_path):
logger.warn(
f"Successfully moved {filename}, but something went wrong: {e}"
)
logger.warn('Successfully moved file "%s", but something went wrong: %s',
filename.decode(headphones.SYS_ENCODING, 'replace'), e)
os.unlink(source_path)
else:
# remove faultly copied file
@@ -880,11 +864,12 @@ def smartMove(src, dest, delete=True):
raise
else:
try:
logger.info(f"Copying {source_path} to {dest_path}")
logger.info('Copying "%s" to "%s"', source_path, dest_path)
shutil.copy(source_path, dest_path)
return True
except Exception as e:
logger.warn(f"Error copying {filename}: {e}")
logger.warn('Error copying file %s: %s', filename.decode(headphones.SYS_ENCODING, 'replace'),
e)
def walk_directory(basedir, followlinks=True):
@@ -893,7 +878,7 @@ def walk_directory(basedir, followlinks=True):
with care. In case a folder is already processed, don't traverse it again.
"""
from . import logger
import logger
# Add the base path, because symlinks poiting to the basedir should not be
# traversed again.
@@ -907,10 +892,8 @@ def walk_directory(basedir, followlinks=True):
real_path = os.path.abspath(os.readlink(path))
if real_path in traversed:
logger.debug(
f"Skipping {path} since it is a symlink to "
f"{real_path}, which is already visited."
)
logger.debug("Skipping '%s' since it is a symlink to "
"'%s', which is already visited.", path, real_path)
else:
traversed.append(real_path)
@@ -952,15 +935,22 @@ def sab_sanitize_foldername(name):
FL_ILLEGAL = CH_ILLEGAL + ':\x92"'
FL_LEGAL = CH_LEGAL + "-''"
if not name:
return
uFL_ILLEGAL = FL_ILLEGAL.decode('latin-1')
uFL_LEGAL = FL_LEGAL.decode('latin-1')
name = unidecode(name)
if not name:
return name
if isinstance(name, unicode):
illegal = uFL_ILLEGAL
legal = uFL_LEGAL
else:
illegal = FL_ILLEGAL
legal = FL_LEGAL
lst = []
for ch in name.strip():
if ch in FL_ILLEGAL:
ch = FL_LEGAL[FL_ILLEGAL.find(ch)]
if ch in illegal:
ch = legal[illegal.find(ch)]
lst.append(ch)
else:
lst.append(ch)
@@ -1016,7 +1006,7 @@ def create_https_certificates(ssl_cert, ssl_key):
with open(ssl_cert, "w") as fp:
fp.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
except IOError as e:
logger.error(f"Error creating SSL key and certificate: e")
logger.error("Error creating SSL key and certificate: %s", e)
return False
return True
+18 -18
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from .unittestcompat import TestCase
from unittestcompat import TestCase
from headphones.helpers import clean_name
@@ -8,28 +8,28 @@ class HelpersTest(TestCase):
def test_clean_name(self):
"""helpers: check correctness of clean_name() function"""
cases = {
' Weiße & rose ': 'Weisse and rose',
'Multiple / spaces': 'Multiple spaces',
'Kevin\'s m²': 'Kevins m2',
'Symphonęy Nº9': 'Symphoney No.9',
'ÆæßðÞIJij': 'AeaessdThIJıj',
'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral '
'apoplexy remix',
'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu '
'zbojow',
'Arbetets Söner och Döttrar': 'arbetets soner och dottrar',
'Björk Guðmundsdóttir': 'bjork gudmundsdottir',
'L\'Arc~en~Ciel': 'larc en ciel',
'Orquesta de la Luz (オルケスタ・デ・ラ・ルス)':
'Orquesta de la Luz オルケスタ デ ラ ルス'
u' Weiße & rose ': 'Weisse and rose',
u'Multiple / spaces': 'Multiple spaces',
u'Kevin\'s m²': 'Kevins m2',
u'Symphonęy Nº9': 'Symphoney No.9',
u'ÆæßðÞIJij': u'AeaessdThIJıj',
u'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral '
'apoplexy remix',
u'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu '
'zbojow',
u'Arbetets Söner och Döttrar': 'arbetets soner och dottrar',
u'Björk Guðmundsdóttir': 'bjork gudmundsdottir',
u'L\'Arc~en~Ciel': 'larc en ciel',
u'Orquesta de la Luz (オルケスタ・デ・ラ・ルス)':
u'Orquesta de la Luz オルケスタ デ ラ ルス'
}
for first, second in cases.items():
for first, second in cases.iteritems():
nf = clean_name(first).lower()
ns = clean_name(second).lower()
self.assertEqual(
nf, ns, "check cleaning of case (%s,"
"%s)" % (nf, ns)
nf, ns, u"check cleaning of case (%s,"
u"%s)" % (nf, ns)
)
def test_clean_name_nonunicode(self):
+14 -14
View File
@@ -16,7 +16,7 @@
import time
from headphones import logger, helpers, db, mb, lastfm, metacritic
from mediafile import MediaFile
from beets.mediafile import MediaFile
import headphones
blacklisted_special_artist_names = ['[anonymous]', '[data]', '[no artist]',
@@ -39,7 +39,7 @@ def is_exists(artistid):
if any(artistid in x for x in artistlist):
logger.info(artistlist[0][
1] + " is already in the database. Updating 'have tracks', but not artist information")
1] + u" is already in the database. Updating 'have tracks', but not artist information")
return True
else:
return False
@@ -53,7 +53,7 @@ def artistlist_to_mbids(artistlist, forced=False):
# If adding artists through Manage New Artists, they're coming through as non-unicode (utf-8?)
# and screwing everything up
if not isinstance(artist, str):
if not isinstance(artist, unicode):
try:
artist = artist.decode('utf-8', 'replace')
except Exception:
@@ -184,7 +184,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
else:
sortname = artist['artist_name']
logger.info("Now adding/updating: " + artist['artist_name'])
logger.info(u"Now adding/updating: " + artist['artist_name'])
controlValueDict = {"ArtistID": artistid}
newValueDict = {"ArtistName": artist['artist_name'],
"ArtistSortName": sortname,
@@ -263,8 +263,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
new_releases = mb.get_new_releases(rgid, includeExtras)
else:
if check_release_date is None or check_release_date == "None":
if not headphones.CONFIG.MB_IGNORE_AGE_MISSING:
if check_release_date is None or check_release_date == u"None":
if headphones.CONFIG.MB_IGNORE_AGE_MISSING is not 1:
logger.info("[%s] Now updating: %s (No Release Date)" % (artist['artist_name'], rg['title']))
new_releases = mb.get_new_releases(rgid, includeExtras, True)
else:
@@ -517,7 +517,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
marked_as_downloaded = True
logger.info(
"[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title']))
u"[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title']))
try:
cache.getThumb(AlbumID=rg['id'])
except Exception as e:
@@ -530,19 +530,19 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
album_searches.append(rg['id'])
else:
if skip_log == 0:
logger.info("[%s] No new releases, so no changes made to %s" % (
logger.info(u"[%s] No new releases, so no changes made to %s" % (
artist['artist_name'], rg['title']))
time.sleep(3)
finalize_update(artistid, artist['artist_name'], errors)
logger.info("Seeing if we need album art for: %s" % artist['artist_name'])
logger.info(u"Seeing if we need album art for: %s" % artist['artist_name'])
try:
cache.getThumb(ArtistID=artistid)
except Exception as e:
logger.error("Error getting album art: %s", e)
logger.info("Fetching Metacritic reviews for: %s" % artist['artist_name'])
logger.info(u"Fetching Metacritic reviews for: %s" % artist['artist_name'])
try:
metacritic.update(artistid, artist['artist_name'], artist['releasegroups'])
except Exception as e:
@@ -554,7 +554,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
artist['artist_name'], artist['artist_name']))
else:
myDB.action('DELETE FROM newartists WHERE ArtistName = ?', [artist['artist_name']])
logger.info("Updating complete for: %s" % artist['artist_name'])
logger.info(u"Updating complete for: %s" % artist['artist_name'])
# Start searching for newly added albums
if album_searches:
@@ -663,7 +663,7 @@ def addReleaseById(rid, rgid=None):
sortname = release_dict['artist_name']
logger.info(
"Now manually adding: " + release_dict['artist_name'] + " - with status Paused")
u"Now manually adding: " + release_dict['artist_name'] + " - with status Paused")
controlValueDict = {"ArtistID": release_dict['artist_id']}
newValueDict = {"ArtistName": release_dict['artist_name'],
"ArtistSortName": sortname,
@@ -696,7 +696,7 @@ def addReleaseById(rid, rgid=None):
if not rg_exists and release_dict or status == 'Loading' and release_dict: # it should never be the case that we have an rg and not the artist
# but if it is this will fail
logger.info("Now adding-by-id album (" + release_dict['title'] + ") from id: " + rgid)
logger.info(u"Now adding-by-id album (" + release_dict['title'] + ") from id: " + rgid)
controlValueDict = {"AlbumID": rgid}
if status != 'Loading':
status = 'Wanted'
@@ -772,7 +772,7 @@ def addReleaseById(rid, rgid=None):
# Start a search for the album
if headphones.CONFIG.AUTOWANT_MANUALLY_ADDED:
from . import searcher
import searcher
searcher.searchforalbum(rgid, False)
elif not rg_exists and not release_dict:
+2 -2
View File
@@ -22,7 +22,7 @@ from headphones import db, logger, request
TIMEOUT = 60.0 # seconds
REQUEST_LIMIT = 1.0 / 5 # seconds
ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
ENTRY_POINT = "http://ws.audioscrobbler.com/2.0/"
API_KEY = "395e6ec6bb557382fc41fde867bce66f"
# Required for API request limit
@@ -91,7 +91,7 @@ def getSimilar():
for artist, mbid in artistlist:
count[artist, mbid] += 1
items = list(count.items())
items = count.items()
top_list = sorted(items, key=lambda x: x[1], reverse=True)[:25]
random.shuffle(top_list)
+105 -85
View File
@@ -17,7 +17,7 @@ import os
import math
import headphones
from mediafile import MediaFile, FileTypeError, UnreadableFileError
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
from headphones import db, logger, helpers, importer, lastfm
@@ -30,64 +30,72 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
if not dir:
if not headphones.CONFIG.MUSIC_DIR:
logger.info(
"No music directory configured. Add it under "
"Manage -> Scan Music Library"
)
return
else:
dir = headphones.CONFIG.MUSIC_DIR
# If we're appending a dir, it's coming from the post processor which is
# already bytestring
if not append or artistScan:
dir = dir.encode(headphones.SYS_ENCODING)
if not os.path.isdir(dir):
logger.warn(f"Cannot find music directory: {dir}")
logger.warn('Cannot find directory: %s. Not scanning' % dir.decode(headphones.SYS_ENCODING,
'replace'))
return
myDB = db.DBConnection()
new_artists = []
logger.info(f"Scanning music directory: {dir}")
logger.info('Scanning music directory: %s' % dir.decode(headphones.SYS_ENCODING, 'replace'))
if not append:
# Clean up bad filepaths. Queries can take some time, ensure all results are loaded before processing
if ArtistID:
dbtracks = myDB.action(
tracks = myDB.action(
'SELECT Location FROM alltracks WHERE ArtistID = ? AND Location IS NOT NULL UNION SELECT Location FROM tracks WHERE ArtistID = ? AND Location '
'IS NOT NULL',
[ArtistID, ArtistID])
else:
dbtracks = myDB.action(
tracks = myDB.action(
'SELECT Location FROM alltracks WHERE Location IS NOT NULL UNION SELECT Location FROM tracks WHERE Location IS NOT NULL')
for track in dbtracks:
track_location = track['Location']
if not os.path.isfile(track_location):
locations = []
for track in tracks:
locations.append(track['Location'])
for location in locations:
encoded_track_string = location.encode(headphones.SYS_ENCODING, 'replace')
if not os.path.isfile(encoded_track_string):
myDB.action('UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, track_location])
[None, None, None, location])
myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, track_location])
[None, None, None, location])
if ArtistName:
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have WHERE ArtistName = ? COLLATE NOCASE', [ArtistName])
else:
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have')
locations = []
for track in del_have_tracks:
if not os.path.isfile(track['Location']):
if track['ArtistName']:
locations.append([track['Location'], track['ArtistName']])
for location in locations:
encoded_track_string = location[0].encode(headphones.SYS_ENCODING, 'replace')
if not os.path.isfile(encoded_track_string):
if location[1]:
# Make sure deleted files get accounted for when updating artist track counts
new_artists.append(track['ArtistName'])
myDB.action('DELETE FROM have WHERE Location=?', [Track['Location']])
new_artists.append(location[1])
myDB.action('DELETE FROM have WHERE Location=?', [location[0]])
logger.info(
f"{Track['Location']} removed from Headphones, as it "
f"is no longer on disk"
)
'File %s removed from Headphones, as it is no longer on disk' % encoded_track_string.decode(
headphones.SYS_ENCODING, 'replace'))
bitrates = []
track_list = []
song_list = []
latest_subdirectory = []
new_track_count = 0
new_song_count = 0
file_count = 0
for r, d, f in helpers.walk_directory(dir):
@@ -102,16 +110,32 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
subdirectory = r.replace(dir, '')
latest_subdirectory.append(subdirectory)
track_path = os.path.join(r, files)
if file_count == 0 and r.replace(dir, '') != '':
logger.info("[%s] Now scanning subdirectory %s" % (
dir.decode(headphones.SYS_ENCODING, 'replace'),
subdirectory.decode(headphones.SYS_ENCODING, 'replace')))
elif latest_subdirectory[file_count] != latest_subdirectory[
file_count - 1] and file_count != 0:
logger.info("[%s] Now scanning subdirectory %s" % (
dir.decode(headphones.SYS_ENCODING, 'replace'),
subdirectory.decode(headphones.SYS_ENCODING, 'replace')))
song = os.path.join(r, files)
# We need the unicode path to use for logging, inserting into database
unicode_song_path = song.decode(headphones.SYS_ENCODING, 'replace')
# Try to read the metadata
try:
f = MediaFile(track_path)
f = MediaFile(song)
except (FileTypeError, UnreadableFileError):
logger.warning(f"Cannot read `{track_path}`. It may be corrupted or not a media file.")
logger.warning(
"Cannot read media file '%s', skipping. It may be corrupted or not a media file.",
unicode_song_path)
continue
except IOError:
logger.warning(f"Cannnot read `{track_path}`. Does the file exists?")
logger.warning("Cannnot read media file '%s', skipping. Does the file exists?",
unicode_song_path)
continue
# Grab the bitrates for the auto detect bit rate option
@@ -126,15 +150,15 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
else:
f_artist = None
# Add the track to our track list -
# TODO: skip adding tracks without the minimum requisite information (just a matter of putting together the right if statements)
# Add the song to our song list -
# TODO: skip adding songs without the minimum requisite information (just a matter of putting together the right if statements)
if f_artist and f.album and f.title:
CleanName = helpers.clean_name(f_artist + ' ' + f.album + ' ' + f.title)
else:
CleanName = None
controlValueDict = {'Location': track_path}
controlValueDict = {'Location': unicode_song_path}
newValueDict = {'TrackID': f.mb_trackid,
# 'ReleaseID' : f.mb_albumid,
@@ -150,24 +174,24 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
'CleanName': CleanName
}
# track_list.append(track_dict)
check_exist_track = myDB.action("SELECT * FROM have WHERE Location=?",
[track_path]).fetchone()
# Only attempt to match tracks that are new, haven't yet been matched, or metadata has changed.
if not check_exist_track:
# song_list.append(song_dict)
check_exist_song = myDB.action("SELECT * FROM have WHERE Location=?",
[unicode_song_path]).fetchone()
# Only attempt to match songs that are new, haven't yet been matched, or metadata has changed.
if not check_exist_song:
# This is a new track
if f_artist:
new_artists.append(f_artist)
myDB.upsert("have", newValueDict, controlValueDict)
new_track_count += 1
new_song_count += 1
else:
if check_exist_track['ArtistName'] != f_artist or check_exist_track[
'AlbumTitle'] != f.album or check_exist_track['TrackTitle'] != f.title:
if check_exist_song['ArtistName'] != f_artist or check_exist_song[
'AlbumTitle'] != f.album or check_exist_song['TrackTitle'] != f.title:
# Important track metadata has been modified, need to run matcher again
if f_artist and f_artist != check_exist_track['ArtistName']:
if f_artist and f_artist != check_exist_song['ArtistName']:
new_artists.append(f_artist)
elif f_artist and f_artist == check_exist_track['ArtistName'] and \
check_exist_track['Matched'] != "Ignored":
elif f_artist and f_artist == check_exist_song['ArtistName'] and \
check_exist_song['Matched'] != "Ignored":
new_artists.append(f_artist)
else:
continue
@@ -176,56 +200,51 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
myDB.upsert("have", newValueDict, controlValueDict)
myDB.action(
'UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, track_path])
[None, None, None, unicode_song_path])
myDB.action(
'UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, track_path])
new_track_count += 1
[None, None, None, unicode_song_path])
new_song_count += 1
else:
# This track information hasn't changed
if f_artist and check_exist_track['Matched'] != "Ignored":
if f_artist and check_exist_song['Matched'] != "Ignored":
new_artists.append(f_artist)
file_count += 1
# Now we start track matching
logger.info(f"{new_track_count} new/modified tracks found and added to the database")
dbtracks = myDB.action(
"SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
[f"{dir}%"]
)
dbtracks_count = myDB.action(
"SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
[f"{dir}%"]
).fetchone()[0]
logger.info(f"Found {dbtracks_count} new/modified tracks in `{dir}`")
logger.info("Matching tracks to the appropriate releases....")
logger.info("%s new/modified songs found and added to the database" % new_song_count)
song_list = myDB.action("SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"])
total_number_of_songs = \
myDB.action("SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]).fetchone()[0]
logger.info("Found " + str(total_number_of_songs) + " new/modified tracks in: '" + dir.decode(
headphones.SYS_ENCODING, 'replace') + "'. Matching tracks to the appropriate releases....")
# Sort the track_list by most vague (e.g. no trackid or releaseid)
# to most specific (both trackid & releaseid)
# When we insert into the database, the tracks with the most
# specific information will overwrite the more general matches
# Sort the song_list by most vague (e.g. no trackid or releaseid) to most specific (both trackid & releaseid)
# When we insert into the database, the tracks with the most specific information will overwrite the more general matches
sorted_dbtracks = helpers.multikeysort(dbtracks, ['ArtistName', 'AlbumTitle'])
# song_list = helpers.multikeysort(song_list, ['ReleaseID', 'TrackID'])
song_list = helpers.multikeysort(song_list, ['ArtistName', 'AlbumTitle'])
# We'll use this to give a % completion, just because the
# track matching might take a while
tracks_completed = 0
latest_artist = None
# We'll use this to give a % completion, just because the track matching might take a while
song_count = 0
latest_artist = []
last_completion_percentage = 0
prev_artist_name = None
artistid = None
for track in sorted_dbtracks:
for song in song_list:
if latest_artist != track['ArtistName']:
logger.info(f"Now matching tracks by {track['ArtistName']}")
latest_artist = track['ArtistName']
latest_artist.append(song['ArtistName'])
if song_count == 0:
logger.info("Now matching songs by %s" % song['ArtistName'])
elif latest_artist[song_count] != latest_artist[song_count - 1] and song_count != 0:
logger.info("Now matching songs by %s" % song['ArtistName'])
tracks_completed += 1
completion_percentage = math.floor(
float(tracks_completed) / dbtracks_count * 1000
) / 10
song_count += 1
completion_percentage = math.floor(float(song_count) / total_number_of_songs * 1000) / 10
if completion_percentage >= (last_completion_percentage + 10):
logger.info("Track matching is " + str(completion_percentage) + "% complete")
@@ -238,9 +257,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
albumid = None
if track['ArtistName'] and track['CleanName']:
artist_name = track['ArtistName']
clean_name = track['CleanName']
if song['ArtistName'] and song['CleanName']:
artist_name = song['ArtistName']
clean_name = song['CleanName']
# Only update if artist is in the db
if artist_name != prev_artist_name:
@@ -278,12 +297,12 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
# matching on CleanName should be enough, ensure it's the same artist just in case
# Update tracks
dbtrack = myDB.action('SELECT AlbumID, ArtistName FROM tracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
if dbtrack:
albumid = dbtrack['AlbumID']
track = myDB.action('SELECT AlbumID, ArtistName FROM tracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
if track:
albumid = track['AlbumID']
myDB.action(
'UPDATE tracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?',
[track['Location'], track['BitRate'], track['Format'], clean_name, artistid])
[song['Location'], song['BitRate'], song['Format'], clean_name, artistid])
# Update alltracks
alltrack = myDB.action('SELECT AlbumID, ArtistName FROM alltracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
@@ -291,25 +310,26 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
albumid = alltrack['AlbumID']
myDB.action(
'UPDATE alltracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?',
[track['Location'], track['BitRate'], track['Format'], clean_name, artistid])
[song['Location'], song['BitRate'], song['Format'], clean_name, artistid])
# Update have
controlValueDict2 = {'Location': track['Location']}
controlValueDict2 = {'Location': song['Location']}
if albumid:
newValueDict2 = {'Matched': albumid}
else:
newValueDict2 = {'Matched': "Failed"}
myDB.upsert("have", newValueDict2, controlValueDict2)
# myDB.action('INSERT INTO have (ArtistName, AlbumTitle, TrackNumber, TrackTitle, TrackLength, BitRate, Genre, Date, TrackID, Location, CleanName, Format) VALUES( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [track['ArtistName'], track['AlbumTitle'], track['TrackNumber'], track['TrackTitle'], track['TrackLength'], track['BitRate'], track['Genre'], track['Date'], track['TrackID'], track['Location'], CleanName, track['Format']])
# myDB.action('INSERT INTO have (ArtistName, AlbumTitle, TrackNumber, TrackTitle, TrackLength, BitRate, Genre, Date, TrackID, Location, CleanName, Format) VALUES( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [song['ArtistName'], song['AlbumTitle'], song['TrackNumber'], song['TrackTitle'], song['TrackLength'], song['BitRate'], song['Genre'], song['Date'], song['TrackID'], song['Location'], CleanName, song['Format']])
logger.info(f"Completed matching tracks from `{dir}`")
logger.info('Completed matching tracks from directory: %s' % dir.decode(headphones.SYS_ENCODING,
'replace'))
if not append or artistScan:
logger.info('Updating scanned artist track counts')
# Clean up the new artist list
unique_artists = list({}.fromkeys(new_artists).keys())
unique_artists = {}.fromkeys(new_artists).keys()
# # Don't think we need to do this, check the db instead below
#
+3 -3
View File
@@ -4,7 +4,7 @@ Locking-related classes
import time
import threading
import queue
import Queue
import headphones.logger
@@ -29,7 +29,7 @@ class TimedLock(object):
self.lock = threading.Lock()
self.last_used = 0
self.minimum_delta = minimum_delta
self.queue = queue.Queue()
self.queue = Queue.Queue()
def __enter__(self):
"""
@@ -47,7 +47,7 @@ class TimedLock(object):
seconds = self.queue.get(False)
headphones.logger.debug('Sleeping %s (queued)', seconds)
time.sleep(seconds)
except queue.Empty:
except Queue.Empty:
continue
self.queue.task_done()
+1 -2
View File
@@ -153,8 +153,7 @@ def initLogger(console=False, log_dir=False, verbose=False):
file_formatter = logging.Formatter(
'%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE,
backupCount=MAX_FILES,
encoding='utf8')
backupCount=MAX_FILES)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
+6 -6
View File
@@ -13,7 +13,7 @@
# You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import html.entities
import htmlentitydefs
import re
from headphones import logger, request
@@ -25,7 +25,7 @@ def getLyrics(artist, song):
"fmt": 'xml'
}
url = 'https://lyrics.wikia.com/api.php'
url = 'http://lyrics.wikia.com/api.php'
data = request.request_minidom(url, params=params)
if not data:
@@ -53,7 +53,7 @@ def getLyrics(artist, song):
'''<div class='lyricbox'><span style="padding:1em"><a href="/Category:Instrumental" title="Instrumental">''').search(
lyricspage)
if m:
return '(Instrumental)'
return u'(Instrumental)'
else:
logger.warn('Cannot find lyrics on: %s' % lyricsurl)
return
@@ -72,7 +72,7 @@ def convert_html_entities(s):
name = hit[2:-1]
try:
entnum = int(name)
s = s.replace(hit, chr(entnum))
s = s.replace(hit, unichr(entnum))
except ValueError:
pass
@@ -83,7 +83,7 @@ def convert_html_entities(s):
hits.remove(amp)
for hit in hits:
name = hit[1:-1]
if name in html.entities.name2codepoint:
s = s.replace(hit, chr(html.entities.name2codepoint[name]))
if name in htmlentitydefs.name2codepoint:
s = s.replace(hit, unichr(htmlentitydefs.name2codepoint[name]))
s = s.replace(amp, "&")
return s
+86 -74
View File
@@ -112,9 +112,9 @@ def findArtist(name, limit=1):
return False
for result in artistResults:
if 'disambiguation' in result:
uniquename = str(result['sort-name'] + " (" + result['disambiguation'] + ")")
uniquename = unicode(result['sort-name'] + " (" + result['disambiguation'] + ")")
else:
uniquename = str(result['sort-name'])
uniquename = unicode(result['sort-name'])
if result['name'] != uniquename and limit == 1:
logger.info(
'Found an artist with a disambiguation: %s - doing an album based search' % name)
@@ -124,16 +124,20 @@ def findArtist(name, limit=1):
'Cannot determine the best match from an artist/album search. Using top match instead')
artistlist.append({
# Just need the artist id if the limit is 1
'id': str(result['id']),
# 'name': unicode(result['sort-name']),
# 'uniquename': uniquename,
'id': unicode(result['id']),
# 'url': unicode("http://musicbrainz.org/artist/" + result['id']),#probably needs to be changed
# 'score': int(result['ext:score'])
})
else:
artistlist.append(artistdict)
else:
artistlist.append({
'name': str(result['sort-name']),
'name': unicode(result['sort-name']),
'uniquename': uniquename,
'id': str(result['id']),
'url': str("https://musicbrainz.org/artist/" + result['id']),
'id': unicode(result['id']),
'url': unicode("http://musicbrainz.org/artist/" + result['id']),
# probably needs to be changed
'score': int(result['ext:score'])
})
@@ -183,7 +187,7 @@ def findRelease(name, limit=1, artist=None):
if tracks:
tracks += ' + '
tracks += str(medium['track-count'])
for format, count in list(format_dict.items()):
for format, count in format_dict.items():
if formats:
formats += ' + '
if count > 1:
@@ -199,22 +203,22 @@ def findRelease(name, limit=1, artist=None):
rg_type = secondary_type
releaselist.append({
'uniquename': str(result['artist-credit'][0]['artist']['name']),
'title': str(title),
'id': str(result['artist-credit'][0]['artist']['id']),
'albumid': str(result['id']),
'url': str(
"https://musicbrainz.org/artist/" + result['artist-credit'][0]['artist']['id']),
'uniquename': unicode(result['artist-credit'][0]['artist']['name']),
'title': unicode(title),
'id': unicode(result['artist-credit'][0]['artist']['id']),
'albumid': unicode(result['id']),
'url': unicode(
"http://musicbrainz.org/artist/" + result['artist-credit'][0]['artist']['id']),
# probably needs to be changed
'albumurl': str("https://musicbrainz.org/release/" + result['id']),
'albumurl': unicode("http://musicbrainz.org/release/" + result['id']),
# probably needs to be changed
'score': int(result['ext:score']),
'date': str(result['date']) if 'date' in result else '',
'country': str(result['country']) if 'country' in result else '',
'formats': str(formats),
'tracks': str(tracks),
'rgid': str(result['release-group']['id']),
'rgtype': str(rg_type)
'date': unicode(result['date']) if 'date' in result else '',
'country': unicode(result['country']) if 'country' in result else '',
'formats': unicode(formats),
'tracks': unicode(tracks),
'rgid': unicode(result['release-group']['id']),
'rgtype': unicode(rg_type)
})
return releaselist
@@ -236,15 +240,15 @@ def findSeries(name, limit=1):
return False
for result in seriesResults:
if 'disambiguation' in result:
uniquename = str(result['name'] + " (" + result['disambiguation'] + ")")
uniquename = unicode(result['name'] + " (" + result['disambiguation'] + ")")
else:
uniquename = str(result['name'])
uniquename = unicode(result['name'])
serieslist.append({
'uniquename': uniquename,
'name': str(result['name']),
'type': str(result['type']),
'id': str(result['id']),
'url': str("https://musicbrainz.org/series/" + result['id']),
'name': unicode(result['name']),
'type': unicode(result['type']),
'id': unicode(result['id']),
'url': unicode("http://musicbrainz.org/series/" + result['id']),
# probably needs to be changed
'score': int(result['ext:score'])
})
@@ -280,19 +284,19 @@ def getArtist(artistid, extrasonly=False):
if not artist:
return False
artist_dict['artist_name'] = str(artist['name'])
artist_dict['artist_name'] = unicode(artist['name'])
releasegroups = []
if not extrasonly:
for rg in artist['release-group-list']:
if "secondary-type-list" in list(rg.keys()): # only add releases without a secondary type
if "secondary-type-list" in rg.keys(): # only add releases without a secondary type
continue
releasegroups.append({
'title': str(rg['title']),
'id': str(rg['id']),
'url': "https://musicbrainz.org/release-group/" + rg['id'],
'type': str(rg['type'])
'title': unicode(rg['title']),
'id': unicode(rg['id']),
'url': u"http://musicbrainz.org/release-group/" + rg['id'],
'type': unicode(rg['type'])
})
# See if we need to grab extras. Artist specific extras take precedence over global option
@@ -310,7 +314,7 @@ def getArtist(artistid, extrasonly=False):
# Need to convert extras string from something like '2,5.6' to ['ep','live','remix'] (append new extras to end)
if db_artist['Extras']:
extras = list(map(int, db_artist['Extras'].split(',')))
extras = map(int, db_artist['Extras'].split(','))
else:
extras = []
extras_list = headphones.POSSIBLE_EXTRAS
@@ -350,10 +354,10 @@ def getArtist(artistid, extrasonly=False):
rg_type = secondary_type
releasegroups.append({
'title': str(rg['title']),
'id': str(rg['id']),
'url': "https://musicbrainz.org/release-group/" + rg['id'],
'type': str(rg_type)
'title': unicode(rg['title']),
'id': unicode(rg['id']),
'url': u"http://musicbrainz.org/release-group/" + rg['id'],
'type': unicode(rg_type)
})
artist_dict['releasegroups'] = releasegroups
return artist_dict
@@ -378,10 +382,10 @@ def getSeries(seriesid):
return False
if 'disambiguation' in series:
series_dict['artist_name'] = str(
series['name'] + " (" + str(series['disambiguation']) + ")")
series_dict['artist_name'] = unicode(
series['name'] + " (" + unicode(series['disambiguation']) + ")")
else:
series_dict['artist_name'] = str(series['name'])
series_dict['artist_name'] = unicode(series['name'])
releasegroups = []
@@ -444,42 +448,42 @@ def getRelease(releaseid, include_artist_info=True):
if not results:
return False
release['title'] = str(results['title'])
release['id'] = str(results['id'])
release['asin'] = str(results['asin']) if 'asin' in results else None
release['date'] = str(results['date']) if 'date' in results else None
release['title'] = unicode(results['title'])
release['id'] = unicode(results['id'])
release['asin'] = unicode(results['asin']) if 'asin' in results else None
release['date'] = unicode(results['date']) if 'date' in results else None
try:
release['format'] = str(results['medium-list'][0]['format'])
release['format'] = unicode(results['medium-list'][0]['format'])
except:
release['format'] = 'Unknown'
release['format'] = u'Unknown'
try:
release['country'] = str(results['country'])
release['country'] = unicode(results['country'])
except:
release['country'] = 'Unknown'
release['country'] = u'Unknown'
if include_artist_info:
if 'release-group' in results:
release['rgid'] = str(results['release-group']['id'])
release['rg_title'] = str(results['release-group']['title'])
release['rgid'] = unicode(results['release-group']['id'])
release['rg_title'] = unicode(results['release-group']['title'])
try:
release['rg_type'] = str(results['release-group']['type'])
release['rg_type'] = unicode(results['release-group']['type'])
if release['rg_type'] == 'Album' and 'secondary-type-list' in results[
'release-group']:
secondary_type = str(results['release-group']['secondary-type-list'][0])
secondary_type = unicode(results['release-group']['secondary-type-list'][0])
if secondary_type != release['rg_type']:
release['rg_type'] = secondary_type
except KeyError:
release['rg_type'] = 'Unknown'
release['rg_type'] = u'Unknown'
else:
logger.warn("Release " + releaseid + "had no ReleaseGroup associated")
release['artist_name'] = str(results['artist-credit'][0]['artist']['name'])
release['artist_id'] = str(results['artist-credit'][0]['artist']['id'])
release['artist_name'] = unicode(results['artist-credit'][0]['artist']['name'])
release['artist_id'] = unicode(results['artist-credit'][0]['artist']['id'])
release['tracks'] = getTracksFromRelease(results)
@@ -525,7 +529,7 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
force_repackage1 = 0
if len(results) != 0:
for release_mark in results:
release_list.append(str(release_mark['id']))
release_list.append(unicode(release_mark['id']))
release_title = release_mark['title']
remove_missing_releases = myDB.action("SELECT ReleaseID FROM allalbums WHERE AlbumID=?",
[rgid])
@@ -557,31 +561,31 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
# DELETE all references to this release since we're updating it anyway.
myDB.action('DELETE from allalbums WHERE ReleaseID=?', [rel_id_check])
myDB.action('DELETE from alltracks WHERE ReleaseID=?', [rel_id_check])
release['AlbumTitle'] = str(releasedata['title'])
release['AlbumID'] = str(rgid)
release['AlbumASIN'] = str(releasedata['asin']) if 'asin' in releasedata else None
release['ReleaseDate'] = str(releasedata['date']) if 'date' in releasedata else None
release['AlbumTitle'] = unicode(releasedata['title'])
release['AlbumID'] = unicode(rgid)
release['AlbumASIN'] = unicode(releasedata['asin']) if 'asin' in releasedata else None
release['ReleaseDate'] = unicode(releasedata['date']) if 'date' in releasedata else None
release['ReleaseID'] = releasedata['id']
if 'release-group' not in releasedata:
raise Exception('No release group associated with release id ' + releasedata[
'id'] + ' album id' + rgid)
release['Type'] = str(releasedata['release-group']['type'])
release['Type'] = unicode(releasedata['release-group']['type'])
if release['Type'] == 'Album' and 'secondary-type-list' in releasedata['release-group']:
secondary_type = str(releasedata['release-group']['secondary-type-list'][0])
secondary_type = unicode(releasedata['release-group']['secondary-type-list'][0])
if secondary_type != release['Type']:
release['Type'] = secondary_type
# making the assumption that the most important artist will be first in the list
if 'artist-credit' in releasedata:
release['ArtistID'] = str(releasedata['artist-credit'][0]['artist']['id'])
release['ArtistName'] = str(releasedata['artist-credit-phrase'])
release['ArtistID'] = unicode(releasedata['artist-credit'][0]['artist']['id'])
release['ArtistName'] = unicode(releasedata['artist-credit-phrase'])
else:
logger.warn('Release ' + releasedata['id'] + ' has no Artists associated.')
return False
release['ReleaseCountry'] = str(
releasedata['country']) if 'country' in releasedata else 'Unknown'
release['ReleaseCountry'] = unicode(
releasedata['country']) if 'country' in releasedata else u'Unknown'
# assuming that the list will contain media and that the format will be consistent
try:
additional_medium = ''
@@ -596,9 +600,9 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
disc_number = str(medium_count) + 'x'
packaged_medium = disc_number + releasedata['medium-list'][0][
'format'] + additional_medium
release['ReleaseFormat'] = str(packaged_medium)
release['ReleaseFormat'] = unicode(packaged_medium)
except:
release['ReleaseFormat'] = 'Unknown'
release['ReleaseFormat'] = u'Unknown'
release['Tracks'] = getTracksFromRelease(releasedata)
@@ -680,14 +684,14 @@ def getTracksFromRelease(release):
for medium in release['medium-list']:
for track in medium['track-list']:
try:
track_title = str(track['title'])
track_title = unicode(track['title'])
except:
track_title = str(track['recording']['title'])
track_title = unicode(track['recording']['title'])
tracks.append({
'number': totalTracks,
'title': track_title,
'id': str(track['recording']['id']),
'url': "https://musicbrainz.org/track/" + track['recording']['id'],
'id': unicode(track['recording']['id']),
'url': u"http://musicbrainz.org/track/" + track['recording']['id'],
'duration': int(track['length']) if 'length' in track else 0
})
totalTracks += 1
@@ -729,7 +733,15 @@ def findArtistbyAlbum(name):
for releaseGroup in results:
newArtist = releaseGroup['artist-credit'][0]['artist']
# Only need the artist ID if we're doing an artist+album lookup
artist_dict['id'] = str(newArtist['id'])
# if 'disambiguation' in newArtist:
# uniquename = unicode(newArtist['sort-name'] + " (" + newArtist['disambiguation'] + ")")
# else:
# uniquename = unicode(newArtist['sort-name'])
# artist_dict['name'] = unicode(newArtist['sort-name'])
# artist_dict['uniquename'] = uniquename
artist_dict['id'] = unicode(newArtist['id'])
# artist_dict['url'] = u'http://musicbrainz.org/artist/' + newArtist['id']
# artist_dict['score'] = int(releaseGroup['ext:score'])
return artist_dict
@@ -756,7 +768,7 @@ def findAlbumID(artist=None, album=None):
if len(results) < 1:
return False
rgid = str(results[0]['id'])
rgid = unicode(results[0]['id'])
return rgid
+1 -1
View File
@@ -35,7 +35,7 @@ def update(artistid, artist_name, release_groups):
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2243.2 Safari/537.36'}
url = "https://www.metacritic.com/person/" + mc_artist_name + "?filter-options=music&sort_options=date&num_items=100"
url = "http://www.metacritic.com/person/" + mc_artist_name + "?filter-options=music&sort_options=date&num_items=100"
res = request.request_soup(url, headers=headers, whitelist_status_code=404)
+20 -16
View File
@@ -17,8 +17,8 @@
Track/album metadata handling routines.
"""
from mediafile import MediaFile, UnreadableFileError
from __future__ import print_function
from beets.mediafile import MediaFile, UnreadableFileError
import headphones
from headphones import logger
import os.path
@@ -38,7 +38,6 @@ class MetadataDict(dict):
lowercase) in member variable self._lower. If case-sensitive lookup
fails, another case-insensitive attempt is made.
"""
def __setitem__(self, key, value):
super(MetadataDict, self).__setitem__(key, value)
self._lower.__setitem__(key.lower(), value)
@@ -61,7 +60,7 @@ class MetadataDict(dict):
self._lower = {}
if seq is not None:
try:
self.add_items(iter(seq.items()))
self.add_items(seq.iteritems())
except KeyError:
self.add_items(seq)
@@ -104,11 +103,11 @@ def _verify_var_type(val):
"""
Check if type of value is allowed as a variable in pathname substitution.
"""
return isinstance(val, (str, int, float, datetime.date))
return isinstance(val, (basestring, int, float, datetime.date))
def _as_str(val):
if isinstance(val, str):
if isinstance(val, basestring):
return val
else:
return str(val)
@@ -135,7 +134,7 @@ def _row_to_dict(row, d):
"""
Populate dict with database row fields.
"""
for fld in list(row.keys()):
for fld in row.keys():
val = row[fld]
if val is None:
val = ''
@@ -185,7 +184,9 @@ def file_metadata(path, release):
try:
f = MediaFile(path)
except UnreadableFileError as ex:
logger.info(f"MediaFile couldn't parse {path}: {e}")
logger.info("MediaFile couldn't parse: %s (%s)",
path.decode(headphones.SYS_ENCODING, 'replace'),
str(ex))
return None, None
res = MetadataDict()
@@ -206,7 +207,8 @@ def file_metadata(path, release):
track_number = '%02d' % f.track
if not f.title:
basename = os.path.basename(path)
basename = os.path.basename(
path.decode(headphones.SYS_ENCODING, 'replace'))
title = os.path.splitext(basename)[0]
from_metadata = False
else:
@@ -240,7 +242,7 @@ def file_metadata(path, release):
Vars.SORT_ARTIST_LOWER: _lower(sort_name),
Vars.ALBUM_LOWER: _lower(album_title),
}
res.add_items(iter(override_values.items()))
res.add_items(override_values.iteritems())
return res, from_metadata
@@ -250,7 +252,7 @@ def _intersect(d1, d2):
Create intersection (common part) of two dictionaries.
"""
res = {}
for key, val in d1.items():
for key, val in d1.iteritems():
if key in d2 and d2[key] == val:
res[key] = val
return res
@@ -282,19 +284,21 @@ def album_metadata(path, release, common_tags):
sort_name = artist
if not sort_name or sort_name[0].isdigit():
first_char = '0-9'
first_char = u'0-9'
else:
first_char = sort_name[0]
orig_folder = ''
orig_folder = u''
# Get from temp path
if "_@hp@_" in path:
orig_folder = path.rsplit("headphones_", 1)[1].split("_@hp@_")[0]
orig_folder = orig_folder.decode(headphones.SYS_ENCODING, 'replace')
else:
for r, d, f in os.walk(path):
try:
orig_folder = os.path.basename(os.path.normpath(r))
orig_folder = os.path.basename(
os.path.normpath(r).decode(headphones.SYS_ENCODING, 'replace'))
break
except:
pass
@@ -316,7 +320,7 @@ def album_metadata(path, release, common_tags):
Vars.ORIGINAL_FOLDER_LOWER: _lower(orig_folder)
}
res = MetadataDict(common_tags)
res.add_items(iter(override_values.items()))
res.add_items(override_values.iteritems())
return res
@@ -341,7 +345,7 @@ def albumart_metadata(release, common_tags):
Vars.ALBUM_LOWER: _lower(album)
}
res = MetadataDict(common_tags)
res.add_items(iter(override_values.items()))
res.add_items(override_values.iteritems())
return res
+6 -6
View File
@@ -22,7 +22,7 @@ import headphones.helpers as _hp
from headphones.metadata import MetadataDict
import datetime
from .unittestcompat import TestCase
from unittestcompat import TestCase
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
@@ -50,7 +50,7 @@ class _MockDatabaseRow(object):
self._dict = dict(d)
def keys(self):
return iter(self._dict.keys())
return self._dict.iterkeys()
def __getitem__(self, item):
return self._dict[item]
@@ -63,9 +63,9 @@ class MetadataTest(TestCase):
def test_metadata_dict_ci(self):
"""MetadataDict: case-insensitive lookup"""
expected = 'naïve'
expected = u'naïve'
key_var = '$TitlE'
m = MetadataDict({key_var.lower(): 'naïve'})
m = MetadataDict({key_var.lower(): u'naïve'})
self.assertFalse('$track' in m)
self.assertTrue('$tITLe' in m, "cross-case lookup with 'in'")
self.assertEqual(m[key_var], expected, "cross-case lookup success")
@@ -74,7 +74,7 @@ class MetadataTest(TestCase):
def test_metadata_dict_cs(self):
"""MetadataDice: case-preserving lookup"""
expected_var = 'NaïVe'
expected_var = u'NaïVe'
key_var = '$TitlE'
m = MetadataDict({
key_var.lower(): expected_var.lower(),
@@ -171,5 +171,5 @@ class MetadataTest(TestCase):
res = _hp.pattern_substitute(
"/music/$First/$Artist/$Artist - $Album{ [$Year]}", md, True)
self.assertEqual(res, "/music/A/artist/artist - Album",
self.assertEqual(res, u"/music/A/artist/artist - Album",
"check correct rendering of None via pattern_substitute()")
+30 -24
View File
@@ -14,7 +14,6 @@
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import time
import datetime
import shutil
import subprocess
import multiprocessing
@@ -22,15 +21,14 @@ import multiprocessing
import os
import headphones
from headphones import logger
from mediafile import MediaFile
from beets.mediafile import MediaFile
# xld
from . import getXldProfile
import getXldProfile
def encode(albumPath):
print(albumPath)
use_xld = headphones.CONFIG.ENCODER == 'xld'
# Return if xld details not found
@@ -65,7 +63,8 @@ def encode(albumPath):
for music in f:
if any(music.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
if not use_xld:
encoderFormat = headphones.CONFIG.ENCODEROUTPUTFORMAT
encoderFormat = headphones.CONFIG.ENCODEROUTPUTFORMAT.encode(
headphones.SYS_ENCODING)
else:
xldMusicFile = os.path.join(r, music)
xldInfoMusic = MediaFile(xldMusicFile)
@@ -87,7 +86,7 @@ def encode(albumPath):
musicTempFiles.append(os.path.join(tempDirEncode, musicTemp))
if headphones.CONFIG.ENCODER_PATH:
encoder = headphones.CONFIG.ENCODER_PATH
encoder = headphones.CONFIG.ENCODER_PATH.encode(headphones.SYS_ENCODING)
else:
if use_xld:
encoder = os.path.join('/Applications', 'xld')
@@ -118,17 +117,18 @@ def encode(albumPath):
if use_xld:
if xldBitrate and (infoMusic.bitrate / 1000 <= xldBitrate):
logger.info(f"{music} has bitrate <= {xldBitrate}kb, will not be re-encoded")
logger.info('%s has bitrate <= %skb, will not be re-encoded',
music.decode(headphones.SYS_ENCODING, 'replace'), xldBitrate)
else:
encode = True
elif headphones.CONFIG.ENCODER == 'lame':
if not any(
music.lower().endswith('.' + x) for x
music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + x) for x
in ["mp3", "wav"]):
logger.warn('Lame cannot encode %s format for %s, use ffmpeg',
os.path.splitext(music)[1], music)
else:
if music.lower().endswith('.mp3') and (
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.mp3') and (
int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE):
logger.info('%s has bitrate <= %skb, will not be re-encoded', music,
headphones.CONFIG.BITRATE)
@@ -136,12 +136,13 @@ def encode(albumPath):
encode = True
else:
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'ogg':
if music.lower().endswith('.ogg'):
logger.warn(f"Cannot re-encode .ogg {music}")
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.ogg'):
logger.warn('Cannot re-encode .ogg %s',
music.decode(headphones.SYS_ENCODING, 'replace'))
else:
encode = True
else:
if music.lower().endswith('.' + headphones.CONFIG.ENCODEROUTPUTFORMAT) and (int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE):
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + headphones.CONFIG.ENCODEROUTPUTFORMAT) and (int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE):
logger.info('%s has bitrate <= %skb, will not be re-encoded', music, headphones.CONFIG.BITRATE)
else:
encode = True
@@ -184,13 +185,13 @@ def encode(albumPath):
# Retrieve the results
results = results.get()
else:
results = list(map(command_map, jobs))
results = map(command_map, jobs)
# The results are either True or False, so determine if one is False
encoder_failed = not all(results)
musicFiles = [_f for _f in musicFiles if _f]
musicTempFiles = [_f for _f in musicTempFiles if _f]
musicFiles = filter(None, musicFiles)
musicTempFiles = filter(None, musicTempFiles)
# check all files to be encoded now exist in temp directory
if not encoder_failed and musicTempFiles:
@@ -351,31 +352,36 @@ def command(encoder, musicSource, musicDest, albumPath, xldProfile):
startupinfo.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW
# Encode
logger.info(f"Encoding {musicSource}")
logger.info('Encoding %s...' % (musicSource.decode(headphones.SYS_ENCODING, 'replace')))
logger.debug(subprocess.list2cmdline(cmd))
process = subprocess.Popen(cmd, startupinfo=startupinfo,
stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
stderr=subprocess.PIPE)
stdout, stderr = process.communicate(headphones.CONFIG.ENCODER)
# Error if return code not zero
if process.returncode:
logger.error(f"Encoding failed for {musicSource}")
out = stdout or stderr
logger.error(
'Encoding failed for %s' % (musicSource.decode(headphones.SYS_ENCODING, 'replace')))
out = stdout if stdout else stderr
out = out.decode(headphones.SYS_ENCODING, 'replace')
outlast2lines = '\n'.join(out.splitlines()[-2:])
logger.error(f"{headphones.CONFIG.ENCODER} error details: {outlast2lines}")
logger.error('%s error details: %s' % (headphones.CONFIG.ENCODER, outlast2lines))
out = out.rstrip("\n")
logger.debug(out)
encoded = False
else:
logger.info(f"{musicSource} encoded in {getTimeEncode(startMusicTime)}")
logger.info('%s encoded in %s', musicSource, getTimeEncode(startMusicTime))
encoded = True
return encoded
def getTimeEncode(start):
finish = time.time()
seconds = int(finish - start)
return datetime.timedelta(seconds=seconds)
seconds = int(time.time() - start)
hours = seconds / 3600
seconds -= 3600 * hours
minutes = seconds / 60
seconds -= 60 * minutes
return "%02d:%02d:%02d" % (hours, minutes, seconds)
+87 -76
View File
@@ -1,17 +1,28 @@
from urllib.parse import urlencode, quote_plus
import urllib.request
import urllib.parse
import urllib.error
# This file is part of Headphones.
#
# Headphones is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Headphones is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
from urllib import urlencode, quote_plus
import urllib
import subprocess
import json
from email.mime.text import MIMEText
import smtplib
import email.utils
from http.client import HTTPSConnection
from urllib.parse import parse_qsl
import urllib.request
import urllib.error
import urllib.parse
from httplib import HTTPSConnection
from urlparse import parse_qsl
import urllib2
import requests as requests
import os.path
@@ -20,8 +31,8 @@ from pynma import pynma
import cherrypy
import headphones
import gntp.notifier
#import oauth2 as oauth
import twitter
import oauth2 as oauth
import pythontwitter as twitter
class GROWL(object):
@@ -70,10 +81,10 @@ class GROWL(object):
try:
growl.register()
except gntp.notifier.errors.NetworkError:
logger.warning('Growl notification failed: network error')
logger.warning(u'Growl notification failed: network error')
return
except gntp.notifier.errors.AuthError:
logger.warning('Growl notification failed: authentication error')
logger.warning(u'Growl notification failed: authentication error')
return
# Fix message
@@ -94,10 +105,10 @@ class GROWL(object):
icon=image
)
except gntp.notifier.errors.NetworkError:
logger.warning('Growl notification failed: network error')
logger.warning(u'Growl notification failed: network error')
return
logger.info("Growl notifications sent.")
logger.info(u"Growl notifications sent.")
def updateLibrary(self):
# For uniformity reasons not removed
@@ -146,13 +157,13 @@ class PROWL(object):
request_status = response.status
if request_status == 200:
logger.info("Prowl notifications sent.")
logger.info(u"Prowl notifications sent.")
return True
elif request_status == 401:
logger.info("Prowl auth failed: %s" % response.reason)
logger.info(u"Prowl auth failed: %s" % response.reason)
return False
else:
logger.info("Prowl notification failed.")
logger.info(u"Prowl notification failed.")
return False
def updateLibrary(self):
@@ -191,7 +202,7 @@ class XBMC(object):
self.password = headphones.CONFIG.XBMC_PASSWORD
def _sendhttp(self, host, command):
url_command = urllib.parse.urlencode(command)
url_command = urllib.urlencode(command)
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
if self.password:
@@ -250,7 +261,7 @@ class XBMC(object):
if version < 12: # Eden
notification = header + "," + message + "," + time + \
"," + albumartpath
"," + albumartpath
notifycommand = {'command': 'ExecBuiltIn',
'parameter': 'Notification(' +
notification + ')'}
@@ -284,10 +295,10 @@ class LMS(object):
content = {'Content-Type': 'application/json'}
req = urllib.request.Request(host + '/jsonrpc.js', data, content)
req = urllib2.Request(host + '/jsonrpc.js', data, content)
try:
handle = urllib.request.urlopen(req)
handle = urllib2.urlopen(req)
except Exception as e:
logger.warn('Error opening LMS url: %s' % e)
return
@@ -413,7 +424,7 @@ class Plex(object):
sections = r.getElementsByTagName('Directory')
if not sections:
logger.info("Plex Media Server not running on: " + host)
logger.info(u"Plex Media Server not running on: " + host)
return False
for s in sections:
@@ -444,7 +455,7 @@ class Plex(object):
if version < 12: # Eden
notification = header + "," + message + "," + time + \
"," + albumartpath
"," + albumartpath
notifycommand = {'command': 'ExecBuiltIn',
'parameter': 'Notification(' +
notification + ')'}
@@ -472,9 +483,9 @@ class NMA(object):
api = headphones.CONFIG.NMA_APIKEY
nma_priority = headphones.CONFIG.NMA_PRIORITY
logger.debug("NMA title: " + title)
logger.debug("NMA API: " + api)
logger.debug("NMA Priority: " + str(nma_priority))
logger.debug(u"NMA title: " + title)
logger.debug(u"NMA API: " + api)
logger.debug(u"NMA Priority: " + str(nma_priority))
if snatched:
event = snatched + " snatched!"
@@ -484,8 +495,8 @@ class NMA(object):
message = "Headphones has downloaded and postprocessed: " + \
artist + ' [' + album + ']'
logger.debug("NMA event: " + event)
logger.debug("NMA message: " + message)
logger.debug(u"NMA event: " + event)
logger.debug(u"NMA message: " + message)
batch = False
@@ -499,8 +510,8 @@ class NMA(object):
response = p.push(title, event, message, priority=nma_priority,
batch_mode=batch)
if not response[api]['code'] == '200':
logger.error('Could not send notification to NotifyMyAndroid')
if not response[api][u'code'] == u'200':
logger.error(u'Could not send notification to NotifyMyAndroid')
return False
else:
return True
@@ -532,10 +543,10 @@ class PUSHBULLET(object):
data=json.dumps(data))
if response:
logger.info("PushBullet notifications sent.")
logger.info(u"PushBullet notifications sent.")
return True
else:
logger.info("PushBullet notification failed.")
logger.info(u"PushBullet notification failed.")
return False
@@ -546,9 +557,9 @@ class PUSHALOT(object):
pushalot_authorizationtoken = headphones.CONFIG.PUSHALOT_APIKEY
logger.debug("Pushalot event: " + event)
logger.debug("Pushalot message: " + message)
logger.debug("Pushalot api: " + pushalot_authorizationtoken)
logger.debug(u"Pushalot event: " + event)
logger.debug(u"Pushalot message: " + message)
logger.debug(u"Pushalot api: " + pushalot_authorizationtoken)
http_handler = HTTPSConnection("pushalot.com")
@@ -565,18 +576,18 @@ class PUSHALOT(object):
response = http_handler.getresponse()
request_status = response.status
logger.debug("Pushalot response status: %r" % request_status)
logger.debug("Pushalot response headers: %r" % response.getheaders())
logger.debug("Pushalot response body: %r" % response.read())
logger.debug(u"Pushalot response status: %r" % request_status)
logger.debug(u"Pushalot response headers: %r" % response.getheaders())
logger.debug(u"Pushalot response body: %r" % response.read())
if request_status == 200:
logger.info("Pushalot notifications sent.")
logger.info(u"Pushalot notifications sent.")
return True
elif request_status == 410:
logger.info("Pushalot auth failed: %s" % response.reason)
logger.info(u"Pushalot auth failed: %s" % response.reason)
return False
else:
logger.info("Pushalot notification failed.")
logger.info(u"Pushalot notification failed.")
return False
@@ -607,19 +618,19 @@ class JOIN(object):
else:
self.url += '&deviceId={deviceid}'
response = urllib.request.urlopen(self.url.format(apikey=self.apikey,
title=quote_plus(event),
text=quote_plus(
message.encode(
"utf-8")),
icon=icon,
deviceid=self.deviceid))
response = urllib2.urlopen(self.url.format(apikey=self.apikey,
title=quote_plus(event),
text=quote_plus(
message.encode(
"utf-8")),
icon=icon,
deviceid=self.deviceid))
if response:
logger.info("Join notifications sent.")
logger.info(u"Join notifications sent.")
return True
else:
logger.error("Join notification failed.")
logger.error(u"Join notification failed.")
return False
@@ -658,7 +669,7 @@ class Synoindex(object):
out, error = p.communicate()
# synoindex never returns any codes other than '0',
# highly irritating
except OSError as e:
except OSError, e:
logger.warn("Error sending notification: %s" % str(e))
def notify_multiple(self, path_list):
@@ -699,10 +710,10 @@ class PUSHOVER(object):
headers=headers, data=data)
if response:
logger.info("Pushover notifications sent.")
logger.info(u"Pushover notifications sent.")
return True
else:
logger.error("Pushover notification failed.")
logger.error(u"Pushover notification failed.")
return False
def updateLibrary(self):
@@ -737,8 +748,8 @@ class TwitterNotifier(object):
def notify_download(self, title):
if headphones.CONFIG.TWITTER_ENABLED:
self._notifyTwitter(common.notifyStrings[
common.NOTIFY_DOWNLOAD] + ': ' +
title + ' at ' + helpers.now())
common.NOTIFY_DOWNLOAD] + ': ' +
title + ' at ' + helpers.now())
def test_notify(self):
return self._notifyTwitter(
@@ -802,7 +813,7 @@ class TwitterNotifier(object):
if resp['status'] != '200':
logger.info('The request for a token with did not succeed: ' + str(
resp['status']),
logger.ERROR)
logger.ERROR)
return False
else:
logger.info('Your Twitter Access Token key: %s' % access_token[
@@ -821,7 +832,7 @@ class TwitterNotifier(object):
access_token_key = headphones.CONFIG.TWITTER_USERNAME
access_token_secret = headphones.CONFIG.TWITTER_PASSWORD
logger.info("Sending tweet: " + message)
logger.info(u"Sending tweet: " + message)
api = twitter.Api(username, password, access_token_key,
access_token_secret)
@@ -829,7 +840,7 @@ class TwitterNotifier(object):
try:
api.PostUpdate(message)
except Exception as e:
logger.info("Error Sending Tweet: %s" % e)
logger.info(u"Error Sending Tweet: %s" % e)
return False
return True
@@ -924,10 +935,10 @@ class BOXCAR(object):
def notify(self, title, message, rgid=None):
try:
if rgid:
message += '<br></br><a href="https://musicbrainz.org/' \
message += '<br></br><a href="http://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid
data = urllib.parse.urlencode({
data = urllib.urlencode({
'user_credentials': headphones.CONFIG.BOXCAR_TOKEN,
'notification[title]': title.encode('utf-8'),
'notification[long_message]': message.encode('utf-8'),
@@ -936,12 +947,12 @@ class BOXCAR(object):
"/headphoneslogo.png"
})
req = urllib.request.Request(self.url)
handle = urllib.request.urlopen(req, data)
req = urllib2.Request(self.url)
handle = urllib2.urlopen(req, data)
handle.close()
return True
except urllib.error.URLError as e:
except urllib2.URLError as e:
logger.warn('Error sending Boxcar2 Notification: %s' % e)
return False
@@ -1000,7 +1011,7 @@ class Email(object):
mailserver.quit()
return True
except Exception as e:
except Exception, e:
logger.warn('Error sending Email: %s' % e)
return False
@@ -1023,8 +1034,8 @@ class TELEGRAM(object):
# MusicBrainz link
if rgid:
message += '\n\n <a href="https://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid
message += '\n\n <a href="http://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid
# Send image
response = None
@@ -1033,15 +1044,15 @@ class TELEGRAM(object):
payload = {'chat_id': userid, 'parse_mode': "HTML", 'caption': status + message}
try:
response = requests.post(TELEGRAM_API % (token, "sendPhoto"), data=payload, files=image_file)
except Exception as e:
logger.info('Telegram notify failed: ' + str(e))
except Exception, e:
logger.info(u'Telegram notify failed: ' + str(e))
# Sent text
else:
payload = {'chat_id': userid, 'parse_mode': "HTML", 'text': status + message}
try:
response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload)
except Exception as e:
logger.info('Telegram notify failed: ' + str(e))
except Exception, e:
logger.info(u'Telegram notify failed: ' + str(e))
# Error logging
sent_successfuly = True
@@ -1049,7 +1060,7 @@ class TELEGRAM(object):
logger.info("Could not send notification to TelegramBot (token=%s). Response: [%s]", token, response.text)
sent_successfuly = False
logger.info("Telegram notifications sent.")
logger.info(u"Telegram notifications sent.")
return sent_successfuly
@@ -1069,15 +1080,15 @@ class SLACK(object):
try:
response = requests.post(SLACK_URL, json=payload)
except Exception as e:
logger.info('Slack notify failed: ' + str(e))
except Exception, e:
logger.info(u'Slack notify failed: ' + str(e))
sent_successfuly = True
if not response.status_code == 200:
logger.info(
'Could not send notification to Slack. Response: [%s]',
u'Could not send notification to Slack. Response: [%s]',
(response.text))
sent_successfuly = False
logger.info("Slack notifications sent.")
logger.info(u"Slack notifications sent.")
return sent_successfuly
+16 -16
View File
@@ -20,8 +20,8 @@
from base64 import standard_b64encode
import http.client
import xmlrpc.client
import httplib
import xmlrpclib
import headphones
from headphones import logger
@@ -32,7 +32,7 @@ def sendNZB(nzb):
nzbgetXMLrpc = "%(protocol)s://%(username)s:%(password)s@%(host)s/xmlrpc"
if not headphones.CONFIG.NZBGET_HOST:
logger.error("No NZBget host found in configuration. Please configure it.")
logger.error(u"No NZBget host found in configuration. Please configure it.")
return False
if headphones.CONFIG.NZBGET_HOST.startswith('https://'):
@@ -46,25 +46,25 @@ def sendNZB(nzb):
"username": headphones.CONFIG.NZBGET_USERNAME,
"password": headphones.CONFIG.NZBGET_PASSWORD}
nzbGetRPC = xmlrpc.client.ServerProxy(url)
nzbGetRPC = xmlrpclib.ServerProxy(url)
try:
if nzbGetRPC.writelog("INFO", "headphones connected to drop of %s any moment now." % (
nzb.name + ".nzb")):
logger.debug("Successfully connected to NZBget")
logger.debug(u"Successfully connected to NZBget")
else:
logger.info("Successfully connected to NZBget, but unable to send a message" % (
logger.info(u"Successfully connected to NZBget, but unable to send a message" % (
nzb.name + ".nzb"))
except http.client.socket.error:
except httplib.socket.error:
logger.error(
"Please check your NZBget host and port (if it is running). NZBget is not responding to this combination")
u"Please check your NZBget host and port (if it is running). NZBget is not responding to this combination")
return False
except xmlrpc.client.ProtocolError as e:
except xmlrpclib.ProtocolError, e:
if e.errmsg == "Unauthorized":
logger.error("NZBget password is incorrect.")
logger.error(u"NZBget password is incorrect.")
else:
logger.error("Protocol Error: " + e.errmsg)
logger.error(u"Protocol Error: " + e.errmsg)
return False
nzbcontent64 = None
@@ -72,8 +72,8 @@ def sendNZB(nzb):
data = nzb.extraInfo[0]
nzbcontent64 = standard_b64encode(data)
logger.info("Sending NZB to NZBget")
logger.debug("URL: " + url)
logger.info(u"Sending NZB to NZBget")
logger.debug(u"URL: " + url)
dupekey = ""
dupescore = 0
@@ -131,12 +131,12 @@ def sendNZB(nzb):
nzb.url)
if nzbget_result:
logger.debug("NZB sent to NZBget successfully")
logger.debug(u"NZB sent to NZBget successfully")
return True
else:
logger.error("NZBget could not add %s to the queue" % (nzb.name + ".nzb"))
logger.error(u"NZBget could not add %s to the queue" % (nzb.name + ".nzb"))
return False
except:
logger.error(
"Connect Error to NZBget: could not add %s to the queue" % (nzb.name + ".nzb"))
u"Connect Error to NZBget: could not add %s to the queue" % (nzb.name + ".nzb"))
return False
+12 -15
View File
@@ -30,7 +30,7 @@ syntax elements are supported:
nonempty value only if any variable or optional inside returned
nonempty value, ignoring literals (like {'{'$That'}'}).
"""
from __future__ import print_function
from enum import Enum
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
@@ -38,7 +38,6 @@ __author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
class _PatternElement(object):
'''ABC for hierarchy of path name renderer pattern elements.'''
def render(self, replacement):
# type: (Mapping[str,str]) -> str
'''Format this _PatternElement into string using provided substitution dictionary.'''
@@ -56,7 +55,6 @@ class _Generator(_PatternElement):
class _Replacement(_Generator):
'''Replacement variable, eg. $title.'''
def __init__(self, pattern):
# type: (str)
self._pattern = pattern
@@ -83,7 +81,6 @@ class _Replacement(_Generator):
class _LiteralText(_PatternElement):
'''Just a plain piece of text to be rendered "as is".'''
def __init__(self, text):
# type: (str)
self._text = text
@@ -114,9 +111,9 @@ class _OptionalBlock(_Generator):
# type: (Mapping[str,str]) -> str
res = [(isinstance(x, _Generator), x.render(replacement)) for x in self._scope]
if any((t[0] and t[1] is not None and len(t[1]) != 0) for t in res):
return "".join(t[1] for t in res)
return u"".join(t[1] for t in res)
else:
return ""
return u""
def __eq__(self, other):
"""
@@ -125,15 +122,15 @@ class _OptionalBlock(_Generator):
return isinstance(other, _OptionalBlock) and self._scope == other._scope
_OPTIONAL_START = '{'
_OPTIONAL_END = '}'
_ESCAPE_CHAR = '\''
_REPLACEMENT_START = '$'
_OPTIONAL_START = u'{'
_OPTIONAL_END = u'}'
_ESCAPE_CHAR = u'\''
_REPLACEMENT_START = u'$'
def _is_replacement_valid(c):
# type: (str) -> bool
return c.isalnum() or c == '_'
return c.isalnum() or c == u'_'
class _State(Enum):
@@ -246,7 +243,7 @@ class Pattern(object):
def __call__(self, replacement):
# type: (Mapping[str,str]) -> str
'''Execute path rendering/substitution based on replacement dictionary.'''
return "".join(p.render(replacement) for p in self._pattern)
return u"".join(p.render(replacement) for p in self._pattern)
def _get_warnings(self):
# type: () -> str
@@ -265,6 +262,6 @@ def render(pattern, replacement):
if __name__ == "__main__":
# primitive test ;)
p = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]}")
d = {'$Disc': '', '$Track': '05', '$Artist': 'Grzegżółka', '$Title': 'Błona kapłona', '$Year': '2019'}
assert p(d) == "05 - Grzegżółka - Błona kapłona [2019]"
p = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]}")
d = {'$Disc': '', '$Track': '05', '$Artist': u'Grzegżółka', '$Title': u'Błona kapłona', '$Year': '2019'}
assert p(d) == u"05 - Grzegżółka - Błona kapłona [2019]"
+21 -21
View File
@@ -19,7 +19,7 @@ Test module for pathrender.
import headphones.pathrender as _pr
from headphones.pathrender import Pattern, Warnings
from .unittestcompat import TestCase
from unittestcompat import TestCase
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
@@ -32,21 +32,21 @@ class PathRenderTest(TestCase):
def test_parsing(self):
"""pathrender: pattern parsing"""
pattern = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]}")
pattern = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]}")
expected = [
_pr._OptionalBlock([
_pr._Replacement("$Disc"),
_pr._LiteralText(".")
_pr._Replacement(u"$Disc"),
_pr._LiteralText(u".")
]),
_pr._Replacement("$Track"),
_pr._LiteralText(" - "),
_pr._Replacement("$Artist"),
_pr._LiteralText(" - "),
_pr._Replacement("$Title"),
_pr._Replacement(u"$Track"),
_pr._LiteralText(u" - "),
_pr._Replacement(u"$Artist"),
_pr._LiteralText(u" - "),
_pr._Replacement(u"$Title"),
_pr._OptionalBlock([
_pr._LiteralText(" ["),
_pr._Replacement("$Year"),
_pr._LiteralText("]")
_pr._LiteralText(u" ["),
_pr._Replacement(u"$Year"),
_pr._LiteralText(u"]")
])
]
self.assertEqual(expected, pattern._pattern)
@@ -54,27 +54,27 @@ class PathRenderTest(TestCase):
def test_parsing_warnings(self):
"""pathrender: pattern parsing with warnings"""
pattern = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]")
pattern = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]")
self.assertEqual(set([Warnings.UNCLOSED_OPTIONAL]), pattern.warnings)
pattern = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]'}")
pattern = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]'}")
self.assertEqual(set([Warnings.UNCLOSED_ESCAPE, Warnings.UNCLOSED_OPTIONAL]), pattern.warnings)
def test_replacement(self):
"""pathrender: _Replacement variable substitution"""
r = _pr._Replacement("$Title")
r = _pr._Replacement(u"$Title")
subst = {'$Title': 'foo', '$Track': 'bar'}
res = r.render(subst)
self.assertEqual(res, 'foo', 'check valid replacement')
self.assertEqual(res, u'foo', 'check valid replacement')
subst = {}
res = r.render(subst)
self.assertEqual(res, '$Title', 'check missing replacement')
self.assertEqual(res, u'$Title', 'check missing replacement')
subst = {'$Title': None}
res = r.render(subst)
self.assertEqual(res, '', 'check render() works with None')
def test_literal(self):
"""pathrender: _Literal text rendering"""
l = _pr._LiteralText("foo")
l = _pr._LiteralText(u"foo")
subst = {'$foo': 'bar'}
res = l.render(subst)
self.assertEqual(res, 'foo')
@@ -82,12 +82,12 @@ class PathRenderTest(TestCase):
def test_optional(self):
"""pathrender: _OptionalBlock element processing"""
o = _pr._OptionalBlock([
_pr._Replacement("$Title"),
_pr._LiteralText(".foobar")
_pr._Replacement(u"$Title"),
_pr._LiteralText(u".foobar")
])
subst = {'$Title': 'foo', '$Track': 'bar'}
res = o.render(subst)
self.assertEqual(res, 'foo.foobar', 'check non-empty replacement')
self.assertEqual(res, u'foo.foobar', 'check non-empty replacement')
subst = {'$Title': ''}
res = o.render(subst)
self.assertEqual(res, '', 'check empty replacement')
+139 -131
View File
@@ -25,7 +25,7 @@ import headphones
from beets import autotag
from beets import config as beetsconfig
from beets import logging as beetslogging
from mediafile import MediaFile, FileTypeError, UnreadableFileError
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
from beetsplug import lyrics as beetslyrics
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
from headphones import db, albumart, librarysync
@@ -65,8 +65,8 @@ def checkFolder():
folder_name = torrent_folder_name
if folder_name:
print(folder_name)
album_path = os.path.join(download_dir, folder_name)
album_path = os.path.join(download_dir, folder_name).encode(
headphones.SYS_ENCODING, 'replace')
logger.debug("Checking if %s exists" % album_path)
if os.path.exists(album_path):
@@ -80,7 +80,6 @@ def checkFolder():
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
print(albumpath)
myDB = db.DBConnection()
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
@@ -136,11 +135,11 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if headphones.CONFIG.RENAME_FROZEN:
renameUnprocessedFolder(albumpath, tag="Frozen")
else:
logger.warn("Won't rename %s to mark as 'Frozen', because it is disabled.",
albumpath)
logger.warn(u"Won't rename %s to mark as 'Frozen', because it is disabled.",
albumpath.decode(headphones.SYS_ENCODING, 'replace'))
return
logger.info("Now adding/updating artist: " + release_dict['artist_name'])
logger.info(u"Now adding/updating artist: " + release_dict['artist_name'])
if release_dict['artist_name'].startswith('The '):
sortname = release_dict['artist_name'][4:]
@@ -162,7 +161,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
myDB.upsert("artists", newValueDict, controlValueDict)
logger.info("Now adding album: " + release_dict['title'])
logger.info(u"Now adding album: " + release_dict['title'])
controlValueDict = {"AlbumID": albumid}
newValueDict = {"ArtistID": release_dict['artist_id'],
@@ -203,7 +202,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
newValueDict = {"Status": "Paused"}
myDB.upsert("artists", newValueDict, controlValueDict)
logger.info("Addition complete for: " + release_dict['title'] + " - " + release_dict[
logger.info(u"Addition complete for: " + release_dict['title'] + " - " + release_dict[
'artist_name'])
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
@@ -212,18 +211,17 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
downloaded_track_list = []
downloaded_cuecount = 0
media_extensions = tuple(map(lambda x: '.' + x, headphones.MEDIA_FORMATS))
for root, dirs, files in os.walk(albumpath):
for file in files:
if file.endswith(media_extensions):
downloaded_track_list.append(os.path.join(root, file))
elif file.endswith('.cue'):
for r, d, f in os.walk(albumpath):
for files in f:
if any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
downloaded_track_list.append(os.path.join(r, files))
elif files.lower().endswith('.cue'):
downloaded_cuecount += 1
# if any of the files end in *.part, we know the torrent isn't done yet. Process if forced, though
elif file.endswith(('.part', '.utpart')) and not forced:
elif files.lower().endswith(('.part', '.utpart')) and not forced:
logger.info(
"Looks like " + os.path.basename(albumpath) + " isn't complete yet. Will try again on the next run")
"Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING,
'replace') + " isn't complete yet. Will try again on the next run")
return
# Force single file through
@@ -266,7 +264,10 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
try:
f = MediaFile(downloaded_track)
except Exception as e:
logger.info(f"Exception from MediaFile for {downloaded_track}: {e}")
logger.info(
u"Exception from MediaFile for: " + downloaded_track.decode(headphones.SYS_ENCODING,
'replace') + u" : " + unicode(
e))
continue
if not f.artist:
@@ -274,10 +275,10 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if not f.album:
continue
metaartist = helpers.latinToAscii(f.artist.lower())
dbartist = helpers.latinToAscii(release['ArtistName'].lower())
metaalbum = helpers.latinToAscii(f.album.lower())
dbalbum = helpers.latinToAscii(release['AlbumTitle'].lower())
metaartist = helpers.latinToAscii(f.artist.lower()).encode('UTF-8')
dbartist = helpers.latinToAscii(release['ArtistName'].lower()).encode('UTF-8')
metaalbum = helpers.latinToAscii(f.album.lower()).encode('UTF-8')
dbalbum = helpers.latinToAscii(release['AlbumTitle'].lower()).encode('UTF-8')
logger.debug('Matching metadata artist: %s with artist name: %s' % (metaartist, dbartist))
logger.debug('Matching metadata album: %s with album name: %s' % (metaalbum, dbalbum))
@@ -297,8 +298,8 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if not track['TrackTitle']:
continue
dbtrack = helpers.latinToAscii(track['TrackTitle'].lower())
filetrack = helpers.latinToAscii(split_track_name)
dbtrack = helpers.latinToAscii(track['TrackTitle'].lower()).encode('UTF-8')
filetrack = helpers.latinToAscii(split_track_name).encode('UTF-8')
logger.debug('Checking if track title: %s is in file name: %s' % (dbtrack, filetrack))
if dbtrack in filetrack:
@@ -339,7 +340,8 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
keep_original_folder, forced, single)
return
logger.warn(f"Could not identify {albumpath}. It may not be the intended album")
logger.warn(u'Could not identify album: %s. It may not be the intended album.',
albumpath.decode(headphones.SYS_ENCODING, 'replace'))
markAsUnprocessed(albumid, albumpath, keep_original_folder)
@@ -352,19 +354,13 @@ def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
if headphones.CONFIG.RENAME_UNPROCESSED and not keep_original_folder:
renameUnprocessedFolder(albumpath, tag="Unprocessed")
else:
logger.warn(
f"Won't rename {albumpath} to mark as 'Unprocessed', "
f"because it is disabled or folder is being kept."
)
return
logger.warn(u"Won't rename %s to mark as 'Unprocessed', because it is disabled or folder is being kept.",
albumpath.decode(headphones.SYS_ENCODING, 'replace'))
def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind=None,
keep_original_folder=False, forced=False, single=False):
logger.info(
f"Starting post-processing for: {release['ArtistName']} - "
f"{release['AlbumTitle']}"
)
logger.info('Starting post-processing for: %s - %s' % (release['ArtistName'], release['AlbumTitle']))
new_folder = None
# Preserve the torrent dir
@@ -397,10 +393,12 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
f = MediaFile(downloaded_track)
builder.add_media_file(f)
except (FileTypeError, UnreadableFileError):
logger.error(f"`{downloaded_track}` is not a valid media file. Not continuing.")
logger.error("Track file is not a valid media file: %s. Not continuing.",
downloaded_track.decode(headphones.SYS_ENCODING, "replace"))
return
except IOError:
logger.error(f"Unable to find `{downloaded_track}`. Not continuing.")
logger.error("Unable to find media file: %s. Not continuing.", downloaded_track.decode(
headphones.SYS_ENCODING, "replace"))
if new_folder:
shutil.rmtree(new_folder)
return
@@ -418,10 +416,9 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
fp.seek(0)
except IOError as e:
logger.debug("Write check exact error: %s", e)
logger.error(
f"`{downloaded_track}` is not writable. This is required "
"for some post processing steps. Not continuing."
)
logger.error("Track file is not writable. This is required "
"for some post processing steps: %s. Not continuing.",
downloaded_track.decode(headphones.SYS_ENCODING, "replace"))
if new_folder:
shutil.rmtree(new_folder)
return
@@ -478,8 +475,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
else:
albumpaths = [albumpath]
if headphones.CONFIG.FILE_PERMISSIONS_ENABLED:
updateFilePermissions(albumpaths)
updateFilePermissions(albumpaths)
myDB = db.DBConnection()
myDB.action('UPDATE albums SET status = "Downloaded" WHERE AlbumID=?', [albumid])
@@ -495,7 +491,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
if seed_snatched:
hash = seed_snatched['TorrentHash']
torrent_removed = False
logger.info('%s - %s. Checking if torrent has finished seeding and can be removed' % (
logger.info(u'%s - %s. Checking if torrent has finished seeding and can be removed' % (
release['ArtistName'], release['AlbumTitle']))
if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
torrent_removed = transmission.removeTorrent(hash, True)
@@ -521,18 +517,18 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
ArtistName=release['ArtistName'])
logger.info(
'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle']))
u'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle']))
pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
statusmessage = "Download and Postprocessing completed"
if headphones.CONFIG.GROWL_ENABLED:
logger.info("Growl request")
logger.info(u"Growl request")
growl = notifiers.GROWL()
growl.notify(pushmessage, statusmessage)
if headphones.CONFIG.PROWL_ENABLED:
logger.info("Prowl request")
logger.info(u"Prowl request")
prowl = notifiers.PROWL()
prowl.notify(pushmessage, statusmessage)
@@ -563,7 +559,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
nma.notify(release['ArtistName'], release['AlbumTitle'])
if headphones.CONFIG.PUSHALOT_ENABLED:
logger.info("Pushalot request")
logger.info(u"Pushalot request")
pushalot = notifiers.PUSHALOT()
pushalot.notify(pushmessage, statusmessage)
@@ -573,36 +569,35 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
syno.notify(albumpath)
if headphones.CONFIG.PUSHOVER_ENABLED:
logger.info("Pushover request")
logger.info(u"Pushover request")
pushover = notifiers.PUSHOVER()
pushover.notify(pushmessage, "Headphones")
if headphones.CONFIG.PUSHBULLET_ENABLED:
logger.info("PushBullet request")
logger.info(u"PushBullet request")
pushbullet = notifiers.PUSHBULLET()
pushbullet.notify(pushmessage, statusmessage)
if headphones.CONFIG.JOIN_ENABLED:
logger.info("Join request")
logger.info(u"Join request")
join = notifiers.JOIN()
join.notify(pushmessage, statusmessage)
if headphones.CONFIG.TELEGRAM_ENABLED:
logger.info("Telegram request")
logger.info(u"Telegram request")
telegram = notifiers.TELEGRAM()
telegram.notify(statusmessage, pushmessage)
if headphones.CONFIG.TWITTER_ENABLED:
logger.info("Twitter notifications temporarily disabled")
#logger.info("Sending Twitter notification")
#twitter = notifiers.TwitterNotifier()
# twitter.notify_download(pushmessage)
logger.info(u"Sending Twitter notification")
twitter = notifiers.TwitterNotifier()
twitter.notify_download(pushmessage)
if headphones.CONFIG.OSX_NOTIFY_ENABLED:
from headphones import cache
c = cache.Cache()
album_art = c.get_artwork_from_cache(None, release['AlbumID'])
logger.info("Sending OS X notification")
logger.info(u"Sending OS X notification")
osx_notify = notifiers.OSX_NOTIFY()
osx_notify.notify(release['ArtistName'],
release['AlbumTitle'],
@@ -610,13 +605,13 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
image=album_art)
if headphones.CONFIG.BOXCAR_ENABLED:
logger.info("Sending Boxcar2 notification")
logger.info(u"Sending Boxcar2 notification")
boxcar = notifiers.BOXCAR()
boxcar.notify('Headphones processed: ' + pushmessage,
statusmessage, release['AlbumID'])
if headphones.CONFIG.SUBSONIC_ENABLED:
logger.info("Sending Subsonic update")
logger.info(u"Sending Subsonic update")
subsonic = notifiers.SubSonicNotifier()
subsonic.notify(albumpaths)
@@ -625,7 +620,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
mpc.notify()
if headphones.CONFIG.EMAIL_ENABLED:
logger.info("Sending Email notification")
logger.info(u"Sending Email notification")
email = notifiers.Email()
subject = release['ArtistName'] + ' - ' + release['AlbumTitle']
email.notify(subject, "Download and Postprocessing completed")
@@ -641,21 +636,23 @@ def embedAlbumArt(artwork, downloaded_track_list):
try:
f = MediaFile(downloaded_track)
except:
logger.error(f"Could not read {downloaded_track}. Not adding album art")
logger.error(u'Could not read %s. Not adding album art' % downloaded_track.decode(
headphones.SYS_ENCODING, 'replace'))
continue
logger.debug(f"Adding album art to `{downloaded_track}`")
logger.debug('Adding album art to: %s' % downloaded_track)
try:
f.art = artwork
f.save()
except Exception as e:
logger.error(f"Error embedding album art to `{downloaded_track}`: {e}")
logger.error(u'Error embedding album art to: %s. Error: %s' % (
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), str(e)))
continue
def addAlbumArt(artwork, albumpath, release, metadata_dict):
logger.info(f"Adding album art to `{albumpath}`")
logger.info('Adding album art to folder')
md = metadata.album_metadata(albumpath, release, metadata_dict)
ext = ".jpg"
@@ -666,7 +663,8 @@ def addAlbumArt(artwork, albumpath, release, metadata_dict):
album_art_name = helpers.pattern_substitute(
headphones.CONFIG.ALBUM_ART_FORMAT.strip(), md) + ext
album_art_name = helpers.replace_illegal_chars(album_art_name)
album_art_name = helpers.replace_illegal_chars(album_art_name).encode(
headphones.SYS_ENCODING, 'replace')
if headphones.CONFIG.FILE_UNDERSCORES:
album_art_name = album_art_name.replace(' ', '_')
@@ -686,13 +684,14 @@ def cleanupFiles(albumpath):
logger.info('Cleaning up files')
for r, d, f in os.walk(albumpath):
for file in f:
if not any(file.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
logger.debug('Removing: %s' % file)
for files in f:
if not any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
logger.debug('Removing: %s' % files)
try:
os.remove(os.path.join(r, file))
os.remove(os.path.join(r, files))
except Exception as e:
logger.error('Could not remove file: %s. Error: %s' % (file, e))
logger.error(u'Could not remove file: %s. Error: %s' % (
files.decode(headphones.SYS_ENCODING, 'replace'), e))
def renameNFO(albumpath):
@@ -702,16 +701,19 @@ def renameNFO(albumpath):
for file in f:
if file.lower().endswith('.nfo'):
if not file.lower().endswith('.orig.nfo'):
logger.debug('Renaming: "%s" to "%s"' % (
file.decode(headphones.SYS_ENCODING, 'replace'),
file.decode(headphones.SYS_ENCODING, 'replace') + '-orig'))
try:
new_file_name = os.path.join(r, file)[:-3] + 'orig.nfo'
logger.debug(f"Renaming `{file}` to `{new_file_name}`")
os.rename(os.path.join(r, file), new_file_name)
except Exception as e:
logger.error(f"Could not rename {file}: {e}")
logger.error(u'Could not rename file: %s. Error: %s' % (
os.path.join(r, file).decode(headphones.SYS_ENCODING, 'replace'), e))
def moveFiles(albumpath, release, metadata_dict):
logger.info(f"Moving files: `{albumpath}`")
logger.info("Moving files: %s" % albumpath)
md = metadata.album_metadata(albumpath, release, metadata_dict)
folder = helpers.pattern_substitute(
@@ -748,8 +750,12 @@ def moveFiles(albumpath, release, metadata_dict):
make_lossy_folder = False
make_lossless_folder = False
lossy_destination_path = os.path.join(headphones.CONFIG.DESTINATION_DIR, folder)
lossless_destination_path = os.path.join(headphones.CONFIG.LOSSLESS_DESTINATION_DIR, folder)
lossy_destination_path = os.path.normpath(
os.path.join(headphones.CONFIG.DESTINATION_DIR, folder)).encode(headphones.SYS_ENCODING,
'replace')
lossless_destination_path = os.path.normpath(
os.path.join(headphones.CONFIG.LOSSLESS_DESTINATION_DIR, folder)).encode(
headphones.SYS_ENCODING, 'replace')
# If they set a destination dir for lossless media, only create the lossy folder if there is lossy media
if headphones.CONFIG.LOSSLESS_DESTINATION_DIR:
@@ -774,9 +780,8 @@ def moveFiles(albumpath, release, metadata_dict):
shutil.rmtree(lossless_destination_path)
except Exception as e:
logger.error(
f"Error deleting `{lossless_destination_path}`. "
f"Creating duplicate folder. Error: {e}"
)
"Error deleting existing folder: %s. Creating duplicate folder. Error: %s" % (
lossless_destination_path.decode(headphones.SYS_ENCODING, 'replace'), e))
create_duplicate_folder = True
if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder:
@@ -786,11 +791,8 @@ def moveFiles(albumpath, release, metadata_dict):
while True:
newfolder = temp_folder + '[%i]' % i
lossless_destination_path = os.path.normpath(
os.path.join(
headphones.CONFIG.LOSSLESS_DESTINATION_DIR,
newfolder
)
)
os.path.join(headphones.CONFIG.LOSSLESS_DESTINATION_DIR, newfolder)).encode(
headphones.SYS_ENCODING, 'replace')
if os.path.exists(lossless_destination_path):
i += 1
else:
@@ -816,9 +818,8 @@ def moveFiles(albumpath, release, metadata_dict):
shutil.rmtree(lossy_destination_path)
except Exception as e:
logger.error(
f"Error deleting `{lossy_destination_path}`. "
f"Creating duplicate folder. Error: {e}"
)
"Error deleting existing folder: %s. Creating duplicate folder. Error: %s" % (
lossy_destination_path.decode(headphones.SYS_ENCODING, 'replace'), e))
create_duplicate_folder = True
if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder:
@@ -828,11 +829,8 @@ def moveFiles(albumpath, release, metadata_dict):
while True:
newfolder = temp_folder + '[%i]' % i
lossy_destination_path = os.path.normpath(
os.path.join(
headphones.CONFIG.DESTINATION_DIR,
newfolder
)
)
os.path.join(headphones.CONFIG.DESTINATION_DIR, newfolder)).encode(
headphones.SYS_ENCODING, 'replace')
if os.path.exists(lossy_destination_path):
i += 1
else:
@@ -878,11 +876,12 @@ def moveFiles(albumpath, release, metadata_dict):
os.remove(file_to_move)
except Exception as e:
logger.error(
f"Error deleting `{file_to_move}` from source directory")
"Error deleting file '" + file_to_move.decode(headphones.SYS_ENCODING,
'replace') + "' from source directory")
else:
logger.error(
f"Error copying `{file_to_move}`. "
f"Not deleting from download directory")
logger.error("Error copying '" + file_to_move.decode(headphones.SYS_ENCODING,
'replace') + "'. Not deleting from download directory")
elif make_lossless_folder and not make_lossy_folder:
for file_to_move in files_to_move:
@@ -911,20 +910,20 @@ def moveFiles(albumpath, release, metadata_dict):
if headphones.CONFIG.FOLDER_PERMISSIONS_ENABLED:
try:
os.chmod(os.path.normpath(temp_f),
os.chmod(os.path.normpath(temp_f).encode(headphones.SYS_ENCODING, 'replace'),
int(headphones.CONFIG.FOLDER_PERMISSIONS, 8))
except Exception as e:
logger.error(f"Error trying to change permissions on `{temp_f}`: {e}")
logger.error("Error trying to change permissions on folder: %s. %s",
temp_f.decode(headphones.SYS_ENCODING, 'replace'), e)
else:
logger.debug(
f"Not changing permissions on `{temp_f}`, "
"since it is disabled")
logger.debug("Not changing folder permissions, since it is disabled: %s",
temp_f.decode(headphones.SYS_ENCODING, 'replace'))
# If we failed to move all the files out of the directory, this will fail too
try:
shutil.rmtree(albumpath)
except Exception as e:
logger.error(f"Could not remove `{albumpath}`: {e}")
logger.error('Could not remove directory: %s. %s', albumpath, e)
destination_paths = []
@@ -953,15 +952,11 @@ def correctMetadata(albumid, release, downloaded_track_list):
headphones.LOSSY_MEDIA_FORMATS):
lossy_items.append(beets.library.Item.from_path(downloaded_track))
else:
logger.warn(
f"Skipping `{downloaded_track}` because it is "
f"not a mutagen friendly file format"
)
continue
logger.warn("Skipping: %s because it is not a mutagen friendly file format",
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'))
except Exception as e:
logger.error(
f"Beets couldn't create an Item from `{downloaded_track}`: {e}")
continue
logger.error("Beets couldn't create an Item from: %s - not a media file? %s",
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), str(e))
for items in [lossy_items, lossless_items]:
@@ -1023,9 +1018,11 @@ def correctMetadata(albumid, release, downloaded_track_list):
for item in items:
try:
item.write()
logger.info(f"Successfully applied metadata to `{item.path}`")
logger.info("Successfully applied metadata to: %s",
item.path.decode(headphones.SYS_ENCODING, 'replace'))
except Exception as e:
logger.warn(f"Error writing metadata to `{item.path}: {e}")
logger.warn("Error writing metadata to '%s': %s",
item.path.decode(headphones.SYS_ENCODING, 'replace'), str(e))
return False
return True
@@ -1051,11 +1048,11 @@ def embedLyrics(downloaded_track_list):
headphones.LOSSY_MEDIA_FORMATS):
lossy_items.append(beets.library.Item.from_path(downloaded_track))
else:
logger.warn(
f"Skipping `{downloaded_track}` because it is "
f"not a mutagen friendly file format")
logger.warn("Skipping: %s because it is not a mutagen friendly file format",
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'))
except Exception as e:
logger.error(f"Beets couldn't create an Item from `{downloaded_track}`: {e}")
logger.error("Beets couldn't create an Item from: %s - not a media file? %s",
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), str(e))
for items in [lossy_items, lossless_items]:
@@ -1070,7 +1067,7 @@ def embedLyrics(downloaded_track_list):
if any(lyrics):
break
lyrics = "\n\n---\n\n".join([l for l in lyrics if l])
lyrics = u"\n\n---\n\n".join([l for l in lyrics if l])
if lyrics:
logger.debug('Adding lyrics to: %s', item.title)
@@ -1102,7 +1099,8 @@ def renameFiles(albumpath, downloaded_track_list, release):
headphones.CONFIG.FILE_FORMAT.strip(), md
).replace('/', '_') + ext
new_file_name = helpers.replace_illegal_chars(new_file_name)
new_file_name = helpers.replace_illegal_chars(new_file_name).encode(
headphones.SYS_ENCODING, 'replace')
if headphones.CONFIG.FILE_UNDERSCORES:
new_file_name = new_file_name.replace(' ', '_')
@@ -1113,28 +1111,36 @@ def renameFiles(albumpath, downloaded_track_list, release):
new_file = os.path.join(albumpath, new_file_name)
if downloaded_track == new_file_name:
logger.debug(f"Renaming for {downloaded_track} is not neccessary")
logger.debug("Renaming for: " + downloaded_track.decode(
headphones.SYS_ENCODING, 'replace') + " is not neccessary")
continue
logger.debug(f"Renaming {downloaded_track} ---> {new_file_name}")
logger.debug('Renaming %s ---> %s',
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'),
new_file_name.decode(headphones.SYS_ENCODING, 'replace'))
try:
os.rename(downloaded_track, new_file)
except Exception as e:
logger.error(f"Error renaming {downloaded_track}: {e}")
logger.error('Error renaming file: %s. Error: %s',
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), e)
continue
def updateFilePermissions(albumpaths):
for folder in albumpaths:
logger.info(f"Updating file permissions in `{folder}`")
logger.info("Updating file permissions in %s", folder)
for r, d, f in os.walk(folder):
for files in f:
full_path = os.path.join(r, files)
try:
os.chmod(full_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
except:
logger.error(f"Could not change permissions for `{full_path}`")
continue
if headphones.CONFIG.FILE_PERMISSIONS_ENABLED:
try:
os.chmod(full_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
except:
logger.error("Could not change permissions for file: %s", full_path)
continue
else:
logger.debug("Not changing file permissions, since it is disabled: %s",
full_path.decode(headphones.SYS_ENCODING, 'replace'))
def renameUnprocessedFolder(path, tag):
@@ -1162,16 +1168,18 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
ignored = 0
if album_dir:
folders = [album_dir]
folders = [album_dir.encode(headphones.SYS_ENCODING, 'replace')]
else:
download_dirs = []
if dir:
download_dirs.append(dir)
download_dirs.append(dir.encode(headphones.SYS_ENCODING, 'replace'))
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
download_dirs.append(
headphones.CONFIG.DOWNLOAD_DIR.encode(headphones.SYS_ENCODING, 'replace'))
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_DIR)
download_dirs.append(
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
download_dirs = list(set(download_dirs))
@@ -1215,7 +1223,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
myDB = db.DBConnection()
for folder in folders:
folder_basename = os.path.basename(folder)
folder_basename = os.path.basename(folder).decode(headphones.SYS_ENCODING, 'replace')
logger.info('Processing: %s', folder_basename)
# Attempt 1: First try to see if there's a match in the snatched table,
+14 -18
View File
@@ -13,13 +13,9 @@
# You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import urllib.request
import urllib.parse
import urllib.error
import urllib.request
import urllib.error
import urllib.parse
import http.cookiejar
import urllib
import urllib2
import cookielib
import json
import time
import mimetypes
@@ -65,27 +61,27 @@ class qbittorrentclient(object):
self.version = 2
except Exception as e:
logger.warning("Error with qBittorrent v2 api, check settings or update, will try v1: %s" % e)
self.cookiejar = http.cookiejar.CookieJar()
self.cookiejar = cookielib.CookieJar()
self.opener = self._make_opener()
self._get_sid(self.base_url, self.username, self.password)
self.version = 1
def _make_opener(self):
# create opener with cookie handler to carry QBitTorrent SID cookie
cookie_handler = urllib.request.HTTPCookieProcessor(self.cookiejar)
cookie_handler = urllib2.HTTPCookieProcessor(self.cookiejar)
handlers = [cookie_handler]
return urllib.request.build_opener(*handlers)
return urllib2.build_opener(*handlers)
def _get_sid(self, base_url, username, password):
# login so we can capture SID cookie
login_data = urllib.parse.urlencode({'username': username, 'password': password})
login_data = urllib.urlencode({'username': username, 'password': password})
try:
self.opener.open(base_url + '/login', login_data)
except urllib.error.URLError as err:
except urllib2.URLError as err:
logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason))
return
for cookie in self.cookiejar:
logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value)
logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value)
return
def _command(self, command, args=None, content_type=None, files=None):
@@ -99,14 +95,14 @@ class qbittorrentclient(object):
data, headers = encode_multipart(args, files)
else:
if args:
data = urllib.parse.urlencode(args)
data = urllib.urlencode(args)
if content_type:
headers['Content-Type'] = content_type
logger.debug('%s' % json.dumps(headers, indent=4))
logger.debug('%s' % data)
request = urllib.request.Request(url, data, headers)
request = urllib2.Request(url, data, headers)
try:
response = self.opener.open(request)
info = response.info()
@@ -121,7 +117,7 @@ class qbittorrentclient(object):
return response.code, json.loads(resp)
logger.debug('response code: %s' % str(response.code))
return response.code, None
except urllib.error.URLError as err:
except urllib2.URLError as err:
logger.debug('Failed URL: %s' % url)
logger.debug('QBitTorrent webUI raised the following error: %s' % str(err))
return None, None
@@ -323,7 +319,7 @@ def encode_multipart(args, files, boundary=None):
lines = []
if args:
for name, value in list(args.items()):
for name, value in args.items():
lines.extend((
'--{0}'.format(boundary),
'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)),
@@ -333,7 +329,7 @@ def encode_multipart(args, files, boundary=None):
logger.debug(''.join(lines))
if files:
for name, value in list(files.items()):
for name, value in files.items():
filename = value['filename']
if 'mimetype' in value:
mimetype = value['mimetype']
+3 -3
View File
@@ -138,7 +138,7 @@ def request_soup(url, **kwargs):
no exceptions are raised.
"""
parser = kwargs.pop("parser", "html.parser")
parser = kwargs.pop("parser", "html5lib")
response = request_response(url, **kwargs)
if response is not None:
@@ -220,9 +220,9 @@ def server_message(response):
# First attempt is to 'read' the response as HTML
if response.headers.get("content-type") and \
"text/html" in response.headers.get("content-type"):
"text/html" in response.headers.get("content-type"):
try:
soup = BeautifulSoup(response.content, "html.parser")
soup = BeautifulSoup(response.content, "html5lib")
except Exception:
pass
+13 -15
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python
import urllib.request
import urllib.parse
import urllib.error
import urllib
import time
from urllib.parse import urlparse
from urlparse import urlparse
import re
import requests as requests
@@ -21,13 +19,13 @@ class Rutracker(object):
self.timeout = 60
self.loggedin = False
self.maxsize = 0
self.search_referer = 'https://rutracker.org/forum/tracker.php'
self.search_referer = 'http://rutracker.org/forum/tracker.php'
def logged_in(self):
return self.loggedin
def still_logged_in(self, html):
if not html or "action=\"https://rutracker.org/forum/login.php\">" in html:
if not html or "action=\"http://rutracker.org/forum/login.php\">" in html:
return False
else:
return True
@@ -37,7 +35,7 @@ class Rutracker(object):
Logs in user
"""
loginpage = 'https://rutracker.org/forum/login.php'
loginpage = 'http://rutracker.org/forum/login.php'
post_params = {
'login_username': headphones.CONFIG.RUTRACKER_USER,
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
@@ -70,10 +68,10 @@ class Rutracker(object):
return self.loggedin
def has_bb_session_cookie(self, response):
if 'bb_session' in list(response.cookies.keys()):
if 'bb_session' in response.cookies.keys():
return True
# Rutracker randomly send a 302 redirect code, cookie may be present in response history
return next(('bb_session' in list(r.cookies.keys()) for r in response.history), False)
return next(('bb_session' in r.cookies.keys() for r in response.history), False)
def searchurl(self, artist, album, year, format):
"""
@@ -101,10 +99,10 @@ class Rutracker(object):
# sort by size, descending.
sort = '&o=7&s=2'
try:
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.parse.quote(searchterm), format, sort)
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort)
except:
searchterm = searchterm.encode('utf-8')
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.parse.quote(searchterm), format, sort)
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort)
logger.info("Searching rutracker using term: %s", searchterm)
return searchurl
@@ -116,7 +114,7 @@ class Rutracker(object):
try:
headers = {'Referer': self.search_referer}
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
soup = BeautifulSoup(r.content, 'html.parser')
soup = BeautifulSoup(r.content, 'html5lib')
# Debug
# logger.debug (soup.prettify())
@@ -125,7 +123,7 @@ class Rutracker(object):
if not self.still_logged_in(soup):
self.login()
r = self.session.get(url=searchurl, timeout=self.timeout)
soup = BeautifulSoup(r.content, 'html.parser')
soup = BeautifulSoup(r.content, 'html5lib')
if not self.still_logged_in(soup):
logger.error("Error getting rutracker data")
return None
@@ -161,7 +159,7 @@ class Rutracker(object):
# Torrent topic page
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
't']
topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id
topicurl = 'http://rutracker.org/forum/viewtopic.php?t=' + torrent_id
rulist.append((title, size, topicurl, 'rutracker.org', 'torrent', True))
else:
logger.info("%s is larger than the maxsize or has too little seeders for this category, "
@@ -181,7 +179,7 @@ class Rutracker(object):
return the .torrent data
"""
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t']
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
downloadurl = 'http://rutracker.org/forum/dl.php?t=' + torrent_id
cookie = {'bb_dl': torrent_id}
try:
headers = {'Referer': url}
+9 -8
View File
@@ -17,7 +17,7 @@
# Stolen from Sick-Beard's sab.py #
###################################
import http.cookiejar
import cookielib
import headphones
from headphones.common import USER_AGENT
@@ -30,7 +30,7 @@ def sab_api_call(request_type=None, params={}, **kwargs):
if headphones.CONFIG.SAB_HOST.endswith('/'):
headphones.CONFIG.SAB_HOST = headphones.CONFIG.SAB_HOST[
0:len(headphones.CONFIG.SAB_HOST) - 1]
0:len(headphones.CONFIG.SAB_HOST) - 1]
url = headphones.CONFIG.SAB_HOST + "/" + "api?"
@@ -74,28 +74,29 @@ def sendNZB(nzb):
# if we get a raw data result we want to upload it to SAB
elif nzb.resultType == "nzbdata":
nzbdata = nzb.extraInfo[0]
# Sanitize the file a bit, since we can only use ascii chars with MultiPartPostHandler
nzbdata = helpers.latinToAscii(nzb.extraInfo[0])
params['mode'] = 'addfile'
files = {"nzbfile": (nzb.name + ".nzb", nzbdata)}
files = {"nzbfile": (helpers.latinToAscii(nzb.name) + ".nzb", nzbdata)}
headers = {'User-Agent': USER_AGENT}
logger.info("Attempting to connect to SABnzbd on url: %s" % headphones.CONFIG.SAB_HOST)
if nzb.resultType == "nzb":
response = sab_api_call('send_nzb', params=params)
elif nzb.resultType == "nzbdata":
cookies = http.cookiejar.CookieJar()
cookies = cookielib.CookieJar()
response = sab_api_call('send_nzb', params=params, method="post", files=files,
cookies=cookies, headers=headers)
if not response:
logger.info("No data returned from SABnzbd, NZB not sent")
logger.info(u"No data returned from SABnzbd, NZB not sent")
return False
if response['status']:
logger.info("NZB sent to SABnzbd successfully")
logger.info(u"NZB sent to SABnzbd successfully")
return True
else:
logger.error("Error sending NZB to SABnzbd: %s" % response['error'])
logger.error(u"Error sending NZB to SABnzbd: %s" % response['error'])
return False
+101 -92
View File
@@ -19,13 +19,11 @@ from base64 import b16encode, b32decode
from hashlib import sha1
import string
import random
import urllib.request
import urllib.parse
import urllib.error
import urllib
import datetime
import subprocess
import unicodedata
import urllib.parse
import urlparse
import os
import re
@@ -37,12 +35,11 @@ import headphones
from headphones.common import USER_AGENT
from headphones import logger, db, helpers, classes, sab, nzbget, request
from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent
from bencode import encode as bencode
from bencode import decode as bdecode
from bencode import bencode, bdecode
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
TORRENT_TO_MAGNET_SERVICES = [
'https://itorrents.org/torrent/%s.torrent',
'http://itorrents.org/torrent/%s.torrent',
'https://cache.torrentgalaxy.org/get/%s',
'https://www.seedpeer.me/torrent/%s'
]
@@ -59,11 +56,14 @@ def fix_url(s, charset="utf-8"):
Fix the URL so it is proper formatted and encoded.
"""
scheme, netloc, path, qs, anchor = urllib.parse.urlsplit(s)
path = urllib.parse.quote(path, '/%')
qs = urllib.parse.quote_plus(qs, ':&=')
if isinstance(s, unicode):
s = s.encode(charset, 'ignore')
return urllib.parse.urlunsplit((scheme, netloc, path, qs, anchor))
scheme, netloc, path, qs, anchor = urlparse.urlsplit(s)
path = urllib.quote(path, '/%')
qs = urllib.quote_plus(qs, ':&=')
return urlparse.urlunsplit((scheme, netloc, path, qs, anchor))
def torrent_to_file(target_file, data):
@@ -88,10 +88,14 @@ def torrent_to_file(target_file, data):
try:
os.chmod(target_file, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
except OSError as e:
logger.warn(f"Could not change permissions for `{target_file}`: {e}")
logger.warn(
"Could not change permissions for file '%s': %s. Continuing.",
target_file.decode(headphones.SYS_ENCODING, "replace"),
e.message)
else:
logger.debug(
f"Not changing file permissions for `{target_file}, since it is disabled")
"Not changing file permissions, since it is disabled: %s",
target_file.decode(headphones.SYS_ENCODING, "replace"))
# Done
return True
@@ -328,13 +332,19 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
if not sorted_search_results:
return
logger.info("Making sure we can download the best result")
logger.info(u"Making sure we can download the best result")
(data, bestqual) = preprocess(sorted_search_results)
if data and bestqual:
send_to_downloader(data, bestqual, album)
def removeDisallowedFilenameChars(filename):
validFilenameChars = "-_.() %s%s" % (string.ascii_letters, string.digits)
cleanedFilename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').lower()
return ''.join(c for c in cleanedFilename if c in validFilenameChars)
def more_filtering(results, album, albumlength, new):
low_size_limit = None
high_size_limit = None
@@ -523,9 +533,9 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
else:
term = cleanartist + ' ' + cleanalbum
# Replace bad characters in the term
term = re.sub('[\.\-\/]', ' ', term)
artistterm = re.sub('[\.\-\/]', ' ', cleanartist)
# Replace bad characters in the term and unicode it
term = re.sub('[\.\-\/]', ' ', term).encode('utf-8')
artistterm = re.sub('[\.\-\/]', ' ', cleanartist).encode('utf-8')
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
@@ -572,7 +582,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
# Process feed
if data:
if not len(data.entries):
logger.info("No results found from %s for %s" % ('Headphones Index', term))
logger.info(u"No results found from %s for %s" % ('Headphones Index', term))
else:
for item in data.entries:
try:
@@ -583,7 +593,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
resultlist.append((title, size, url, provider, 'nzb', True))
logger.info('Found %s. Size: %s' % (title, helpers.bytes_to_mb(size)))
except Exception as e:
logger.error("An unknown error occurred trying to parse the feed: %s" % e)
logger.error(u"An unknown error occurred trying to parse the feed: %s" % e)
if headphones.CONFIG.NEWZNAB:
provider = "newznab"
@@ -613,7 +623,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
provider = newznab_host[0]
# Add a little mod for kere.ws
if newznab_host[0] == "https://kere.ws":
if newznab_host[0] == "http://kere.ws":
if categories == "3040":
categories = categories + ",4070"
elif categories == "3040,3010":
@@ -643,7 +653,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
# Process feed
if data:
if not len(data.entries):
logger.info("No results found from %s for %s", newznab_host[0], term)
logger.info(u"No results found from %s for %s", newznab_host[0], term)
else:
for item in data.entries:
try:
@@ -684,7 +694,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
}
data = request.request_feed(
url='https://beta.nzbs.org/api',
url='http://beta.nzbs.org/api',
params=params, headers=headers,
timeout=5
)
@@ -693,7 +703,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
# Process feed
if data:
if not len(data.entries):
logger.info("No results found from nzbs.org for %s" % term)
logger.info(u"No results found from nzbs.org for %s" % term)
else:
for item in data.entries:
try:
@@ -733,14 +743,14 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
}
data = request.request_json(
url='https://api.omgwtfnzbs.me/json/',
url='http://api.omgwtfnzbs.me/json/',
params=params, headers=headers
)
# Parse response
if data:
if 'notice' in data:
logger.info("No results returned from omgwtfnzbs: %s" % data['notice'])
logger.info(u"No results returned from omgwtfnzbs: %s" % data['notice'])
else:
for item in data:
try:
@@ -770,7 +780,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
def send_to_downloader(data, bestqual, album):
logger.info('Found best result from %s: <a href="%s">%s</a> - %s', bestqual[3], bestqual[2],
logger.info(u'Found best result from %s: <a href="%s">%s</a> - %s', bestqual[3], bestqual[2],
bestqual[0], helpers.bytes_to_mb(bestqual[1]))
# Get rid of any dodgy chars here so we can prevent sab from renaming our downloads
kind = bestqual[4]
@@ -821,8 +831,8 @@ def send_to_downloader(data, bestqual, album):
return
else:
folder_name = '%s - %s [%s]' % (
helpers.latinToAscii(album['ArtistName']).replace('/', '_'),
helpers.latinToAscii(album['AlbumTitle']).replace('/', '_'),
helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'),
helpers.latinToAscii(album['AlbumTitle']).encode('UTF-8').replace('/', '_'),
get_year_from_release_date(album['ReleaseDate']))
# Blackhole
@@ -1048,31 +1058,31 @@ def send_to_downloader(data, bestqual, album):
name = folder_name if folder_name else None
if headphones.CONFIG.GROWL_ENABLED and headphones.CONFIG.GROWL_ONSNATCH:
logger.info("Sending Growl notification")
logger.info(u"Sending Growl notification")
growl = notifiers.GROWL()
growl.notify(name, "Download started")
if headphones.CONFIG.PROWL_ENABLED and headphones.CONFIG.PROWL_ONSNATCH:
logger.info("Sending Prowl notification")
logger.info(u"Sending Prowl notification")
prowl = notifiers.PROWL()
prowl.notify(name, "Download started")
if headphones.CONFIG.PUSHOVER_ENABLED and headphones.CONFIG.PUSHOVER_ONSNATCH:
logger.info("Sending Pushover notification")
logger.info(u"Sending Pushover notification")
prowl = notifiers.PUSHOVER()
prowl.notify(name, "Download started")
if headphones.CONFIG.PUSHBULLET_ENABLED and headphones.CONFIG.PUSHBULLET_ONSNATCH:
logger.info("Sending PushBullet notification")
logger.info(u"Sending PushBullet notification")
pushbullet = notifiers.PUSHBULLET()
pushbullet.notify(name, "Download started")
if headphones.CONFIG.JOIN_ENABLED and headphones.CONFIG.JOIN_ONSNATCH:
logger.info("Sending Join notification")
logger.info(u"Sending Join notification")
join = notifiers.JOIN()
join.notify(name, "Download started")
if headphones.CONFIG.SLACK_ENABLED and headphones.CONFIG.SLACK_ONSNATCH:
logger.info("Sending Slack notification")
logger.info(u"Sending Slack notification")
slack = notifiers.SLACK()
slack.notify(name, "Download started")
if headphones.CONFIG.TELEGRAM_ENABLED and headphones.CONFIG.TELEGRAM_ONSNATCH:
logger.info("Sending Telegram notification")
logger.info(u"Sending Telegram notification")
from headphones import cache
c = cache.Cache()
album_art = c.get_artwork_from_cache(None, rgid)
@@ -1080,35 +1090,34 @@ def send_to_downloader(data, bestqual, album):
message = 'Snatched from ' + provider + '. ' + name
telegram.notify(message, "Snatched: " + title, rgid, image=album_art)
if headphones.CONFIG.TWITTER_ENABLED and headphones.CONFIG.TWITTER_ONSNATCH:
logger.info("Twitter notifications temporarily disabled")
#logger.info("Sending Twitter notification")
#twitter = notifiers.TwitterNotifier()
# twitter.notify_snatch(name)
logger.info(u"Sending Twitter notification")
twitter = notifiers.TwitterNotifier()
twitter.notify_snatch(name)
if headphones.CONFIG.NMA_ENABLED and headphones.CONFIG.NMA_ONSNATCH:
logger.info("Sending NMA notification")
logger.info(u"Sending NMA notification")
nma = notifiers.NMA()
nma.notify(snatched=name)
if headphones.CONFIG.PUSHALOT_ENABLED and headphones.CONFIG.PUSHALOT_ONSNATCH:
logger.info("Sending Pushalot notification")
logger.info(u"Sending Pushalot notification")
pushalot = notifiers.PUSHALOT()
pushalot.notify(name, "Download started")
if headphones.CONFIG.OSX_NOTIFY_ENABLED and headphones.CONFIG.OSX_NOTIFY_ONSNATCH:
from headphones import cache
c = cache.Cache()
album_art = c.get_artwork_from_cache(None, rgid)
logger.info("Sending OS X notification")
logger.info(u"Sending OS X notification")
osx_notify = notifiers.OSX_NOTIFY()
osx_notify.notify(artist,
albumname,
'Snatched: ' + provider + '. ' + name,
image=album_art)
if headphones.CONFIG.BOXCAR_ENABLED and headphones.CONFIG.BOXCAR_ONSNATCH:
logger.info("Sending Boxcar2 notification")
logger.info(u"Sending Boxcar2 notification")
b2msg = 'From ' + provider + '<br></br>' + name
boxcar = notifiers.BOXCAR()
boxcar.notify('Headphones snatched: ' + title, b2msg, rgid)
if headphones.CONFIG.EMAIL_ENABLED and headphones.CONFIG.EMAIL_ONSNATCH:
logger.info("Sending Email notification")
logger.info(u"Sending Email notification")
email = notifiers.Email()
message = 'Snatched from ' + provider + '. ' + name
email.notify("Snatched: " + title, message)
@@ -1243,12 +1252,12 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
else:
usersearchterm = ''
semi_clean_artist_term = re.sub('[\.\-\/]', ' ', semi_cleanartist)
semi_clean_album_term = re.sub('[\.\-\/]', ' ', semi_cleanalbum)
# Replace bad characters in the term
term = re.sub('[\.\-\/]', ' ', term)
artistterm = re.sub('[\.\-\/]', ' ', cleanartist)
albumterm = re.sub('[\.\-\/]', ' ', cleanalbum)
semi_clean_artist_term = re.sub('[\.\-\/]', ' ', semi_cleanartist).encode('utf-8', 'replace')
semi_clean_album_term = re.sub('[\.\-\/]', ' ', semi_cleanalbum).encode('utf-8', 'replace')
# Replace bad characters in the term and unicode it
term = re.sub('[\.\-\/]', ' ', term).encode('utf-8')
artistterm = re.sub('[\.\-\/]', ' ', cleanartist).encode('utf-8', 'replace')
albumterm = re.sub('[\.\-\/]', ' ', cleanalbum).encode('utf-8', 'replace')
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
@@ -1263,7 +1272,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
def set_proxy(proxy_url):
if not proxy_url.startswith('http'):
proxy_url = 'https://' + proxy_url
proxy_url = 'http://' + proxy_url
if proxy_url.endswith('/'):
proxy_url = proxy_url[:-1]
@@ -1324,7 +1333,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if data:
items = data.find_all('item')
if not items:
logger.info("No results found from %s for %s", provider, term)
logger.info(u"No results found from %s for %s", provider, term)
else:
for item in items:
try:
@@ -1418,7 +1427,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
# Process feed
if data:
if not len(data.entries):
logger.info("No results found from %s for %s", provider, term)
logger.info(u"No results found from %s for %s", provider, term)
else:
for item in data.entries:
try:
@@ -1430,7 +1439,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
logger.info('Found %s. Size: %s', title, helpers.bytes_to_mb(size))
except Exception as e:
logger.error(
"An error occurred while trying to parse the response from Waffles.ch: %s",
u"An error occurred while trying to parse the response from Waffles.ch: %s",
e)
# rutracker.org
@@ -1439,7 +1448,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
# Ignore if release date not specified, results too unpredictable
if not year and not usersearchterm:
logger.info("Release date not specified, ignoring for rutracker.org")
logger.info(u"Release date not specified, ignoring for rutracker.org")
else:
if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly:
format = 'lossless'
@@ -1469,7 +1478,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if headphones.CONFIG.ORPHEUS:
provider = "Orpheus.network"
providerurl = "https://orpheus.network/"
providerurl = "http://orpheus.network/"
bitrate = None
bitrate_string = bitrate
@@ -1492,7 +1501,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
bitrate_string = encoding_string
if bitrate_string not in gazelleencoding.ALL_ENCODINGS:
logger.info(
"Your preferred bitrate is not one of the available Orpheus.network filters, so not using it as a search parameter.")
u"Your preferred bitrate is not one of the available Orpheus.network filters, so not using it as a search parameter.")
maxsize = 10000000000
elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless
search_formats = [gazelleformat.FLAC, gazelleformat.MP3]
@@ -1503,18 +1512,18 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if not orpheusobj or not orpheusobj.logged_in():
try:
logger.info("Attempting to log in to Orpheus.network...")
logger.info(u"Attempting to log in to Orpheus.network...")
orpheusobj = gazelleapi.GazelleAPI(headphones.CONFIG.ORPHEUS_USERNAME,
headphones.CONFIG.ORPHEUS_PASSWORD,
headphones.CONFIG.ORPHEUS_URL)
headphones.CONFIG.ORPHEUS_PASSWORD,
headphones.CONFIG.ORPHEUS_URL)
orpheusobj._login()
except Exception as e:
orpheusobj = None
logger.error("Orpheus.network credentials incorrect or site is down. Error: %s %s" % (
logger.error(u"Orpheus.network credentials incorrect or site is down. Error: %s %s" % (
e.__class__.__name__, str(e)))
if orpheusobj and orpheusobj.logged_in():
logger.info("Searching %s..." % provider)
logger.info(u"Searching %s..." % provider)
all_torrents = []
album_type = ""
@@ -1552,27 +1561,27 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if usersearchterm:
all_torrents.extend(
orpheusobj.search_torrents(searchstr=usersearchterm, format=search_format,
encoding=bitrate_string, releasetype=album_type)['results'])
encoding=bitrate_string, releasetype=album_type)['results'])
else:
all_torrents.extend(orpheusobj.search_torrents(artistname=semi_clean_artist_term,
groupname=semi_clean_album_term,
format=search_format,
encoding=bitrate_string,
releasetype=album_type)['results'])
groupname=semi_clean_album_term,
format=search_format,
encoding=bitrate_string,
releasetype=album_type)['results'])
# filter on format, size, and num seeders
logger.info("Filtering torrents by format, maximum size, and minimum seeders...")
logger.info(u"Filtering torrents by format, maximum size, and minimum seeders...")
match_torrents = [t for t in all_torrents if
t.size <= maxsize and t.seeders >= minimumseeders]
logger.info(
"Remaining torrents: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
u"Remaining torrents: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
# sort by times d/l'd
if not len(match_torrents):
logger.info("No results found from %s for %s after filtering" % (provider, term))
logger.info(u"No results found from %s for %s after filtering" % (provider, term))
elif len(match_torrents) > 1:
logger.info("Found %d matching releases from %s for %s - %s after filtering" %
logger.info(u"Found %d matching releases from %s for %s - %s after filtering" %
(len(match_torrents), provider, artistterm, albumterm))
logger.info('Sorting torrents by number of seeders...')
match_torrents.sort(key=lambda x: int(x.seeders), reverse=True)
@@ -1586,7 +1595,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
# match_torrents.sort(key=lambda x: re.match("mp3", x.getTorrentDetails(), flags=re.I), reverse=True)
# match_torrents.sort(key=lambda x: str(bitrate) in x.getTorrentFolderName(), reverse=True)
logger.info(
"New order: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
u"New order: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
for torrent in match_torrents:
if not torrent.file_path:
@@ -1623,7 +1632,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
bitrate_string = encoding_string
if bitrate_string not in gazelleencoding.ALL_ENCODINGS:
logger.info(
"Your preferred bitrate is not one of the available RED filters, so not using it as a search parameter.")
u"Your preferred bitrate is not one of the available RED filters, so not using it as a search parameter.")
maxsize = 10000000000
elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless
search_formats = [gazelleformat.FLAC, gazelleformat.MP3]
@@ -1634,43 +1643,43 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if not redobj or not redobj.logged_in():
try:
logger.info("Attempting to log in to Redacted...")
logger.info(u"Attempting to log in to Redacted...")
redobj = gazelleapi.GazelleAPI(headphones.CONFIG.REDACTED_USERNAME,
headphones.CONFIG.REDACTED_PASSWORD,
providerurl)
headphones.CONFIG.REDACTED_PASSWORD,
providerurl)
redobj._login()
except Exception as e:
redobj = None
logger.error("Redacted credentials incorrect or site is down. Error: %s %s" % (
logger.error(u"Redacted credentials incorrect or site is down. Error: %s %s" % (
e.__class__.__name__, str(e)))
if redobj and redobj.logged_in():
logger.info("Searching %s..." % provider)
logger.info(u"Searching %s..." % provider)
all_torrents = []
for search_format in search_formats:
if usersearchterm:
all_torrents.extend(
redobj.search_torrents(searchstr=usersearchterm, format=search_format,
encoding=bitrate_string)['results'])
encoding=bitrate_string)['results'])
else:
all_torrents.extend(redobj.search_torrents(artistname=semi_clean_artist_term,
groupname=semi_clean_album_term,
format=search_format,
encoding=bitrate_string)['results'])
groupname=semi_clean_album_term,
format=search_format,
encoding=bitrate_string)['results'])
# filter on format, size, and num seeders
logger.info("Filtering torrents by format, maximum size, and minimum seeders...")
logger.info(u"Filtering torrents by format, maximum size, and minimum seeders...")
match_torrents = [t for t in all_torrents if
t.size <= maxsize and t.seeders >= minimumseeders]
logger.info(
"Remaining torrents: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
u"Remaining torrents: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
# sort by times d/l'd
if not len(match_torrents):
logger.info("No results found from %s for %s after filtering" % (provider, term))
logger.info(u"No results found from %s for %s after filtering" % (provider, term))
elif len(match_torrents) > 1:
logger.info("Found %d matching releases from %s for %s - %s after filtering" %
logger.info(u"Found %d matching releases from %s for %s - %s after filtering" %
(len(match_torrents), provider, artistterm, albumterm))
logger.info(
"Sorting torrents by times snatched and preferred bitrate %s..." % bitrate_string)
@@ -1686,7 +1695,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
# match_torrents.sort(key=lambda x: re.match("mp3", x.getTorrentDetails(), flags=re.I), reverse=True)
# match_torrents.sort(key=lambda x: str(bitrate) in x.getTorrentFolderName(), reverse=True)
logger.info(
"New order: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
u"New order: %s" % ", ".join(repr(torrent) for torrent in match_torrents))
for torrent in match_torrents:
if not torrent.file_path:
@@ -1758,8 +1767,8 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
if url.lower().startswith("//"):
url = "http:" + url
formatted_size = re.search('Size (.*),', str(item)).group(1).replace(
'\xa0', ' ')
formatted_size = re.search('Size (.*),', unicode(item)).group(1).replace(
u'\xa0', ' ')
size = helpers.piratesize(formatted_size)
if size < maxsize and minimumseeders < seeds and url is not None:
@@ -1772,7 +1781,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
resultlist.append((title, size, url, provider, "torrent", match))
except Exception as e:
logger.error("An unknown error occurred in the Pirate Bay parser: %s" % e)
logger.error(u"An unknown error occurred in the Pirate Bay parser: %s" % e)
# Old Pirate Bay Compatible
if headphones.CONFIG.OLDPIRATEBAY:
@@ -1793,7 +1802,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2243.2 Safari/537.36'}
provider_url = fix_url(headphones.CONFIG.OLDPIRATEBAY_URL) + \
"/search.php?" + urllib.parse.urlencode({"q": tpb_term, "iht": 6})
"/search.php?" + urllib.urlencode({"q": tpb_term, "iht": 6})
data = request.request_soup(url=provider_url, headers=headers)
@@ -1827,7 +1836,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
resultlist.append((title, size, url, provider, "torrent", match))
except Exception as e:
logger.error(
"An unknown error occurred in the Old Pirate Bay parser: %s" % e)
u"An unknown error occurred in the Old Pirate Bay parser: %s" % e)
# attempt to verify that this isn't a substring result
# when looking for "Foo - Foo" we don't want "Foobar"
+4 -4
View File
@@ -38,8 +38,8 @@ class SoftChrootTest(TestCase):
cf = SoftChroot(path)
self.assertIsNone(cf)
self.assertRegex(str(exc.exception), r'No such directory')
self.assertRegex(str(exc.exception), path)
self.assertRegexpMatches(str(exc.exception), r'No such directory')
self.assertRegexpMatches(str(exc.exception), path)
@mock.patch('headphones.softchroot.os', wrap=os, name='OsMock')
def test_create_on_file(self, os_mock):
@@ -57,8 +57,8 @@ class SoftChrootTest(TestCase):
self.assertTrue(os_mock.path.isdir.called)
self.assertRegex(str(exc.exception), r'No such directory')
self.assertRegex(str(exc.exception), path)
self.assertRegexpMatches(str(exc.exception), r'No such directory')
self.assertRegexpMatches(str(exc.exception), path)
@TestArgs(
(None, None),
+9 -9
View File
@@ -16,7 +16,7 @@
import time
import json
import base64
import urllib.parse
import urlparse
import os
from headphones import logger, request
@@ -57,7 +57,7 @@ def addTorrent(link, data=None):
else:
retid = False
logger.info("Torrent sent to Transmission successfully")
logger.info(u"Torrent sent to Transmission successfully")
return retid
else:
@@ -167,7 +167,7 @@ def torrentAction(method, arguments):
# Fix the URL. We assume that the user does not point to the RPC endpoint,
# so add it if it is missing.
parts = list(urllib.parse.urlparse(host))
parts = list(urlparse.urlparse(host))
if not parts[0] in ("http", "https"):
parts[0] = "http"
@@ -175,7 +175,7 @@ def torrentAction(method, arguments):
if not parts[2].endswith("/rpc"):
parts[2] += "/transmission/rpc"
host = urllib.parse.urlunparse(parts)
host = urlparse.urlunparse(parts)
data = {'method': method, 'arguments': arguments}
data_json = json.dumps(data)
auth = (username, password) if username and password else None
@@ -183,15 +183,15 @@ def torrentAction(method, arguments):
if _session_id is not None:
headers = {'x-transmission-session-id': _session_id}
response = request.request_response(host, method="POST",
data=data_json, headers=headers, auth=auth,
whitelist_status_code=[200, 401, 409])
data=data_json, headers=headers, auth=auth,
whitelist_status_code=[200, 401, 409])
else:
response = request.request_response(host, auth=auth,
whitelist_status_code=[401, 409])
whitelist_status_code=[401, 409])
if response.status_code == 401:
if auth:
logger.error("Username and/or password not accepted by "
"Transmission")
"Transmission")
else:
logger.error("Transmission authorization required")
return
@@ -205,5 +205,5 @@ def torrentAction(method, arguments):
continue
resp_json = response.json()
print(resp_json)
print resp_json
return resp_json
+1 -1
View File
@@ -44,7 +44,7 @@ class TestCase(TC):
@_d
def assertRegexpMatches(self, *args, **kw):
return super(TestCase, self).assertRegex(*args, **kw)
return super(TestCase, self).assertRegexpMatches(*args, **kw)
# -----------------------------------------------------------
# NOT DUMMY ASSERTIONS
+16 -20
View File
@@ -13,17 +13,13 @@
# You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import urllib.request
import urllib.parse
import urllib.error
import urllib
import json
import time
from collections import namedtuple
import urllib.request
import urllib.error
import urllib.parse
import urllib.parse
import http.cookiejar
import urllib2
import urlparse
import cookielib
import re
import os
@@ -56,23 +52,23 @@ class utorrentclient(object):
def _make_opener(self, realm, base_url, username, password):
"""uTorrent API need HTTP Basic Auth and cookie support for token verify."""
auth = urllib.request.HTTPBasicAuthHandler()
auth = urllib2.HTTPBasicAuthHandler()
auth.add_password(realm=realm, uri=base_url, user=username, passwd=password)
opener = urllib.request.build_opener(auth)
urllib.request.install_opener(opener)
opener = urllib2.build_opener(auth)
urllib2.install_opener(opener)
cookie_jar = http.cookiejar.CookieJar()
cookie_handler = urllib.request.HTTPCookieProcessor(cookie_jar)
cookie_jar = cookielib.CookieJar()
cookie_handler = urllib2.HTTPCookieProcessor(cookie_jar)
handlers = [auth, cookie_handler]
opener = urllib.request.build_opener(*handlers)
opener = urllib2.build_opener(*handlers)
return opener
def _get_token(self):
url = urllib.parse.urljoin(self.base_url, 'gui/token.html')
url = urlparse.urljoin(self.base_url, 'gui/token.html')
try:
response = self.opener.open(url)
except urllib.error.HTTPError as err:
except urllib2.HTTPError as err:
logger.debug('URL: ' + str(url))
logger.debug('Error getting Token. uTorrent responded with error: ' + str(err))
return
@@ -81,7 +77,7 @@ class utorrentclient(object):
def list(self, **kwargs):
params = [('list', '1')]
params += list(kwargs.items())
params += kwargs.items()
return self._action(params)
def add_url(self, url):
@@ -154,8 +150,8 @@ class utorrentclient(object):
if not self.token:
return
url = self.base_url + '/gui/' + '?token=' + self.token + '&' + urllib.parse.urlencode(params)
request = urllib.request.Request(url)
url = self.base_url + '/gui/' + '?token=' + self.token + '&' + urllib.urlencode(params)
request = urllib2.Request(url)
if body:
request.add_data(body)
@@ -166,7 +162,7 @@ class utorrentclient(object):
try:
response = self.opener.open(request)
return response.code, json.loads(response.read())
except urllib.error.HTTPError as err:
except urllib2.HTTPError as err:
logger.debug('URL: ' + str(url))
logger.debug('uTorrent webUI raised the following error: ' + str(err))
+1 -1
View File
@@ -43,7 +43,7 @@ def runGit(args):
shell=True,
cwd=headphones.PROG_DIR)
output, err = p.communicate()
output = output.decode('utf-8').strip()
output = output.strip()
logger.debug('Git output: ' + output)
except OSError as e:
+61 -66
View File
@@ -17,18 +17,14 @@
from operator import itemgetter
import threading
import secrets
import hashlib
import random
import urllib.request
import urllib.parse
import urllib.error
import urllib
import json
import time
import cgi
import sys
from html import escape as html_escape
import urllib.request
import urllib.error
import urllib.parse
import urllib2
import os
import re
@@ -101,7 +97,7 @@ class WebInterface(object):
# Serve the extras up as a dict to make things easier for new templates (append new extras to the end)
extras_list = headphones.POSSIBLE_EXTRAS
if artist['Extras']:
artist_extras = list(map(int, artist['Extras'].split(',')))
artist_extras = map(int, artist['Extras'].split(','))
else:
artist_extras = []
@@ -162,8 +158,8 @@ class WebInterface(object):
else:
searchresults = mb.findSeries(name, limit=100)
return serve_template(templatename="searchresults.html",
title='Search Results for: "' + html_escape(name) + '"',
searchresults=searchresults, name=html_escape(name), type=type)
title='Search Results for: "' + cgi.escape(name) + '"',
searchresults=searchresults, name=cgi.escape(name), type=type)
@cherrypy.expose
def addArtist(self, artistid):
@@ -234,7 +230,7 @@ class WebInterface(object):
@cherrypy.expose
def pauseArtist(self, ArtistID):
logger.info("Pausing artist: " + ArtistID)
logger.info(u"Pausing artist: " + ArtistID)
myDB = db.DBConnection()
controlValueDict = {'ArtistID': ArtistID}
newValueDict = {'Status': 'Paused'}
@@ -243,7 +239,7 @@ class WebInterface(object):
@cherrypy.expose
def resumeArtist(self, ArtistID):
logger.info("Resuming artist: " + ArtistID)
logger.info(u"Resuming artist: " + ArtistID)
myDB = db.DBConnection()
controlValueDict = {'ArtistID': ArtistID}
newValueDict = {'Status': 'Active'}
@@ -256,9 +252,9 @@ class WebInterface(object):
for name in namecheck:
artistname = name['ArtistName']
try:
logger.info("Deleting all traces of artist: " + artistname)
logger.info(u"Deleting all traces of artist: " + artistname)
except TypeError:
logger.info("Deleting all traces of artist: null")
logger.info(u"Deleting all traces of artist: null")
myDB.action('DELETE from artists WHERE ArtistID=?', [ArtistID])
from headphones import cache
@@ -295,7 +291,7 @@ class WebInterface(object):
myDB = db.DBConnection()
artist_name = myDB.select('SELECT DISTINCT ArtistName FROM artists WHERE ArtistID=?', [ArtistID])[0][0]
logger.info("Scanning artist: %s", artist_name)
logger.info(u"Scanning artist: %s", artist_name)
full_folder_format = headphones.CONFIG.FOLDER_FORMAT
folder_format = re.findall(r'(.*?[Aa]rtist?)\.*', full_folder_format)[0]
@@ -318,7 +314,7 @@ class WebInterface(object):
sortname = artist
if sortname[0].isdigit():
firstchar = '0-9'
firstchar = u'0-9'
else:
firstchar = sortname[0]
@@ -367,7 +363,7 @@ class WebInterface(object):
@cherrypy.expose
def deleteEmptyArtists(self):
logger.info("Deleting all empty artists")
logger.info(u"Deleting all empty artists")
myDB = db.DBConnection()
emptyArtistIDs = [row['ArtistID'] for row in
myDB.select("SELECT ArtistID FROM artists WHERE LatestAlbum IS NULL")]
@@ -427,7 +423,7 @@ class WebInterface(object):
@cherrypy.expose
def queueAlbum(self, AlbumID, ArtistID=None, new=False, redirect=None, lossless=False):
logger.info("Marking album: " + AlbumID + " as wanted...")
logger.info(u"Marking album: " + AlbumID + " as wanted...")
myDB = db.DBConnection()
controlValueDict = {'AlbumID': AlbumID}
if lossless:
@@ -442,11 +438,10 @@ class WebInterface(object):
raise cherrypy.HTTPRedirect(redirect)
@cherrypy.expose
@cherrypy.tools.json_out()
def choose_specific_download(self, AlbumID):
results = searcher.searchforalbum(AlbumID, choose_specific_download=True)
data = []
results_as_dicts = []
for result in results:
result_dict = {
@@ -457,34 +452,35 @@ class WebInterface(object):
'kind': result[4],
'matches': result[5]
}
data.append(result_dict)
return data
results_as_dicts.append(result_dict)
s = json.dumps(results_as_dicts)
cherrypy.response.headers['Content-type'] = 'application/json'
return s
@cherrypy.expose
@cherrypy.tools.json_out()
def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs):
# Handle situations where the torrent url contains arguments that are parsed
if kwargs:
url = urllib.parse.quote(url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
url = urllib2.quote(url, safe=":?/=&") + '&' + urllib.urlencode(kwargs)
try:
result = [(title, int(size), url, provider, kind)]
except ValueError:
result = [(title, float(size), url, provider, kind)]
logger.info("Making sure we can download the chosen result")
logger.info(u"Making sure we can download the chosen result")
(data, bestqual) = searcher.preprocess(result)
if data and bestqual:
myDB = db.DBConnection()
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
searcher.send_to_downloader(data, bestqual, album)
return {'result': 'success'}
return json.dumps({'result': 'success'})
else:
return {'result': 'failure'}
return json.dumps({'result': 'failure'})
@cherrypy.expose
def unqueueAlbum(self, AlbumID, ArtistID):
logger.info("Marking album: " + AlbumID + "as skipped...")
logger.info(u"Marking album: " + AlbumID + "as skipped...")
myDB = db.DBConnection()
controlValueDict = {'AlbumID': AlbumID}
newValueDict = {'Status': 'Skipped'}
@@ -493,7 +489,7 @@ class WebInterface(object):
@cherrypy.expose
def deleteAlbum(self, AlbumID, ArtistID=None):
logger.info("Deleting all traces of album: " + AlbumID)
logger.info(u"Deleting all traces of album: " + AlbumID)
myDB = db.DBConnection()
myDB.action('DELETE from have WHERE Matched=?', [AlbumID])
@@ -532,7 +528,7 @@ class WebInterface(object):
@cherrypy.expose
def editSearchTerm(self, AlbumID, SearchTerm):
logger.info("Updating search term for albumid: " + AlbumID)
logger.info(u"Updating search term for albumid: " + AlbumID)
myDB = db.DBConnection()
controlValueDict = {'AlbumID': AlbumID}
newValueDict = {'SearchTerm': SearchTerm}
@@ -789,7 +785,7 @@ class WebInterface(object):
track_title = tracks['TrackTitle']
if tracks['CleanName'] != original_clean:
artist_id_check = myDB.action('SELECT ArtistID FROM tracks WHERE CleanName = ?',
[tracks['CleanName']]).fetchone()
[tracks['CleanName']]).fetchone()
if artist_id_check:
artist_id = artist_id_check[0]
myDB.action(
@@ -963,7 +959,6 @@ class WebInterface(object):
raise cherrypy.HTTPRedirect("logs")
@cherrypy.expose
@cherrypy.tools.json_out()
def getLog(self, iDisplayStart=0, iDisplayLength=100, iSortCol_0=0, sSortDir_0="desc",
sSearch="", **kwargs):
iDisplayStart = int(iDisplayStart)
@@ -986,14 +981,13 @@ class WebInterface(object):
rows = filtered[iDisplayStart:(iDisplayStart + iDisplayLength)]
rows = [[row[0], row[2], row[1]] for row in rows]
return {
return json.dumps({
'iTotalDisplayRecords': len(filtered),
'iTotalRecords': len(headphones.LOG_LIST),
'aaData': rows,
}
})
@cherrypy.expose
@cherrypy.tools.json_out()
def getArtists_json(self, iDisplayStart=0, iDisplayLength=100, sSearch="", iSortCol_0='0',
sSortDir_0='asc', **kwargs):
iDisplayStart = int(iDisplayStart)
@@ -1061,58 +1055,61 @@ class WebInterface(object):
rows.append(row)
data = {'iTotalDisplayRecords': len(filtered),
dict = {'iTotalDisplayRecords': len(filtered),
'iTotalRecords': totalcount,
'aaData': rows,
}
return data
s = json.dumps(dict)
cherrypy.response.headers['Content-type'] = 'application/json'
return s
@cherrypy.expose
@cherrypy.tools.json_out()
def getAlbumsByArtist_json(self, artist=None):
myDB = db.DBConnection()
data = {}
album_json = {}
counter = 0
album_list = myDB.select("SELECT AlbumTitle from albums WHERE ArtistName=?", [artist])
for album in album_list:
data[counter] = album['AlbumTitle']
album_json[counter] = album['AlbumTitle']
counter += 1
json_albums = json.dumps(album_json)
return data
cherrypy.response.headers['Content-type'] = 'application/json'
return json_albums
@cherrypy.expose
@cherrypy.tools.json_out()
def getArtistjson(self, ArtistID, **kwargs):
myDB = db.DBConnection()
artist = myDB.action('SELECT * FROM artists WHERE ArtistID=?', [ArtistID]).fetchone()
return {
artist_json = json.dumps({
'ArtistName': artist['ArtistName'],
'Status': artist['Status']
}
})
return artist_json
@cherrypy.expose
@cherrypy.tools.json_out()
def getAlbumjson(self, AlbumID, **kwargs):
myDB = db.DBConnection()
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
return {
album_json = json.dumps({
'AlbumTitle': album['AlbumTitle'],
'ArtistName': album['ArtistName'],
'Status': album['Status']
}
})
return album_json
@cherrypy.expose
def clearhistory(self, type=None, date_added=None, title=None):
myDB = db.DBConnection()
if type:
if type == 'all':
logger.info("Clearing all history")
logger.info(u"Clearing all history")
myDB.action('DELETE from snatched WHERE Status NOT LIKE "Seed%"')
else:
logger.info("Clearing history where status is %s" % type)
logger.info(u"Clearing history where status is %s" % type)
myDB.action('DELETE from snatched WHERE Status=?', [type])
else:
logger.info("Deleting '%s' from history" % title)
logger.info(u"Deleting '%s' from history" % title)
myDB.action(
'DELETE from snatched WHERE Status NOT LIKE "Seed%" AND Title=? AND DateAdded=?',
[title, date_added])
@@ -1120,7 +1117,7 @@ class WebInterface(object):
@cherrypy.expose
def generateAPI(self):
apikey = secrets.token_hex(nbytes=16)
apikey = hashlib.sha224(str(random.getrandbits(256))).hexdigest()[0:32]
logger.info("New API generated")
return apikey
@@ -1421,7 +1418,7 @@ class WebInterface(object):
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID
}
for k, v in config.items():
for k, v in config.iteritems():
if isinstance(v, headphones.config.path):
# need to apply SoftChroot to paths:
nv = headphones.SOFT_CHROOT.apply(v)
@@ -1438,7 +1435,7 @@ class WebInterface(object):
extras_list = [extra_munges.get(x, x) for x in headphones.POSSIBLE_EXTRAS]
if headphones.CONFIG.EXTRAS:
extras = list(map(int, headphones.CONFIG.EXTRAS.split(',')))
extras = map(int, headphones.CONFIG.EXTRAS.split(','))
else:
extras = []
@@ -1499,7 +1496,7 @@ class WebInterface(object):
kwargs[plain_config] = kwargs[use_config]
del kwargs[use_config]
for k, v in kwargs.items():
for k, v in kwargs.iteritems():
# TODO : HUGE crutch. It is all because there is no way to deal with options...
try:
_conf = headphones.CONFIG._define(k)
@@ -1651,13 +1648,12 @@ class WebInterface(object):
return a.fetchData()
@cherrypy.expose
@cherrypy.tools.json_out()
def getInfo(self, ArtistID=None, AlbumID=None):
from headphones import cache
info_dict = cache.getInfo(ArtistID, AlbumID)
return info_dict
return json.dumps(info_dict)
@cherrypy.expose
def getArtwork(self, ArtistID=None, AlbumID=None):
@@ -1674,25 +1670,24 @@ class WebInterface(object):
# If you just want to get the last.fm image links for an album, make sure
# to pass a releaseid and not a releasegroupid
@cherrypy.expose
@cherrypy.tools.json_out()
def getImageLinks(self, ArtistID=None, AlbumID=None):
from headphones import cache
image_dict = cache.getImageLinks(ArtistID, AlbumID)
# Return the Cover Art Archive urls if not found on last.fm
if AlbumID and not image_dict:
image_url = "https://coverartarchive.org/release/%s/front-500.jpg" % AlbumID
thumb_url = "https://coverartarchive.org/release/%s/front-250.jpg" % AlbumID
image_url = "http://coverartarchive.org/release/%s/front-500.jpg" % AlbumID
thumb_url = "http://coverartarchive.org/release/%s/front-250.jpg" % AlbumID
image_dict = {'artwork': image_url, 'thumbnail': thumb_url}
elif AlbumID and (not image_dict['artwork'] or not image_dict['thumbnail']):
if not image_dict['artwork']:
image_dict[
'artwork'] = "https://coverartarchive.org/release/%s/front-500.jpg" % AlbumID
'artwork'] = "http://coverartarchive.org/release/%s/front-500.jpg" % AlbumID
if not image_dict['thumbnail']:
image_dict[
'thumbnail'] = "https://coverartarchive.org/release/%s/front-250.jpg" % AlbumID
'thumbnail'] = "http://coverartarchive.org/release/%s/front-250.jpg" % AlbumID
return image_dict
return json.dumps(image_dict)
@cherrypy.expose
def twitterStep1(self):
@@ -1705,7 +1700,7 @@ class WebInterface(object):
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
tweet = notifiers.TwitterNotifier()
result = tweet._get_credentials(key)
logger.info("result: " + str(result))
logger.info(u"result: " + str(result))
if result:
return "Key verification successful"
else:
@@ -1737,14 +1732,14 @@ class WebInterface(object):
@cherrypy.expose
def testPushover(self):
logger.info("Sending Pushover notification")
logger.info(u"Sending Pushover notification")
pushover = notifiers.PUSHOVER()
result = pushover.notify("hooray!", "This is a test")
return str(result)
@cherrypy.expose
def testPlex(self):
logger.info("Testing plex update")
logger.info(u"Testing plex update")
plex = notifiers.Plex()
plex.update()
+3
View File
@@ -111,9 +111,12 @@ def initialize(options):
})
conf['/api'] = {'tools.auth_basic.on': False}
# Prevent time-outs
cherrypy.engine.timeout_monitor.unsubscribe()
cherrypy.tree.mount(WebInterface(), str(options['http_root']), config=conf)
try:
cherrypy.process.servers.check_port(str(options['http_host']), options['http_port'])
cherrypy.server.start()
except IOError:
sys.stderr.write(
+8 -8
View File
@@ -15,8 +15,8 @@
# Lesser General Public License for more details.
#
import urllib.request, urllib.parse, urllib.error
import urllib.request, urllib.error, urllib.parse
import urllib
import urllib2
import mimetools, mimetypes
import os, sys
@@ -24,8 +24,8 @@ import os, sys
# assigning a sequence.
doseq = 1
class MultipartPostHandler(urllib.request.BaseHandler):
handler_order = urllib.request.HTTPHandler.handler_order - 10 # needs to run first
class MultipartPostHandler(urllib2.BaseHandler):
handler_order = urllib2.HTTPHandler.handler_order - 10 # needs to run first
def http_request(self, request):
data = request.get_data()
@@ -33,23 +33,23 @@ class MultipartPostHandler(urllib.request.BaseHandler):
v_files = []
v_vars = []
try:
for(key, value) in list(data.items()):
for(key, value) in data.items():
if type(value) in (file, list, tuple):
v_files.append((key, value))
else:
v_vars.append((key, value))
except TypeError:
systype, value, traceback = sys.exc_info()
raise TypeError("not a valid non-string sequence or mapping object").with_traceback(traceback)
raise TypeError, "not a valid non-string sequence or mapping object", traceback
if len(v_files) == 0:
data = urllib.parse.urlencode(v_vars, doseq)
data = urllib.urlencode(v_vars, doseq)
else:
boundary, data = MultipartPostHandler.multipart_encode(v_vars, v_files)
contenttype = 'multipart/form-data; boundary=%s' % boundary
if(request.has_header('Content-Type')
and request.get_header('Content-Type').find('multipart/form-data') != 0):
print("Replacing %s with %s" % (request.get_header('content-type'), 'multipart/form-data'))
print "Replacing %s with %s" % (request.get_header('content-type'), 'multipart/form-data')
request.add_unredirected_header('Content-Type', contenttype)
request.add_data(data)
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import sys
from apscheduler.executors.base import BaseExecutor, run_job
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import sys
from apscheduler.executors.base import BaseExecutor, run_job
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.executors.base import BaseExecutor, run_job
+1 -1
View File
@@ -1,4 +1,4 @@
from collections.abc import Iterable, Mapping
from collections import Iterable, Mapping
from uuid import uuid4
import six
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import datetime_to_utc_timestamp
+2 -2
View File
@@ -1,11 +1,11 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job
try:
import pickle as pickle
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
+2 -2
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import six
@@ -7,7 +7,7 @@ from apscheduler.util import datetime_to_utc_timestamp, utc_timestamp_to_datetim
from apscheduler.job import Job
try:
import pickle as pickle
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
+2 -2
View File
@@ -1,11 +1,11 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job
try:
import pickle as pickle
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from functools import wraps
from apscheduler.schedulers.base import BaseScheduler
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from threading import Thread, Event
from apscheduler.schedulers.base import BaseScheduler
+2 -2
View File
@@ -1,6 +1,6 @@
from __future__ import print_function
from abc import ABCMeta, abstractmethod
from collections.abc import MutableMapping
from collections import MutableMapping
from threading import RLock
from datetime import datetime
from logging import getLogger
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from threading import Event
from apscheduler.schedulers.base import BaseScheduler
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.schedulers.base import BaseScheduler
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.schedulers.base import BaseScheduler
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from datetime import timedelta
from functools import wraps
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from functools import wraps
from apscheduler.schedulers.base import BaseScheduler
+3 -3
View File
@@ -1,6 +1,6 @@
"""This module contains several handy functions primarily meant for internal use."""
from __future__ import division
from datetime import date, datetime, time, timedelta, tzinfo
from inspect import isfunction, ismethod, getargspec
from calendar import timegm
@@ -23,7 +23,7 @@ __all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_
class _Undefined(object):
def __bool__(self):
def __nonzero__(self):
return False
def __bool__(self):
@@ -116,7 +116,7 @@ def convert_to_datetime(input, tz, arg_name):
m = _DATE_REGEX.match(input)
if not m:
raise ValueError('Invalid date string')
values = [(k, int(v or 0)) for k, v in list(m.groupdict().items())]
values = [(k, int(v or 0)) for k, v in m.groupdict().items()]
values = dict(values)
datetime_ = datetime(**values)
else:
Regular → Executable
+19 -14
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -12,29 +13,33 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
import confuse
from sys import stderr
import os
__version__ = '1.6.0'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
from beets.util import confit
# This particular version has been slightly modified to work with Headphones
# https://github.com/rembo10/headphones
__version__ = u'1.4.4-headphones'
__author__ = u'Adrian Sampson <adrian@radbox.org>'
class IncludeLazyConfig(confuse.LazyConfig):
"""A version of Confuse's LazyConfig that also merges in data from
class IncludeLazyConfig(confit.LazyConfig):
"""A version of Confit's LazyConfig that also merges in data from
YAML files specified in an `include` setting.
"""
def read(self, user=True, defaults=True):
super().read(user, defaults)
super(IncludeLazyConfig, self).read(user, defaults)
try:
for view in self['include']:
self.set_file(view.as_filename())
except confuse.NotFoundError:
filename = view.as_filename()
if os.path.isfile(filename):
self.set_file(filename)
except confit.NotFoundError:
pass
except confuse.ConfigReadError as err:
stderr.write("configuration `import` failed: {}"
.format(err.reason))
config = IncludeLazyConfig('beets', __name__)
# headphones
#config = IncludeLazyConfig('beets', __name__)
config = IncludeLazyConfig(os.path.dirname(__file__), __name__)
Regular → Executable
+2
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Adrian Sampson.
#
@@ -16,6 +17,7 @@
`python -m beets`.
"""
from __future__ import division, absolute_import, print_function
import sys
from .ui import main
Regular → Executable
+35 -37
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -16,6 +17,7 @@
music and items' embedded album art.
"""
from __future__ import division, absolute_import, print_function
import subprocess
import platform
@@ -24,7 +26,7 @@ import os
from beets.util import displayable_path, syspath, bytestring_path
from beets.util.artresizer import ArtResizer
import mediafile
from beets import mediafile
def mediafile_image(image_path, maxwidth=None):
@@ -41,7 +43,7 @@ def get_art(log, item):
try:
mf = mediafile.MediaFile(syspath(item.path))
except mediafile.UnreadableFileError as exc:
log.warning('Could not extract art from {0}: {1}',
log.warning(u'Could not extract art from {0}: {1}',
displayable_path(item.path), exc)
return
@@ -49,27 +51,26 @@ def get_art(log, item):
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
quality=0):
compare_threshold=0, ifempty=False, as_album=False):
"""Embed an image into the item's media file.
"""
# Conditions and filters.
if compare_threshold:
if not check_art_similarity(log, item, imagepath, compare_threshold):
log.info('Image not similar; skipping.')
log.info(u'Image not similar; skipping.')
return
if ifempty and get_art(log, item):
log.info('media file already contained art')
return
log.info(u'media file already contained art')
return
if maxwidth and not as_album:
imagepath = resize_image(log, imagepath, maxwidth, quality)
imagepath = resize_image(log, imagepath, maxwidth)
# Get the `Image` object from the file.
try:
log.debug('embedding {0}', displayable_path(imagepath))
log.debug(u'embedding {0}', displayable_path(imagepath))
image = mediafile_image(imagepath, maxwidth)
except OSError as exc:
log.warning('could not read image file: {0}', exc)
except IOError as exc:
log.warning(u'could not read image file: {0}', exc)
return
# Make sure the image kind is safe (some formats only support PNG
@@ -79,39 +80,36 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
image.mime_type)
return
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
item.try_write(path=itempath, tags={'images': [image]})
def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
ifempty=False, quality=0):
def embed_album(log, album, maxwidth=None, quiet=False,
compare_threshold=0, ifempty=False):
"""Embed album art into all of the album's items.
"""
imagepath = album.artpath
if not imagepath:
log.info('No album art present for {0}', album)
log.info(u'No album art present for {0}', album)
return
if not os.path.isfile(syspath(imagepath)):
log.info('Album art not found at {0} for {1}',
log.info(u'Album art not found at {0} for {1}',
displayable_path(imagepath), album)
return
if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth, quality)
imagepath = resize_image(log, imagepath, maxwidth)
log.info('Embedding album art into {0}', album)
log.info(u'Embedding album art into {0}', album)
for item in album.items():
embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
ifempty, as_album=True, quality=quality)
embed_item(log, item, imagepath, maxwidth, None,
compare_threshold, ifempty, as_album=True)
def resize_image(log, imagepath, maxwidth, quality):
"""Returns path to an image resized to maxwidth and encoded with the
specified quality level.
def resize_image(log, imagepath, maxwidth):
"""Returns path to an image resized to maxwidth.
"""
log.debug('Resizing album art to {0} pixels wide and encoding at quality \
level {1}', maxwidth, quality)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
quality=quality)
log.debug(u'Resizing album art to {0} pixels wide', maxwidth)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath))
return imagepath
@@ -133,7 +131,7 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
syspath(art, prefix=False),
'-colorspace', 'gray', 'MIFF:-']
compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
log.debug('comparing images with pipeline {} | {}',
log.debug(u'comparing images with pipeline {} | {}',
convert_cmd, compare_cmd)
convert_proc = subprocess.Popen(
convert_cmd,
@@ -157,7 +155,7 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
convert_proc.wait()
if convert_proc.returncode:
log.debug(
'ImageMagick convert failed with status {}: {!r}',
u'ImageMagick convert failed with status {}: {!r}',
convert_proc.returncode,
convert_stderr,
)
@@ -167,7 +165,7 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
stdout, stderr = compare_proc.communicate()
if compare_proc.returncode:
if compare_proc.returncode != 1:
log.debug('ImageMagick compare failed: {0}, {1}',
log.debug(u'ImageMagick compare failed: {0}, {1}',
displayable_path(imagepath),
displayable_path(art))
return
@@ -178,10 +176,10 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
try:
phash_diff = float(out_str)
except ValueError:
log.debug('IM output is not a number: {0!r}', out_str)
log.debug(u'IM output is not a number: {0!r}', out_str)
return
log.debug('ImageMagick compare score: {0}', phash_diff)
log.debug(u'ImageMagick compare score: {0}', phash_diff)
return phash_diff <= compare_threshold
return True
@@ -191,18 +189,18 @@ def extract(log, outpath, item):
art = get_art(log, item)
outpath = bytestring_path(outpath)
if not art:
log.info('No album art present in {0}, skipping.', item)
log.info(u'No album art present in {0}, skipping.', item)
return
# Add an extension to the filename.
ext = mediafile.image_extension(art)
if not ext:
log.warning('Unknown image type in {0}.',
log.warning(u'Unknown image type in {0}.',
displayable_path(item.path))
return
outpath += bytestring_path('.' + ext)
log.info('Extracting album art from: {0} to: {1}',
log.info(u'Extracting album art from: {0} to: {1}',
item, displayable_path(outpath))
with open(syspath(outpath), 'wb') as f:
f.write(art)
@@ -218,7 +216,7 @@ def extract_first(log, outpath, items):
def clear(log, lib, query):
items = lib.items(query)
log.info('Clearing album art from {0} items', len(items))
log.info(u'Clearing album art from {0} items', len(items))
for item in items:
log.debug('Clearing art for {0}', item)
log.debug(u'Clearing art for {0}', item)
item.try_write(tags={'images': None})
Regular → Executable
+45 -82
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -15,59 +16,19 @@
"""Facilities for automatically determining files' correct metadata.
"""
from __future__ import division, absolute_import, print_function
from beets import logging
from beets import config
# Parts of external interface.
from .hooks import ( # noqa
AlbumInfo,
TrackInfo,
AlbumMatch,
TrackMatch,
Distance,
)
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa
from .match import tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa
# Global logger.
log = logging.getLogger('beets')
# Metadata fields that are already hardcoded, or where the tag name changes.
SPECIAL_FIELDS = {
'album': (
'va',
'releasegroup_id',
'artist_id',
'album_id',
'mediums',
'tracks',
'year',
'month',
'day',
'artist',
'artist_credit',
'artist_sort',
'data_url'
),
'track': (
'track_alt',
'artist_id',
'release_track_id',
'medium',
'index',
'medium_index',
'title',
'artist_credit',
'artist_sort',
'artist',
'track_id',
'medium_total',
'data_url',
'length'
)
}
# Additional utilities for the main interface.
@@ -79,17 +40,17 @@ def apply_item_metadata(item, track_info):
item.artist_credit = track_info.artist_credit
item.title = track_info.title
item.mb_trackid = track_info.track_id
item.mb_releasetrackid = track_info.release_track_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
if track_info.data_source:
item.data_source = track_info.data_source
for field, value in track_info.items():
# We only overwrite fields that are not already hardcoded.
if field in SPECIAL_FIELDS['track']:
continue
if value is None:
continue
item[field] = value
if track_info.lyricist is not None:
item.lyricist = track_info.lyricist
if track_info.composer is not None:
item.composer = track_info.composer
if track_info.arranger is not None:
item.arranger = track_info.arranger
# At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied?
@@ -100,19 +61,12 @@ def apply_metadata(album_info, mapping):
mapping from Items to TrackInfo objects.
"""
for item, track_info in mapping.items():
# Artist or artist credit.
if config['artist_credit']:
item.artist = (track_info.artist_credit or
track_info.artist or
album_info.artist_credit or
album_info.artist)
item.albumartist = (album_info.artist_credit or
album_info.artist)
# Album, artist, track count.
if track_info.artist:
item.artist = track_info.artist
else:
item.artist = (track_info.artist or album_info.artist)
item.albumartist = album_info.artist
# Album.
item.artist = album_info.artist
item.albumartist = album_info.artist
item.album = album_info.album
# Artist sort and credit names.
@@ -166,7 +120,6 @@ def apply_metadata(album_info, mapping):
# MusicBrainz IDs.
item.mb_trackid = track_info.track_id
item.mb_releasetrackid = track_info.release_track_id
item.mb_albumid = album_info.album_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
@@ -178,24 +131,34 @@ def apply_metadata(album_info, mapping):
# Compilation flag.
item.comp = album_info.va
# Track alt.
# Miscellaneous metadata.
for field in ('albumtype',
'label',
'asin',
'catalognum',
'script',
'language',
'country',
'albumstatus',
'albumdisambig',
'data_source',):
value = getattr(album_info, field)
if value is not None:
item[field] = value
if track_info.disctitle is not None:
item.disctitle = track_info.disctitle
if track_info.media is not None:
item.media = track_info.media
if track_info.lyricist is not None:
item.lyricist = track_info.lyricist
if track_info.composer is not None:
item.composer = track_info.composer
if track_info.arranger is not None:
item.arranger = track_info.arranger
item.track_alt = track_info.track_alt
# Don't overwrite fields with empty values unless the
# field is explicitly allowed to be overwritten
for field, value in album_info.items():
if field in SPECIAL_FIELDS['album']:
continue
clobber = field in config['overwrite_null']['album'].as_str_seq()
if value is None and not clobber:
continue
item[field] = value
for field, value in track_info.items():
if field in SPECIAL_FIELDS['track']:
continue
clobber = field in config['overwrite_null']['track'].as_str_seq()
value = getattr(track_info, field)
if value is None and not clobber:
continue
item[field] = value
# Headphones seal of approval
item.comments = 'tagged by headphones/beets'
Regular → Executable
+93 -121
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -13,6 +14,7 @@
# included in all copies or substantial portions of the Software.
"""Glue between metadata sources and the matching logic."""
from __future__ import division, absolute_import, print_function
from collections import namedtuple
from functools import total_ordering
@@ -25,36 +27,14 @@ from beets.util import as_string
from beets.autotag import mb
from jellyfish import levenshtein_distance
from unidecode import unidecode
import six
log = logging.getLogger('beets')
# The name of the type for patterns in re changed in Python 3.7.
try:
Pattern = re._pattern_type
except AttributeError:
Pattern = re.Pattern
# Classes used to represent candidate options.
class AttrDict(dict):
"""A dictionary that supports attribute ("dot") access, so `d.field`
is equivalent to `d['field']`.
"""
def __getattr__(self, attr):
if attr in self:
return self.get(attr)
else:
raise AttributeError
def __setattr__(self, key, value):
self.__setitem__(key, value)
def __hash__(self):
return id(self)
class AlbumInfo(AttrDict):
class AlbumInfo(object):
"""Describes a canonical release that may be used to match a release
in the library. Consists of these data members:
@@ -63,22 +43,38 @@ class AlbumInfo(AttrDict):
- ``artist``: name of the release's primary artist
- ``artist_id``
- ``tracks``: list of TrackInfo objects making up the release
- ``asin``: Amazon ASIN
- ``albumtype``: string describing the kind of release
- ``va``: boolean: whether the release has "various artists"
- ``year``: release year
- ``month``: release month
- ``day``: release day
- ``label``: music label responsible for the release
- ``mediums``: the number of discs in this release
- ``artist_sort``: name of the release's artist for sorting
- ``releasegroup_id``: MBID for the album's release group
- ``catalognum``: the label's catalog number for the release
- ``script``: character set used for metadata
- ``language``: human language of the metadata
- ``country``: the release country
- ``albumstatus``: MusicBrainz release status (Official, etc.)
- ``media``: delivery mechanism (Vinyl, etc.)
- ``albumdisambig``: MusicBrainz release disambiguation comment
- ``artist_credit``: Release-specific artist name
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
- ``data_url``: The data source release URL.
``mediums`` along with the fields up through ``tracks`` are required.
The others are optional and may be None.
The fields up through ``tracks`` are required. The others are
optional and may be None.
"""
def __init__(self, tracks, album=None, album_id=None, artist=None,
artist_id=None, asin=None, albumtype=None, va=False,
year=None, month=None, day=None, label=None, mediums=None,
artist_sort=None, releasegroup_id=None, catalognum=None,
script=None, language=None, country=None, style=None,
genre=None, albumstatus=None, media=None, albumdisambig=None,
releasegroupdisambig=None, artist_credit=None,
original_year=None, original_month=None,
original_day=None, data_source=None, data_url=None,
discogs_albumid=None, discogs_labelid=None,
discogs_artistid=None, **kwargs):
def __init__(self, album, album_id, artist, artist_id, tracks, asin=None,
albumtype=None, va=False, year=None, month=None, day=None,
label=None, mediums=None, artist_sort=None,
releasegroup_id=None, catalognum=None, script=None,
language=None, country=None, albumstatus=None, media=None,
albumdisambig=None, artist_credit=None, original_year=None,
original_month=None, original_day=None, data_source=None,
data_url=None):
self.album = album
self.album_id = album_id
self.artist = artist
@@ -98,22 +94,15 @@ class AlbumInfo(AttrDict):
self.script = script
self.language = language
self.country = country
self.style = style
self.genre = genre
self.albumstatus = albumstatus
self.media = media
self.albumdisambig = albumdisambig
self.releasegroupdisambig = releasegroupdisambig
self.artist_credit = artist_credit
self.original_year = original_year
self.original_month = original_month
self.original_day = original_day
self.data_source = data_source
self.data_url = data_url
self.discogs_albumid = discogs_albumid
self.discogs_labelid = discogs_labelid
self.discogs_artistid = discogs_artistid
self.update(kwargs)
# Work around a bug in python-musicbrainz-ngs that causes some
# strings to be bytes rather than Unicode.
@@ -123,49 +112,53 @@ class AlbumInfo(AttrDict):
constituent `TrackInfo` objects, are decoded to Unicode.
"""
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
'catalognum', 'script', 'language', 'country', 'style',
'genre', 'albumstatus', 'albumdisambig',
'releasegroupdisambig', 'artist_credit',
'media', 'discogs_albumid', 'discogs_labelid',
'discogs_artistid']:
'catalognum', 'script', 'language', 'country',
'albumstatus', 'albumdisambig', 'artist_credit', 'media']:
value = getattr(self, fld)
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
for track in self.tracks:
track.decode(codec)
def copy(self):
dupe = AlbumInfo([])
dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks]
return dupe
if self.tracks:
for track in self.tracks:
track.decode(codec)
class TrackInfo(AttrDict):
class TrackInfo(object):
"""Describes a canonical track present on a release. Appears as part
of an AlbumInfo's ``tracks`` list. Consists of these data members:
- ``title``: name of the track
- ``track_id``: MusicBrainz ID; UUID fragment only
- ``artist``: individual track artist name
- ``artist_id``
- ``length``: float: duration of the track in seconds
- ``index``: position on the entire release
- ``media``: delivery mechanism (Vinyl, etc.)
- ``medium``: the disc number this track appears on in the album
- ``medium_index``: the track's position on the disc
- ``medium_total``: the number of tracks on the item's disc
- ``artist_sort``: name of the track artist for sorting
- ``disctitle``: name of the individual medium (subtitle)
- ``artist_credit``: Recording-specific artist name
- ``data_source``: The original data source (MusicBrainz, Discogs, etc.)
- ``data_url``: The data source release URL.
- ``lyricist``: individual track lyricist name
- ``composer``: individual track composer name
- ``arranger`: individual track arranger name
- ``track_alt``: alternative track number (tape, vinyl, etc.)
Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index``
are all 1-based.
"""
def __init__(self, title=None, track_id=None, release_track_id=None,
artist=None, artist_id=None, length=None, index=None,
medium=None, medium_index=None, medium_total=None,
artist_sort=None, disctitle=None, artist_credit=None,
data_source=None, data_url=None, media=None, lyricist=None,
composer=None, composer_sort=None, arranger=None,
track_alt=None, work=None, mb_workid=None,
work_disambig=None, bpm=None, initial_key=None, genre=None,
**kwargs):
def __init__(self, title, track_id, artist=None, artist_id=None,
length=None, index=None, medium=None, medium_index=None,
medium_total=None, artist_sort=None, disctitle=None,
artist_credit=None, data_source=None, data_url=None,
media=None, lyricist=None, composer=None, arranger=None,
track_alt=None):
self.title = title
self.track_id = track_id
self.release_track_id = release_track_id
self.artist = artist
self.artist_id = artist_id
self.length = length
@@ -181,16 +174,8 @@ class TrackInfo(AttrDict):
self.data_url = data_url
self.lyricist = lyricist
self.composer = composer
self.composer_sort = composer_sort
self.arranger = arranger
self.track_alt = track_alt
self.work = work
self.mb_workid = mb_workid
self.work_disambig = work_disambig
self.bpm = bpm
self.initial_key = initial_key
self.genre = genre
self.update(kwargs)
# As above, work around a bug in python-musicbrainz-ngs.
def decode(self, codec='utf-8'):
@@ -203,11 +188,6 @@ class TrackInfo(AttrDict):
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
def copy(self):
dupe = TrackInfo()
dupe.update(self)
return dupe
# Candidate distance scoring.
@@ -235,8 +215,8 @@ def _string_dist_basic(str1, str2):
transliteration/lowering to ASCII characters. Normalized by string
length.
"""
assert isinstance(str1, str)
assert isinstance(str2, str)
assert isinstance(str1, six.text_type)
assert isinstance(str2, six.text_type)
str1 = as_string(unidecode(str1))
str2 = as_string(unidecode(str2))
str1 = re.sub(r'[^a-z0-9]', '', str1.lower())
@@ -264,9 +244,9 @@ def string_dist(str1, str2):
# "something, the".
for word in SD_END_WORDS:
if str1.endswith(', %s' % word):
str1 = '{} {}'.format(word, str1[:-len(word) - 2])
str1 = '%s %s' % (word, str1[:-len(word) - 2])
if str2.endswith(', %s' % word):
str2 = '{} {}'.format(word, str2[:-len(word) - 2])
str2 = '%s %s' % (word, str2[:-len(word) - 2])
# Perform a couple of basic normalizing substitutions.
for pat, repl in SD_REPLACE:
@@ -304,12 +284,11 @@ def string_dist(str1, str2):
return base_dist + penalty
class LazyClassProperty:
class LazyClassProperty(object):
"""A decorator implementing a read-only property that is *lazy* in
the sense that the getter is only invoked once. Subsequent accesses
through *any* instance use the cached result.
"""
def __init__(self, getter):
self.getter = getter
self.computed = False
@@ -322,17 +301,17 @@ class LazyClassProperty:
@total_ordering
class Distance:
@six.python_2_unicode_compatible
class Distance(object):
"""Keeps track of multiple distance penalties. Provides a single
weighted distance for all penalties as well as a weighted distance
for each individual penalty.
"""
def __init__(self):
self._penalties = {}
@LazyClassProperty
def _weights(cls): # noqa: N805
def _weights(cls): # noqa
"""A dictionary from keys to floating-point weights.
"""
weights_view = config['match']['distance_weights']
@@ -410,7 +389,7 @@ class Distance:
return other - self.distance
def __str__(self):
return f"{self.distance:.2f}"
return "{0:.2f}".format(self.distance)
# Behave like a dict.
@@ -437,7 +416,7 @@ class Distance:
"""
if not isinstance(dist, Distance):
raise ValueError(
'`dist` must be a Distance object, not {}'.format(type(dist))
u'`dist` must be a Distance object, not {0}'.format(type(dist))
)
for key, penalties in dist._penalties.items():
self._penalties.setdefault(key, []).extend(penalties)
@@ -449,7 +428,7 @@ class Distance:
be a compiled regular expression, in which case it will be
matched against `value2`.
"""
if isinstance(value1, Pattern):
if isinstance(value1, re._pattern_type):
return bool(value1.match(value2))
return value1 == value2
@@ -461,7 +440,7 @@ class Distance:
"""
if not 0.0 <= dist <= 1.0:
raise ValueError(
f'`dist` must be between 0.0 and 1.0, not {dist}'
u'`dist` must be between 0.0 and 1.0, not {0}'.format(dist)
)
self._penalties.setdefault(key, []).append(dist)
@@ -555,10 +534,7 @@ def album_for_mbid(release_id):
if the ID is not found.
"""
try:
album = mb.album_for_id(release_id)
if album:
plugins.send('albuminfo_received', info=album)
return album
return mb.album_for_id(release_id)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
@@ -568,14 +544,12 @@ def track_for_mbid(recording_id):
if the ID is not found.
"""
try:
track = mb.track_for_id(recording_id)
if track:
plugins.send('trackinfo_received', info=track)
return track
return mb.track_for_id(recording_id)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
@plugins.notify_info_yielded(u'albuminfo_received')
def albums_for_id(album_id):
"""Get a list of albums for an ID."""
a = album_for_mbid(album_id)
@@ -583,10 +557,10 @@ def albums_for_id(album_id):
yield a
for a in plugins.album_for_id(album_id):
if a:
plugins.send('albuminfo_received', info=a)
yield a
@plugins.notify_info_yielded(u'trackinfo_received')
def tracks_for_id(track_id):
"""Get a list of tracks for an ID."""
t = track_for_mbid(track_id)
@@ -594,43 +568,39 @@ def tracks_for_id(track_id):
yield t
for t in plugins.track_for_id(track_id):
if t:
plugins.send('trackinfo_received', info=t)
yield t
@plugins.notify_info_yielded('albuminfo_received')
def album_candidates(items, artist, album, va_likely, extra_tags):
@plugins.notify_info_yielded(u'albuminfo_received')
def album_candidates(items, artist, album, va_likely):
"""Search for album matches. ``items`` is a list of Item objects
that make up the album. ``artist`` and ``album`` are the respective
names (strings), which may be derived from the item list or may be
entered by the user. ``va_likely`` is a boolean indicating whether
the album is likely to be a "various artists" release. ``extra_tags``
is an optional dictionary of additional tags used to further
constrain the search.
the album is likely to be a "various artists" release.
"""
# Base candidates if we have album and artist to match.
if artist and album:
try:
yield from mb.match_album(artist, album, len(items),
extra_tags)
for candidate in mb.match_album(artist, album, len(items)):
yield candidate
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Also add VA matches from MusicBrainz where appropriate.
if va_likely and album:
try:
yield from mb.match_album(None, album, len(items),
extra_tags)
for candidate in mb.match_album(None, album, len(items)):
yield candidate
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Candidates from plugins.
yield from plugins.candidates(items, artist, album, va_likely,
extra_tags)
for candidate in plugins.candidates(items, artist, album, va_likely):
yield candidate
@plugins.notify_info_yielded('trackinfo_received')
@plugins.notify_info_yielded(u'trackinfo_received')
def item_candidates(item, artist, title):
"""Search for item matches. ``item`` is the Item to be matched.
``artist`` and ``title`` are strings and either reflect the item or
@@ -640,9 +610,11 @@ def item_candidates(item, artist, title):
# MusicBrainz candidates.
if artist and title:
try:
yield from mb.match_track(artist, title)
for candidate in mb.match_track(artist, title):
yield candidate
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Plugin candidates.
yield from plugins.item_candidates(item, artist, title)
for candidate in plugins.item_candidates(item, artist, title):
yield candidate
Regular → Executable
+25 -30
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -16,6 +17,7 @@
releases and tracks.
"""
from __future__ import division, absolute_import, print_function
import datetime
import re
@@ -33,7 +35,7 @@ from beets.util.enumeration import OrderedEnum
# album level to determine whether a given release is likely a VA
# release and also on the track level to to remove the penalty for
# differing artists.
VA_ARTISTS = ('', 'various artists', 'various', 'va', 'unknown')
VA_ARTISTS = (u'', u'various artists', u'various', u'va', u'unknown')
# Global logger.
log = logging.getLogger('beets')
@@ -106,7 +108,7 @@ def assign_items(items, tracks):
log.debug('...done.')
# Produce the output matching.
mapping = {items[i]: tracks[j] for (i, j) in matching}
mapping = dict((items[i], tracks[j]) for (i, j) in matching)
extra_items = list(set(items) - set(mapping.keys()))
extra_items.sort(key=lambda i: (i.disc, i.track, i.title))
extra_tracks = list(set(tracks) - set(mapping.values()))
@@ -274,16 +276,16 @@ def match_by_id(items):
try:
first = next(albumids)
except StopIteration:
log.debug('No album ID found.')
log.debug(u'No album ID found.')
return None
# Is there a consensus on the MB album ID?
for other in albumids:
if other != first:
log.debug('No album ID consensus.')
log.debug(u'No album ID consensus.')
return None
# If all album IDs are equal, look up the album.
log.debug('Searching for discovered album ID: {0}', first)
log.debug(u'Searching for discovered album ID: {0}', first)
return hooks.album_for_mbid(first)
@@ -349,23 +351,23 @@ def _add_candidate(items, results, info):
checking the track count, ordering the items, checking for
duplicates, and calculating the distance.
"""
log.debug('Candidate: {0} - {1} ({2})',
log.debug(u'Candidate: {0} - {1} ({2})',
info.artist, info.album, info.album_id)
# Discard albums with zero tracks.
if not info.tracks:
log.debug('No tracks.')
log.debug(u'No tracks.')
return
# Don't duplicate.
if info.album_id in results:
log.debug('Duplicate.')
log.debug(u'Duplicate.')
return
# Discard matches without required tags.
for req_tag in config['match']['required'].as_str_seq():
if getattr(info, req_tag) is None:
log.debug('Ignored. Missing required tag: {0}', req_tag)
log.debug(u'Ignored. Missing required tag: {0}', req_tag)
return
# Find mapping between the items and the track info.
@@ -378,10 +380,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('Ignored. Penalty: {0}', penalty)
log.debug(u'Ignored. Penalty: {0}', penalty)
return
log.debug('Success. Distance: {0}', dist)
log.debug(u'Success. Distance: {0}', dist)
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
extra_items, extra_tracks)
@@ -409,7 +411,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('Tagging {0} - {1}', 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).
@@ -418,7 +420,7 @@ def tag_album(items, search_artist=None, search_album=None,
# Search by explicit ID.
if search_ids:
for search_id in search_ids:
log.debug('Searching for album ID: {0}', search_id)
log.debug(u'Searching for album ID: {0}', search_id)
for id_candidate in hooks.albums_for_id(search_id):
_add_candidate(items, candidates, id_candidate)
@@ -429,13 +431,13 @@ def tag_album(items, search_artist=None, search_album=None,
if id_info:
_add_candidate(items, candidates, id_info)
rec = _recommendation(list(candidates.values()))
log.debug('Album ID match recommendation is {0}', rec)
log.debug(u'Album ID match recommendation is {0}', 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
# matches.
if rec == Recommendation.strong:
log.debug('ID match.')
log.debug(u'ID match.')
return cur_artist, cur_album, \
Proposal(list(candidates.values()), rec)
@@ -443,29 +445,22 @@ 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('Search terms: {0} - {1}', search_artist, search_album)
extra_tags = None
if config['musicbrainz']['extra_tags']:
tag_list = config['musicbrainz']['extra_tags'].get()
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
log.debug('Additional search terms: {0}', extra_tags)
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('Album might be VA: {0}', va_likely)
log.debug(u'Album might be VA: {0}', va_likely)
# Get the results from the data sources.
for matched_candidate in hooks.album_candidates(items,
search_artist,
search_album,
va_likely,
extra_tags):
va_likely):
_add_candidate(items, candidates, matched_candidate)
log.debug('Evaluating {0} candidates.', len(candidates))
log.debug(u'Evaluating {0} candidates.', len(candidates))
# Sort and get the recommendation.
candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates)
@@ -490,7 +485,7 @@ def tag_item(item, search_artist=None, search_title=None,
trackids = search_ids or [t for t in [item.mb_trackid] if t]
if trackids:
for trackid in trackids:
log.debug('Searching for track ID: {0}', 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] = \
@@ -499,7 +494,7 @@ def tag_item(item, search_artist=None, search_title=None,
rec = _recommendation(_sort_candidates(candidates.values()))
if rec == Recommendation.strong and \
not config['import']['timid']:
log.debug('Track ID match.')
log.debug(u'Track ID match.')
return Proposal(_sort_candidates(candidates.values()), rec)
# If we're searching by ID, don't proceed.
@@ -512,7 +507,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('Item search terms: {0} - {1}', 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):
@@ -520,7 +515,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('Found {0} candidates.', len(candidates))
log.debug(u'Found {0} candidates.', len(candidates))
candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates)
return Proposal(candidates, rec)
Regular → Executable
+50 -183
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,72 +15,55 @@
"""Searches for albums in the MusicBrainz database.
"""
from __future__ import division, absolute_import, print_function
import musicbrainzngs
import re
import traceback
from six.moves.urllib.parse import urljoin
from beets import logging
from beets import plugins
import beets.autotag.hooks
import beets
from beets import util
from beets import config
from collections import Counter
from urllib.parse import urljoin
import six
VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377'
BASE_URL = 'https://musicbrainz.org/'
SKIPPED_TRACKS = ['[data track]']
FIELDS_TO_MB_KEYS = {
'catalognum': 'catno',
'country': 'country',
'label': 'label',
'media': 'format',
'year': 'date',
}
if util.SNI_SUPPORTED:
BASE_URL = 'https://musicbrainz.org/'
else:
BASE_URL = 'http://musicbrainz.org/'
musicbrainzngs.set_useragent('beets', beets.__version__,
'https://beets.io/')
'http://beets.io/')
class MusicBrainzAPIError(util.HumanReadableException):
"""An error while talking to MusicBrainz. The `query` field is the
parameter to the action and may have any type.
"""
def __init__(self, reason, verb, query, tb=None):
self.query = query
if isinstance(reason, musicbrainzngs.WebServiceError):
reason = 'MusicBrainz not reachable'
super().__init__(reason, verb, tb)
reason = u'MusicBrainz not reachable'
super(MusicBrainzAPIError, self).__init__(reason, verb, tb)
def get_message(self):
return '{} in {} with query {}'.format(
return u'{0} in {1} with query {2}'.format(
self._reasonstr(), self.verb, repr(self.query)
)
log = logging.getLogger('beets')
RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
'labels', 'artist-credits', 'aliases',
'recording-level-rels', 'work-rels',
'work-level-rels', 'artist-rels', 'isrcs']
BROWSE_INCLUDES = ['artist-credits', 'work-rels',
'artist-rels', 'recording-rels', 'release-rels']
if "work-level-rels" in musicbrainzngs.VALID_BROWSE_INCLUDES['recording']:
BROWSE_INCLUDES.append("work-level-rels")
BROWSE_CHUNKSIZE = 100
BROWSE_MAXTRACKS = 500
TRACK_INCLUDES = ['artists', 'aliases', 'isrcs']
'work-level-rels', 'artist-rels']
TRACK_INCLUDES = ['artists', 'aliases']
if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']:
TRACK_INCLUDES += ['work-level-rels', 'artist-rels']
if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']:
RELEASE_INCLUDES += ['genres']
def track_url(trackid):
@@ -95,11 +79,7 @@ def configure():
from the beets configuration. This should be called at startup.
"""
hostname = config['musicbrainz']['host'].as_str()
https = config['musicbrainz']['https'].get(bool)
# Only call set_hostname when a custom server is configured. Since
# musicbrainz-ngs connects to musicbrainz.org with HTTPS by default
if hostname != "musicbrainz.org":
musicbrainzngs.set_hostname(hostname, https)
musicbrainzngs.set_hostname(hostname)
musicbrainzngs.set_rate_limit(
config['musicbrainz']['ratelimit_interval'].as_number(),
config['musicbrainz']['ratelimit'].get(int),
@@ -129,24 +109,6 @@ def _preferred_alias(aliases):
return matches[0]
def _preferred_release_event(release):
"""Given a release, select and return the user's preferred release
event as a tuple of (country, release_date). Fall back to the
default release event if a preferred event is not found.
"""
countries = config['match']['preferred']['countries'].as_str_seq()
for country in countries:
for event in release.get('release-event-list', {}):
try:
if country in event['area']['iso-3166-1-code-list']:
return country, event['date']
except KeyError:
pass
return release.get('country'), release.get('date')
def _flatten_artist_credit(credit):
"""Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and
@@ -156,7 +118,7 @@ def _flatten_artist_credit(credit):
artist_sort_parts = []
artist_credit_parts = []
for el in credit:
if isinstance(el, str):
if isinstance(el, six.string_types):
# Join phrase.
artist_parts.append(el)
artist_credit_parts.append(el)
@@ -203,13 +165,13 @@ def track_info(recording, index=None, medium=None, medium_index=None,
the number of tracks on the medium. Each number is a 1-based index.
"""
info = beets.autotag.hooks.TrackInfo(
title=recording['title'],
track_id=recording['id'],
recording['title'],
recording['id'],
index=index,
medium=medium,
medium_index=medium_index,
medium_total=medium_total,
data_source='MusicBrainz',
data_source=u'MusicBrainz',
data_url=track_url(recording['id']),
)
@@ -225,22 +187,11 @@ def track_info(recording, index=None, medium=None, medium_index=None,
if recording.get('length'):
info.length = int(recording['length']) / (1000.0)
info.trackdisambig = recording.get('disambiguation')
if recording.get('isrc-list'):
info.isrc = ';'.join(recording['isrc-list'])
lyricist = []
composer = []
composer_sort = []
for work_relation in recording.get('work-relation-list', ()):
if work_relation['type'] != 'performance':
continue
info.work = work_relation['work']['title']
info.mb_workid = work_relation['work']['id']
if 'disambiguation' in work_relation['work']:
info.work_disambig = work_relation['work']['disambiguation']
for artist_relation in work_relation['work'].get(
'artist-relation-list', ()):
if 'type' in artist_relation:
@@ -249,13 +200,10 @@ def track_info(recording, index=None, medium=None, medium_index=None,
lyricist.append(artist_relation['artist']['name'])
elif type == 'composer':
composer.append(artist_relation['artist']['name'])
composer_sort.append(
artist_relation['artist']['sort-name'])
if lyricist:
info.lyricist = ', '.join(lyricist)
info.lyricist = u', '.join(lyricist)
if composer:
info.composer = ', '.join(composer)
info.composer_sort = ', '.join(composer_sort)
info.composer = u', '.join(composer)
arranger = []
for artist_relation in recording.get('artist-relation-list', ()):
@@ -264,12 +212,7 @@ def track_info(recording, index=None, medium=None, medium_index=None,
if type == 'arranger':
arranger.append(artist_relation['artist']['name'])
if arranger:
info.arranger = ', '.join(arranger)
# Supplementary fields provided by plugins
extra_trackdatas = plugins.send('mb_track_extract', data=recording)
for extra_trackdata in extra_trackdatas:
info.update(extra_trackdata)
info.arranger = u', '.join(arranger)
info.decode()
return info
@@ -303,26 +246,6 @@ def album_info(release):
artist_name, artist_sort_name, artist_credit_name = \
_flatten_artist_credit(release['artist-credit'])
ntracks = sum(len(m['track-list']) for m in release['medium-list'])
# The MusicBrainz API omits 'artist-relation-list' and 'work-relation-list'
# when the release has more than 500 tracks. So we use browse_recordings
# on chunks of tracks to recover the same information in this case.
if ntracks > BROWSE_MAXTRACKS:
log.debug('Album {} has too many tracks', release['id'])
recording_list = []
for i in range(0, ntracks, BROWSE_CHUNKSIZE):
log.debug('Retrieving tracks starting at {}', i)
recording_list.extend(musicbrainzngs.browse_recordings(
release=release['id'], limit=BROWSE_CHUNKSIZE,
includes=BROWSE_INCLUDES,
offset=i)['recording-list'])
track_map = {r['id']: r for r in recording_list}
for medium in release['medium-list']:
for recording in medium['track-list']:
recording_info = track_map[recording['recording']['id']]
recording['recording'] = recording_info
# Basic info.
track_infos = []
index = 0
@@ -330,29 +253,11 @@ def album_info(release):
disctitle = medium.get('title')
format = medium.get('format')
if format in config['match']['ignored_media'].as_str_seq():
continue
all_tracks = medium['track-list']
if ('data-track-list' in medium
and not config['match']['ignore_data_tracks']):
all_tracks += medium['data-track-list']
track_count = len(all_tracks)
if 'pregap' in medium:
all_tracks.insert(0, medium['pregap'])
for track in all_tracks:
if ('title' in track['recording'] and
track['recording']['title'] in SKIPPED_TRACKS):
continue
if ('video' in track['recording'] and
track['recording']['video'] == 'true' and
config['match']['ignore_video_tracks']):
continue
# Basic information from the recording.
index += 1
ti = track_info(
@@ -360,9 +265,8 @@ def album_info(release):
index,
int(medium['position']),
int(track['position']),
track_count,
len(medium['track-list']),
)
ti.release_track_id = track['id']
ti.disctitle = disctitle
ti.media = format
ti.track_alt = track['number']
@@ -381,15 +285,15 @@ def album_info(release):
track_infos.append(ti)
info = beets.autotag.hooks.AlbumInfo(
album=release['title'],
album_id=release['id'],
artist=artist_name,
artist_id=release['artist-credit'][0]['artist']['id'],
tracks=track_infos,
release['title'],
release['id'],
artist_name,
release['artist-credit'][0]['artist']['id'],
track_infos,
mediums=len(release['medium-list']),
artist_sort=artist_sort_name,
artist_credit=artist_credit_name,
data_source='MusicBrainz',
data_source=u'MusicBrainz',
data_url=album_url(release['id']),
)
info.va = info.artist_id == VARIOUS_ARTISTS_ID
@@ -397,36 +301,25 @@ def album_info(release):
info.artist = config['va_name'].as_str()
info.asin = release.get('asin')
info.releasegroup_id = release['release-group']['id']
info.country = release.get('country')
info.albumstatus = release.get('status')
# Get the disambiguation strings at the release and release group level.
# Build up the disambiguation string from the release group and release.
disambig = []
if release['release-group'].get('disambiguation'):
info.releasegroupdisambig = \
release['release-group'].get('disambiguation')
disambig.append(release['release-group'].get('disambiguation'))
if release.get('disambiguation'):
info.albumdisambig = release.get('disambiguation')
disambig.append(release.get('disambiguation'))
info.albumdisambig = u', '.join(disambig)
# Get the "classic" Release type. This data comes from a legacy API
# feature before MusicBrainz supported multiple release types.
# Release type not always populated.
if 'type' in release['release-group']:
reltype = release['release-group']['type']
if reltype:
info.albumtype = reltype.lower()
# Set the new-style "primary" and "secondary" release types.
albumtypes = []
if 'primary-type' in release['release-group']:
rel_primarytype = release['release-group']['primary-type']
if rel_primarytype:
albumtypes.append(rel_primarytype.lower())
if 'secondary-type-list' in release['release-group']:
if release['release-group']['secondary-type-list']:
for sec_type in release['release-group']['secondary-type-list']:
albumtypes.append(sec_type.lower())
info.albumtypes = '; '.join(albumtypes)
# Release events.
info.country, release_date = _preferred_release_event(release)
# Release dates.
release_date = release.get('date')
release_group_date = release['release-group'].get('first-release-date')
if not release_date:
# Fall back if release-specific date is not available.
@@ -454,33 +347,17 @@ def album_info(release):
first_medium = release['medium-list'][0]
info.media = first_medium.get('format')
if config['musicbrainz']['genres']:
sources = [
release['release-group'].get('genre-list', []),
release.get('genre-list', []),
]
genres = Counter()
for source in sources:
for genreitem in source:
genres[genreitem['name']] += int(genreitem['count'])
info.genre = '; '.join(g[0] for g in sorted(genres.items(),
key=lambda g: -g[1]))
extra_albumdatas = plugins.send('mb_album_extract', data=release)
for extra_albumdata in extra_albumdatas:
info.update(extra_albumdata)
info.decode()
return info
def match_album(artist, album, tracks=None, extra_tags=None):
def match_album(artist, album, tracks=None):
"""Searches for a single album ("release" in MusicBrainz parlance)
and returns an iterator over AlbumInfo objects. May raise a
MusicBrainzAPIError.
The query consists of an artist name, an album name, and,
optionally, a number of tracks on the album and any other extra tags.
optionally, a number of tracks on the album.
"""
# Build search criteria.
criteria = {'release': album.lower().strip()}
@@ -490,24 +367,14 @@ def match_album(artist, album, tracks=None, extra_tags=None):
# Various Artists search.
criteria['arid'] = VARIOUS_ARTISTS_ID
if tracks is not None:
criteria['tracks'] = str(tracks)
# Additional search cues from existing metadata.
if extra_tags:
for tag in extra_tags:
key = FIELDS_TO_MB_KEYS[tag]
value = str(extra_tags.get(tag, '')).lower().strip()
if key == 'catno':
value = value.replace(' ', '')
if value:
criteria[key] = value
criteria['tracks'] = six.text_type(tracks)
# Abort if we have no search terms.
if not any(criteria.values()):
return
try:
log.debug('Searching for MusicBrainz releases with: {!r}', criteria)
log.debug(u'Searching for MusicBrainz releases with: {!r}', criteria)
res = musicbrainzngs.search_releases(
limit=config['musicbrainz']['searchlimit'].get(int), **criteria)
except musicbrainzngs.MusicBrainzError as exc:
@@ -548,7 +415,7 @@ def _parse_id(s):
no ID can be found, return None.
"""
# Find the first thing that looks like a UUID/MBID.
match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', s)
match = re.search(u'[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', s)
if match:
return match.group()
@@ -558,19 +425,19 @@ def album_for_id(releaseid):
object or None if the album is not found. May raise a
MusicBrainzAPIError.
"""
log.debug('Requesting MusicBrainz release {}', releaseid)
log.debug(u'Requesting MusicBrainz release {}', releaseid)
albumid = _parse_id(releaseid)
if not albumid:
log.debug('Invalid MBID ({0}).', releaseid)
log.debug(u'Invalid MBID ({0}).', releaseid)
return
try:
res = musicbrainzngs.get_release_by_id(albumid,
RELEASE_INCLUDES)
except musicbrainzngs.ResponseError:
log.debug('Album ID match failed.')
log.debug(u'Album ID match failed.')
return None
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, 'get release by ID', albumid,
raise MusicBrainzAPIError(exc, u'get release by ID', albumid,
traceback.format_exc())
return album_info(res['release'])
@@ -581,14 +448,14 @@ def track_for_id(releaseid):
"""
trackid = _parse_id(releaseid)
if not trackid:
log.debug('Invalid MBID ({0}).', releaseid)
log.debug(u'Invalid MBID ({0}).', releaseid)
return
try:
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
except musicbrainzngs.ResponseError:
log.debug('Track ID match failed.')
log.debug(u'Track ID match failed.')
return None
except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, 'get recording by ID', trackid,
raise MusicBrainzAPIError(exc, u'get recording by ID', trackid,
traceback.format_exc())
return track_info(res['recording'])
Regular → Executable
-23
View File
@@ -7,12 +7,9 @@ import:
move: no
link: no
hardlink: no
reflink: no
delete: no
resume: ask
incremental: no
incremental_skip_later: no
from_scratch: no
quiet_fallback: skip
none_rec_action: ask
timid: no
@@ -28,8 +25,6 @@ import:
pretend: false
search_ids: []
duplicate_action: ask
bell: no
set_fields: {}
clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information", "lost+found"]
@@ -43,22 +38,11 @@ replace:
'\.$': _
'\s+$': ''
'^\s+': ''
'^-': _
path_sep_replace: _
drive_sep_replace: _
asciify_paths: false
art_filename: cover
max_filename_length: 0
aunique:
keys: albumartist album
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
bracket: '[]'
overwrite_null:
album: []
track: []
plugins: []
pluginpath: []
threaded: yes
@@ -67,7 +51,6 @@ per_disc_numbering: no
verbose: 0
terminal_encoding:
original_date: no
artist_credit: no
id3v23: no
va_name: "Various Artists"
@@ -102,12 +85,9 @@ statefile: state.pickle
musicbrainz:
host: musicbrainz.org
https: no
ratelimit: 1
ratelimit_interval: 1.0
searchlimit: 5
extra_tags: []
genres: no
match:
strong_rec_thresh: 0.04
@@ -142,8 +122,5 @@ match:
original_year: no
ignored: []
required: []
ignored_media: []
ignore_data_tracks: yes
ignore_video_tracks: yes
track_length_grace: 10
track_length_max: 30
Regular → Executable
+2
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -15,6 +16,7 @@
"""DBCore is an abstract database package that forms the basis for beets'
Library.
"""
from __future__ import division, absolute_import, print_function
from .db import Model, Database
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
+92 -331
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,56 +15,38 @@
"""The central Model and Database constructs for DBCore.
"""
from __future__ import division, absolute_import, print_function
import time
import os
import re
from collections import defaultdict
import threading
import sqlite3
import contextlib
import collections
import beets
from beets.util import functemplate
from beets.util.functemplate import Template
from beets.util import py3_path
from beets.dbcore import types
from .query import MatchQuery, NullSort, TrueQuery
from collections.abc import Mapping
import six
class DBAccessError(Exception):
"""The SQLite database became inaccessible.
This can happen when trying to read or write the database when, for
example, the database file is deleted or otherwise disappears. There
is probably no way to recover from this error.
"""
class FormattedMapping(Mapping):
class FormattedMapping(collections.Mapping):
"""A `dict`-like formatted view of a model.
The accessor `mapping[key]` returns the formatted version of
`model[key]` as a unicode string.
The `included_keys` parameter allows filtering the fields that are
returned. By default all fields are returned. Limiting to specific keys can
avoid expensive per-item database queries.
If `for_path` is true, all path separators in the formatted values
are replaced.
"""
ALL_KEYS = '*'
def __init__(self, model, included_keys=ALL_KEYS, for_path=False):
def __init__(self, model, for_path=False):
self.for_path = for_path
self.model = model
if included_keys == self.ALL_KEYS:
# Performance note: this triggers a database query.
self.model_keys = self.model.keys(True)
else:
self.model_keys = included_keys
self.model_keys = model.keys(True)
def __getitem__(self, key):
if key in self.model_keys:
@@ -80,7 +63,7 @@ class FormattedMapping(Mapping):
def get(self, key, default=None):
if default is None:
default = self.model._type(key).format(None)
return super().get(key, default)
return super(FormattedMapping, self).get(key, default)
def _get_formatted(self, model, key):
value = model._type(key).format(model.get(key))
@@ -89,11 +72,6 @@ class FormattedMapping(Mapping):
if self.for_path:
sep_repl = beets.config['path_sep_replace'].as_str()
sep_drive = beets.config['drive_sep_replace'].as_str()
if re.match(r'^\w:', value):
value = re.sub(r'(?<=^\w):', sep_drive, value)
for sep in (os.path.sep, os.path.altsep):
if sep:
value = value.replace(sep, sep_repl)
@@ -101,105 +79,11 @@ class FormattedMapping(Mapping):
return value
class LazyConvertDict:
"""Lazily convert types for attributes fetched from the database
"""
def __init__(self, model_cls):
"""Initialize the object empty
"""
self.data = {}
self.model_cls = model_cls
self._converted = {}
def init(self, data):
"""Set the base data that should be lazily converted
"""
self.data = data
def _convert(self, key, value):
"""Convert the attribute type according the the SQL type
"""
return self.model_cls._type(key).from_sql(value)
def __setitem__(self, key, value):
"""Set an attribute value, assume it's already converted
"""
self._converted[key] = value
def __getitem__(self, key):
"""Get an attribute value, converting the type on demand
if needed
"""
if key in self._converted:
return self._converted[key]
elif key in self.data:
value = self._convert(key, self.data[key])
self._converted[key] = value
return value
def __delitem__(self, key):
"""Delete both converted and base data
"""
if key in self._converted:
del self._converted[key]
if key in self.data:
del self.data[key]
def keys(self):
"""Get a list of available field names for this object.
"""
return list(self._converted.keys()) + list(self.data.keys())
def copy(self):
"""Create a copy of the object.
"""
new = self.__class__(self.model_cls)
new.data = self.data.copy()
new._converted = self._converted.copy()
return new
# Act like a dictionary.
def update(self, values):
"""Assign all values in the given dict.
"""
for key, value in values.items():
self[key] = value
def items(self):
"""Iterate over (key, value) pairs that this object contains.
Computed fields are not included.
"""
for key in self:
yield key, self[key]
def get(self, key, default=None):
"""Get the value for a given key or `default` if it does not
exist.
"""
if key in self:
return self[key]
else:
return default
def __contains__(self, key):
"""Determine whether `key` is an attribute on this object.
"""
return key in self.keys()
def __iter__(self):
"""Iterate over the available field names (excluding computed
fields).
"""
return iter(self.keys())
# Abstract base for model classes.
class Model:
class Model(object):
"""An abstract object representing an object in the database. Model
objects act like dictionaries (i.e., they allow subscript access like
objects act like dictionaries (i.e., the allow subscript access like
``obj['field']``). The same field set is available via attribute
access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are
available:
@@ -250,22 +134,12 @@ class Model:
are subclasses of `Sort`.
"""
_queries = {}
"""Named queries that use a field-like `name:value` syntax but which
do not relate to any specific field.
"""
_always_dirty = False
"""By default, fields only become "dirty" when their value actually
changes. Enabling this flag marks fields as dirty even when the new
value is the same as the old value (e.g., `o.f = o.f`).
"""
_revision = -1
"""A revision number from when the model was loaded from or written
to the database.
"""
@classmethod
def _getters(cls):
"""Return a mapping from field names to getter functions.
@@ -289,8 +163,8 @@ class Model:
"""
self._db = db
self._dirty = set()
self._values_fixed = LazyConvertDict(self)
self._values_flex = LazyConvertDict(self)
self._values_fixed = {}
self._values_flex = {}
# Initial contents.
self.update(values)
@@ -304,25 +178,23 @@ class Model:
ordinary construction are bypassed.
"""
obj = cls(db)
obj._values_fixed.init(fixed_values)
obj._values_flex.init(flex_values)
for key, value in fixed_values.items():
obj._values_fixed[key] = cls._type(key).from_sql(value)
for key, value in flex_values.items():
obj._values_flex[key] = cls._type(key).from_sql(value)
return obj
def __repr__(self):
return '{}({})'.format(
return '{0}({1})'.format(
type(self).__name__,
', '.join(f'{k}={v!r}' for k, v in dict(self).items()),
', '.join('{0}={1!r}'.format(k, v) for k, v in dict(self).items()),
)
def clear_dirty(self):
"""Mark all fields as *clean* (i.e., not needing to be stored to
the database). Also update the revision.
the database).
"""
self._dirty = set()
if self._db:
self._revision = self._db.revision
def _check_db(self, need_id=True):
"""Ensure that this object is associated with a database row: it
@@ -331,25 +203,10 @@ class Model:
"""
if not self._db:
raise ValueError(
'{} has no database'.format(type(self).__name__)
u'{0} has no database'.format(type(self).__name__)
)
if need_id and not self.id:
raise ValueError('{} has no id'.format(type(self).__name__))
def copy(self):
"""Create a copy of the model object.
The field values and other state is duplicated, but the new copy
remains associated with the same database as the old object.
(A simple `copy.deepcopy` will not work because it would try to
duplicate the SQLite connection.)
"""
new = self.__class__()
new._db = self._db
new._values_fixed = self._values_fixed.copy()
new._values_flex = self._values_flex.copy()
new._dirty = self._dirty.copy()
return new
raise ValueError(u'{0} has no id'.format(type(self).__name__))
# Essential field accessors.
@@ -362,36 +219,22 @@ class Model:
"""
return cls._fields.get(key) or cls._types.get(key) or types.DEFAULT
def _get(self, key, default=None, raise_=False):
"""Get the value for a field, or `default`. Alternatively,
raise a KeyError if the field is not available.
def __getitem__(self, key):
"""Get the value for a field. Raise a KeyError if the field is
not available.
"""
getters = self._getters()
if key in getters: # Computed.
return getters[key](self)
elif key in self._fields: # Fixed.
if key in self._values_fixed:
return self._values_fixed[key]
else:
return self._type(key).null
return self._values_fixed.get(key)
elif key in self._values_flex: # Flexible.
return self._values_flex[key]
elif raise_:
raise KeyError(key)
else:
return default
raise KeyError(key)
get = _get
def __getitem__(self, key):
"""Get the value for a field. Raise a KeyError if the field is
not available.
"""
return self._get(key, raise_=True)
def _setitem(self, key, value):
"""Assign the value for a field, return whether new and old value
differ.
def __setitem__(self, key, value):
"""Assign the value for a field.
"""
# Choose where to place the value.
if key in self._fields:
@@ -405,29 +248,21 @@ class Model:
# Assign value and possibly mark as dirty.
old_value = source.get(key)
source[key] = value
changed = old_value != value
if self._always_dirty or changed:
if self._always_dirty or old_value != value:
self._dirty.add(key)
return changed
def __setitem__(self, key, value):
"""Assign the value for a field.
"""
self._setitem(key, value)
def __delitem__(self, key):
"""Remove a flexible attribute from the model.
"""
if key in self._values_flex: # Flexible.
del self._values_flex[key]
self._dirty.add(key) # Mark for dropping on store.
elif key in self._fields: # Fixed
setattr(self, key, self._type(key).null)
elif key in self._getters(): # Computed.
raise KeyError(f'computed field {key} cannot be deleted')
raise KeyError(u'computed field {0} cannot be deleted'.format(key))
elif key in self._fields: # Fixed.
raise KeyError(u'fixed field {0} cannot be deleted'.format(key))
else:
raise KeyError(f'no such field {key}')
raise KeyError(u'no such field {0}'.format(key))
def keys(self, computed=False):
"""Get a list of available field names for this object. The
@@ -462,10 +297,19 @@ class Model:
for key in self:
yield key, self[key]
def get(self, key, default=None):
"""Get the value for a given key or `default` if it does not
exist.
"""
if key in self:
return self[key]
else:
return default
def __contains__(self, key):
"""Determine whether `key` is an attribute on this object.
"""
return key in self.keys(computed=True)
return key in self.keys(True)
def __iter__(self):
"""Iterate over the available field names (excluding computed
@@ -477,22 +321,22 @@ class Model:
def __getattr__(self, key):
if key.startswith('_'):
raise AttributeError(f'model has no attribute {key!r}')
raise AttributeError(u'model has no attribute {0!r}'.format(key))
else:
try:
return self[key]
except KeyError:
raise AttributeError(f'no such field {key!r}')
raise AttributeError(u'no such field {0!r}'.format(key))
def __setattr__(self, key, value):
if key.startswith('_'):
super().__setattr__(key, value)
super(Model, self).__setattr__(key, value)
else:
self[key] = value
def __delattr__(self, key):
if key.startswith('_'):
super().__delattr__(key)
super(Model, self).__delattr__(key)
else:
del self[key]
@@ -521,7 +365,7 @@ class Model:
with self._db.transaction() as tx:
# Main table update.
if assignments:
query = 'UPDATE {} SET {} WHERE id=?'.format(
query = 'UPDATE {0} SET {1} WHERE id=?'.format(
self._table, assignments
)
subvars.append(self.id)
@@ -532,7 +376,7 @@ class Model:
if key in self._dirty:
self._dirty.remove(key)
tx.mutate(
'INSERT INTO {} '
'INSERT INTO {0} '
'(entity_id, key, value) '
'VALUES (?, ?, ?);'.format(self._flex_table),
(self.id, key, value),
@@ -541,7 +385,7 @@ class Model:
# Deleted flexible attributes.
for key in self._dirty:
tx.mutate(
'DELETE FROM {} '
'DELETE FROM {0} '
'WHERE entity_id=? AND key=?'.format(self._flex_table),
(self.id, key)
)
@@ -550,18 +394,12 @@ class Model:
def load(self):
"""Refresh the object's metadata from the library database.
If check_revision is true, the database is only queried loaded when a
transaction has been committed since the item was last loaded.
"""
self._check_db()
if not self._dirty and self._db.revision == self._revision:
# Exit early
return
stored_obj = self._db._get(type(self), self.id)
assert stored_obj is not None, f"object {self.id} not in DB"
self._values_fixed = LazyConvertDict(self)
self._values_flex = LazyConvertDict(self)
assert stored_obj is not None, u"object {0} not in DB".format(self.id)
self._values_fixed = {}
self._values_flex = {}
self.update(dict(stored_obj))
self.clear_dirty()
@@ -571,11 +409,11 @@ class Model:
self._check_db()
with self._db.transaction() as tx:
tx.mutate(
f'DELETE FROM {self._table} WHERE id=?',
'DELETE FROM {0} WHERE id=?'.format(self._table),
(self.id,)
)
tx.mutate(
f'DELETE FROM {self._flex_table} WHERE entity_id=?',
'DELETE FROM {0} WHERE entity_id=?'.format(self._flex_table),
(self.id,)
)
@@ -593,7 +431,7 @@ class Model:
with self._db.transaction() as tx:
new_id = tx.mutate(
f'INSERT INTO {self._table} DEFAULT VALUES'
'INSERT INTO {0} DEFAULT VALUES'.format(self._table)
)
self.id = new_id
self.added = time.time()
@@ -608,11 +446,11 @@ class Model:
_formatter = FormattedMapping
def formatted(self, included_keys=_formatter.ALL_KEYS, for_path=False):
def formatted(self, for_path=False):
"""Get a mapping containing all values on this object formatted
as human-readable unicode strings.
"""
return self._formatter(self, included_keys, for_path)
return self._formatter(self, for_path)
def evaluate_template(self, template, for_path=False):
"""Evaluate a template (a string or a `Template` object) using
@@ -620,9 +458,9 @@ class Model:
separators will be added to the template.
"""
# Perform substitution.
if isinstance(template, str):
template = functemplate.template(template)
return template.substitute(self.formatted(for_path=for_path),
if isinstance(template, six.string_types):
template = Template(template)
return template.substitute(self.formatted(for_path),
self._template_funcs())
# Parsing.
@@ -631,8 +469,8 @@ class Model:
def _parse(cls, key, string):
"""Parse a string as a value for the given key.
"""
if not isinstance(string, str):
raise TypeError("_parse() argument must be a string")
if not isinstance(string, six.string_types):
raise TypeError(u"_parse() argument must be a string")
return cls._type(key).parse(string)
@@ -644,13 +482,11 @@ class Model:
# Database controller and supporting interfaces.
class Results:
class Results(object):
"""An item query result set. Iterating over the collection lazily
constructs LibModel objects that reflect database rows.
"""
def __init__(self, model_class, rows, db, flex_rows,
query=None, sort=None):
def __init__(self, model_class, rows, db, query=None, sort=None):
"""Create a result set that will construct objects of type
`model_class`.
@@ -670,7 +506,6 @@ class Results:
self.db = db
self.query = query
self.sort = sort
self.flex_rows = flex_rows
# We keep a queue of rows we haven't yet consumed for
# materialization. We preserve the original total number of
@@ -692,10 +527,6 @@ class Results:
a `Results` object a second time should be much faster than the
first.
"""
# Index flexible attributes by the item ID, so we have easier access
flex_attrs = self._get_indexed_flex_attrs()
index = 0 # Position in the materialized objects.
while index < len(self._objects) or self._rows:
# Are there previously-materialized objects to produce?
@@ -708,7 +539,7 @@ class Results:
else:
while self._rows:
row = self._rows.pop(0)
obj = self._make_model(row, flex_attrs.get(row['id'], {}))
obj = self._make_model(row)
# If there is a slow-query predicate, ensurer that the
# object passes it.
if not self.query or self.query.match(obj):
@@ -730,24 +561,20 @@ class Results:
# Objects are pre-sorted (i.e., by the database).
return self._get_objects()
def _get_indexed_flex_attrs(self):
""" Index flexible attributes by the entity id they belong to
"""
flex_values = {}
for row in self.flex_rows:
if row['entity_id'] not in flex_values:
flex_values[row['entity_id']] = {}
def _make_model(self, row):
# Get the flexible attributes for the object.
with self.db.transaction() as tx:
flex_rows = tx.query(
'SELECT * FROM {0} WHERE entity_id=?'.format(
self.model_class._flex_table
),
(row['id'],)
)
flex_values[row['entity_id']][row['key']] = row['value']
return flex_values
def _make_model(self, row, flex_values={}):
""" Create a Model object for the given row
"""
cols = dict(row)
values = {k: v for (k, v) in cols.items()
if not k[:4] == 'flex'}
values = dict((k, v) for (k, v) in cols.items()
if not k[:4] == 'flex')
flex_values = dict((row['key'], row['value']) for row in flex_rows)
# Construct the Python object
obj = self.model_class._awaken(self.db, values, flex_values)
@@ -796,7 +623,7 @@ class Results:
next(it)
return next(it)
except StopIteration:
raise IndexError(f'result index {n} out of range')
raise IndexError(u'result index {0} out of range'.format(n))
def get(self):
"""Return the first matching object, or None if no objects
@@ -809,16 +636,10 @@ class Results:
return None
class Transaction:
class Transaction(object):
"""A context manager for safe, concurrent access to the database.
All SQL commands should be executed through a transaction.
"""
_mutated = False
"""A flag storing whether a mutation has been executed in the
current transaction.
"""
def __init__(self, db):
self.db = db
@@ -840,15 +661,12 @@ class Transaction:
entered but not yet exited transaction. If it is the last active
transaction, the database updates are committed.
"""
# Beware of races; currently secured by db._db_lock
self.db.revision += self._mutated
with self.db._tx_stack() as stack:
assert stack.pop() is self
empty = not stack
if empty:
# Ending a "root" transaction. End the SQLite transaction.
self.db._connection().commit()
self._mutated = False
self.db._db_lock.release()
def query(self, statement, subvals=()):
@@ -862,52 +680,28 @@ class Transaction:
"""Execute an SQL statement with substitution values and return
the row ID of the last affected row.
"""
try:
cursor = self.db._connection().execute(statement, subvals)
except sqlite3.OperationalError as e:
# In two specific cases, SQLite reports an error while accessing
# the underlying database file. We surface these exceptions as
# DBAccessError so the application can abort.
if e.args[0] in ("attempt to write a readonly database",
"unable to open database file"):
raise DBAccessError(e.args[0])
else:
raise
else:
self._mutated = True
return cursor.lastrowid
cursor = self.db._connection().execute(statement, subvals)
return cursor.lastrowid
def script(self, statements):
"""Execute a string containing multiple SQL statements."""
# We don't know whether this mutates, but quite likely it does.
self._mutated = True
self.db._connection().executescript(statements)
class Database:
class Database(object):
"""A container for Model objects that wraps an SQLite database as
the backend.
"""
_models = ()
"""The Model subclasses representing tables in this database.
"""
supports_extensions = hasattr(sqlite3.Connection, 'enable_load_extension')
"""Whether or not the current version of SQLite supports extensions"""
revision = 0
"""The current revision of the database. To be increased whenever
data is written in a transaction.
"""
def __init__(self, path, timeout=5.0):
self.path = path
self.timeout = timeout
self._connections = {}
self._tx_stacks = defaultdict(list)
self._extensions = []
# A lock to protect the _connections and _tx_stacks maps, which
# both map thread IDs to private resources.
@@ -957,13 +751,6 @@ class Database:
py3_path(self.path), timeout=self.timeout
)
if self.supports_extensions:
conn.enable_load_extension(True)
# Load any extension that are already loaded for other connections.
for path in self._extensions:
conn.load_extension(path)
# Access SELECT results like dictionaries.
conn.row_factory = sqlite3.Row
return conn
@@ -992,18 +779,6 @@ class Database:
"""
return Transaction(self)
def load_extension(self, path):
"""Load an SQLite extension into all open connections."""
if not self.supports_extensions:
raise ValueError(
'this sqlite3 installation does not support extensions')
self._extensions.append(path)
# Load the extension into every open connection.
for conn in self._connections.values():
conn.load_extension(path)
# Schema setup and migration.
def _make_table(self, table, fields):
@@ -1013,7 +788,7 @@ class Database:
# Get current schema.
with self.transaction() as tx:
rows = tx.query('PRAGMA table_info(%s)' % table)
current_fields = {row[1] for row in rows}
current_fields = set([row[1] for row in rows])
field_names = set(fields.keys())
if current_fields.issuperset(field_names):
@@ -1024,9 +799,9 @@ class Database:
# No table exists.
columns = []
for name, typ in fields.items():
columns.append(f'{name} {typ.sql}')
setup_sql = 'CREATE TABLE {} ({});\n'.format(table,
', '.join(columns))
columns.append('{0} {1}'.format(name, typ.sql))
setup_sql = 'CREATE TABLE {0} ({1});\n'.format(table,
', '.join(columns))
else:
# Table exists does not match the field set.
@@ -1034,7 +809,7 @@ class Database:
for name, typ in fields.items():
if name in current_fields:
continue
setup_sql += 'ALTER TABLE {} ADD COLUMN {} {};\n'.format(
setup_sql += 'ALTER TABLE {0} ADD COLUMN {1} {2};\n'.format(
table, name, typ.sql
)
@@ -1070,31 +845,17 @@ class Database:
where, subvals = query.clause()
order_by = sort.order_clause()
sql = ("SELECT * FROM {} WHERE {} {}").format(
sql = ("SELECT * FROM {0} WHERE {1} {2}").format(
model_cls._table,
where or '1',
f"ORDER BY {order_by}" if order_by else '',
)
# Fetch flexible attributes for items matching the main query.
# Doing the per-item filtering in python is faster than issuing
# one query per item to sqlite.
flex_sql = ("""
SELECT * FROM {} WHERE entity_id IN
(SELECT id FROM {} WHERE {});
""".format(
model_cls._flex_table,
model_cls._table,
where or '1',
)
"ORDER BY {0}".format(order_by) if order_by else '',
)
with self.transaction() as tx:
rows = tx.query(sql, subvals)
flex_rows = tx.query(flex_sql, subvals)
return Results(
model_cls, rows, self, flex_rows,
model_cls, rows, self,
None if where else query, # Slow query component.
sort if sort.is_slow() else None, # Slow sort component.
)
Regular → Executable
+81 -149
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,6 +15,7 @@
"""The Query type hierarchy for DBCore.
"""
from __future__ import division, absolute_import, print_function
import re
from operator import mul
@@ -21,6 +23,10 @@ from beets import util
from datetime import datetime, timedelta
import unicodedata
from functools import reduce
import six
if not six.PY2:
buffer = memoryview # sqlite won't accept memoryview in python 2
class ParsingError(ValueError):
@@ -34,32 +40,29 @@ class InvalidQueryError(ParsingError):
The query should be a unicode string or a list, which will be space-joined.
"""
def __init__(self, query, explanation):
if isinstance(query, list):
query = " ".join(query)
message = f"'{query}': {explanation}"
super().__init__(message)
message = u"'{0}': {1}".format(query, explanation)
super(InvalidQueryError, self).__init__(message)
class InvalidQueryArgumentValueError(ParsingError):
class InvalidQueryArgumentTypeError(ParsingError):
"""Represent a query argument that could not be converted as expected.
It exists to be caught in upper stack levels so a meaningful (i.e. with the
query) InvalidQueryError can be raised.
"""
def __init__(self, what, expected, detail=None):
message = f"'{what}' is not {expected}"
message = u"'{0}' is not {1}".format(what, expected)
if detail:
message = f"{message}: {detail}"
super().__init__(message)
message = u"{0}: {1}".format(message, detail)
super(InvalidQueryArgumentTypeError, self).__init__(message)
class Query:
class Query(object):
"""An abstract class representing a query into the item database.
"""
def clause(self):
"""Generate an SQLite expression implementing the query.
@@ -76,7 +79,7 @@ class Query:
raise NotImplementedError
def __repr__(self):
return f"{self.__class__.__name__}()"
return "{0.__class__.__name__}()".format(self)
def __eq__(self, other):
return type(self) == type(other)
@@ -92,7 +95,6 @@ class FieldQuery(Query):
string. Subclasses may also provide `col_clause` to implement the
same matching functionality in SQLite.
"""
def __init__(self, field, pattern, fast=True):
self.field = field
self.pattern = pattern
@@ -123,7 +125,7 @@ class FieldQuery(Query):
"{0.fast})".format(self))
def __eq__(self, other):
return super().__eq__(other) and \
return super(FieldQuery, self).__eq__(other) and \
self.field == other.field and self.pattern == other.pattern
def __hash__(self):
@@ -132,7 +134,6 @@ class FieldQuery(Query):
class MatchQuery(FieldQuery):
"""A query that looks for exact matches in an item field."""
def col_clause(self):
return self.field + " = ?", [self.pattern]
@@ -142,16 +143,19 @@ class MatchQuery(FieldQuery):
class NoneQuery(FieldQuery):
"""A query that checks whether a field is null."""
def __init__(self, field, fast=True):
super().__init__(field, None, fast)
super(NoneQuery, self).__init__(field, None, fast)
def col_clause(self):
return self.field + " IS NULL", ()
def match(self, item):
return item.get(self.field) is None
@classmethod
def match(cls, item):
try:
return item[cls.field] is None
except KeyError:
return True
def __repr__(self):
return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self)
@@ -161,7 +165,6 @@ class StringFieldQuery(FieldQuery):
"""A FieldQuery that converts values to strings before matching
them.
"""
@classmethod
def value_match(cls, pattern, value):
"""Determine whether the value matches the pattern. The value
@@ -179,12 +182,11 @@ class StringFieldQuery(FieldQuery):
class SubstringQuery(StringFieldQuery):
"""A query that matches a substring in a specific item field."""
def col_clause(self):
pattern = (self.pattern
.replace('\\', '\\\\')
.replace('%', '\\%')
.replace('_', '\\_'))
.replace('\\', '\\\\')
.replace('%', '\\%')
.replace('_', '\\_'))
search = '%' + pattern + '%'
clause = self.field + " like ? escape '\\'"
subvals = [search]
@@ -202,17 +204,16 @@ class RegexpQuery(StringFieldQuery):
Raises InvalidQueryError when the pattern is not a valid regular
expression.
"""
def __init__(self, field, pattern, fast=True):
super().__init__(field, pattern, fast)
super(RegexpQuery, self).__init__(field, pattern, fast)
pattern = self._normalize(pattern)
try:
self.pattern = re.compile(self.pattern)
except re.error as exc:
# Invalid regular expression.
raise InvalidQueryArgumentValueError(pattern,
"a regular expression",
format(exc))
raise InvalidQueryArgumentTypeError(pattern,
u"a regular expression",
format(exc))
@staticmethod
def _normalize(s):
@@ -230,10 +231,9 @@ class BooleanQuery(MatchQuery):
"""Matches a boolean field. Pattern should either be a boolean or a
string reflecting a boolean.
"""
def __init__(self, field, pattern, fast=True):
super().__init__(field, pattern, fast)
if isinstance(pattern, str):
super(BooleanQuery, self).__init__(field, pattern, fast)
if isinstance(pattern, six.string_types):
self.pattern = util.str2bool(pattern)
self.pattern = int(self.pattern)
@@ -244,18 +244,17 @@ class BytesQuery(MatchQuery):
`unicode` equivalently in Python 2. Always use this query instead of
`MatchQuery` when matching on BLOB values.
"""
def __init__(self, field, pattern):
super().__init__(field, pattern)
super(BytesQuery, self).__init__(field, pattern)
# Use a buffer/memoryview representation of the pattern for SQLite
# matching. This instructs SQLite to treat the blob as binary
# rather than encoded Unicode.
if isinstance(self.pattern, (str, bytes)):
if isinstance(self.pattern, str):
if isinstance(self.pattern, (six.text_type, bytes)):
if isinstance(self.pattern, six.text_type):
self.pattern = self.pattern.encode('utf-8')
self.buf_pattern = memoryview(self.pattern)
elif isinstance(self.pattern, memoryview):
self.buf_pattern = buffer(self.pattern)
elif isinstance(self.pattern, buffer):
self.buf_pattern = self.pattern
self.pattern = bytes(self.pattern)
@@ -271,7 +270,6 @@ class NumericQuery(FieldQuery):
Raises InvalidQueryError when the pattern does not represent an int or
a float.
"""
def _convert(self, s):
"""Convert a string to a numeric type (float or int).
@@ -287,10 +285,10 @@ class NumericQuery(FieldQuery):
try:
return float(s)
except ValueError:
raise InvalidQueryArgumentValueError(s, "an int or a float")
raise InvalidQueryArgumentTypeError(s, u"an int or a float")
def __init__(self, field, pattern, fast=True):
super().__init__(field, pattern, fast)
super(NumericQuery, self).__init__(field, pattern, fast)
parts = pattern.split('..', 1)
if len(parts) == 1:
@@ -308,7 +306,7 @@ class NumericQuery(FieldQuery):
if self.field not in item:
return False
value = item[self.field]
if isinstance(value, str):
if isinstance(value, six.string_types):
value = self._convert(value)
if self.point is not None:
@@ -325,21 +323,20 @@ class NumericQuery(FieldQuery):
return self.field + '=?', (self.point,)
else:
if self.rangemin is not None and self.rangemax is not None:
return ('{0} >= ? AND {0} <= ?'.format(self.field),
return (u'{0} >= ? AND {0} <= ?'.format(self.field),
(self.rangemin, self.rangemax))
elif self.rangemin is not None:
return f'{self.field} >= ?', (self.rangemin,)
return u'{0} >= ?'.format(self.field), (self.rangemin,)
elif self.rangemax is not None:
return f'{self.field} <= ?', (self.rangemax,)
return u'{0} <= ?'.format(self.field), (self.rangemax,)
else:
return '1', ()
return u'1', ()
class CollectionQuery(Query):
"""An abstract query class that aggregates other queries. Can be
indexed like a list to access the sub-queries.
"""
def __init__(self, subqueries=()):
self.subqueries = subqueries
@@ -377,7 +374,7 @@ class CollectionQuery(Query):
return "{0.__class__.__name__}({0.subqueries!r})".format(self)
def __eq__(self, other):
return super().__eq__(other) and \
return super(CollectionQuery, self).__eq__(other) and \
self.subqueries == other.subqueries
def __hash__(self):
@@ -392,7 +389,6 @@ class AnyFieldQuery(CollectionQuery):
any field. The individual field query class is provided to the
constructor.
"""
def __init__(self, pattern, fields, cls):
self.pattern = pattern
self.fields = fields
@@ -401,7 +397,7 @@ class AnyFieldQuery(CollectionQuery):
subqueries = []
for field in self.fields:
subqueries.append(cls(field, pattern, True))
super().__init__(subqueries)
super(AnyFieldQuery, self).__init__(subqueries)
def clause(self):
return self.clause_with_joiner('or')
@@ -417,7 +413,7 @@ class AnyFieldQuery(CollectionQuery):
"{0.query_class.__name__})".format(self))
def __eq__(self, other):
return super().__eq__(other) and \
return super(AnyFieldQuery, self).__eq__(other) and \
self.query_class == other.query_class
def __hash__(self):
@@ -428,7 +424,6 @@ class MutableCollectionQuery(CollectionQuery):
"""A collection query whose subqueries may be modified after the
query is initialized.
"""
def __setitem__(self, key, value):
self.subqueries[key] = value
@@ -438,36 +433,33 @@ class MutableCollectionQuery(CollectionQuery):
class AndQuery(MutableCollectionQuery):
"""A conjunction of a list of other queries."""
def clause(self):
return self.clause_with_joiner('and')
def match(self, item):
return all(q.match(item) for q in self.subqueries)
return all([q.match(item) for q in self.subqueries])
class OrQuery(MutableCollectionQuery):
"""A conjunction of a list of other queries."""
def clause(self):
return self.clause_with_joiner('or')
def match(self, item):
return any(q.match(item) for q in self.subqueries)
return any([q.match(item) for q in self.subqueries])
class NotQuery(Query):
"""A query that matches the negation of its `subquery`, as a shorcut for
performing `not(subquery)` without using regular expressions.
"""
def __init__(self, subquery):
self.subquery = subquery
def clause(self):
clause, subvals = self.subquery.clause()
if clause:
return f'not ({clause})', subvals
return 'not ({0})'.format(clause), subvals
else:
# If there is no clause, there is nothing to negate. All the logic
# is handled by match() for slow queries.
@@ -480,7 +472,7 @@ class NotQuery(Query):
return "{0.__class__.__name__}({0.subquery!r})".format(self)
def __eq__(self, other):
return super().__eq__(other) and \
return super(NotQuery, self).__eq__(other) and \
self.subquery == other.subquery
def __hash__(self):
@@ -489,7 +481,6 @@ class NotQuery(Query):
class TrueQuery(Query):
"""A query that always matches."""
def clause(self):
return '1', ()
@@ -499,7 +490,6 @@ class TrueQuery(Query):
class FalseQuery(Query):
"""A query that never matches."""
def clause(self):
return '0', ()
@@ -536,88 +526,42 @@ def _parse_periods(pattern):
return (start, end)
class Period:
class Period(object):
"""A period of time given by a date, time and precision.
Example: 2014-01-01 10:50:30 with precision 'month' represents all
instants of time during January 2014.
"""
precisions = ('year', 'month', 'day', 'hour', 'minute', 'second')
date_formats = (
('%Y',), # year
('%Y-%m',), # month
('%Y-%m-%d',), # day
('%Y-%m-%dT%H', '%Y-%m-%d %H'), # hour
('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute
('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second
)
relative_units = {'y': 365, 'm': 30, 'w': 7, 'd': 1}
relative_re = '(?P<sign>[+|-]?)(?P<quantity>[0-9]+)' + \
'(?P<timespan>[y|m|w|d])'
precisions = ('year', 'month', 'day')
date_formats = ('%Y', '%Y-%m', '%Y-%m-%d')
def __init__(self, date, precision):
"""Create a period with the given date (a `datetime` object) and
precision (a string, one of "year", "month", "day", "hour", "minute",
or "second").
precision (a string, one of "year", "month", or "day").
"""
if precision not in Period.precisions:
raise ValueError(f'Invalid precision {precision}')
raise ValueError(u'Invalid precision {0}'.format(precision))
self.date = date
self.precision = precision
@classmethod
def parse(cls, string):
"""Parse a date and return a `Period` object or `None` if the
string is empty, or raise an InvalidQueryArgumentValueError if
the string cannot be parsed to a date.
The date may be absolute or relative. Absolute dates look like
`YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative
dates have three parts:
- Optionally, a ``+`` or ``-`` sign indicating the future or the
past. The default is the future.
- A number: how much to add or subtract.
- A letter indicating the unit: days, weeks, months or years
(``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days
and a "year" is exactly 365 days.
string is empty.
"""
def find_date_and_format(string):
for ord, format in enumerate(cls.date_formats):
for format_option in format:
try:
date = datetime.strptime(string, format_option)
return date, ord
except ValueError:
# Parsing failed.
pass
return (None, None)
if not string:
return None
# Check for a relative date.
match_dq = re.match(cls.relative_re, string)
if match_dq:
sign = match_dq.group('sign')
quantity = match_dq.group('quantity')
timespan = match_dq.group('timespan')
# Add or subtract the given amount of time from the current
# date.
multiplier = -1 if sign == '-' else 1
days = cls.relative_units[timespan]
date = datetime.now() + \
timedelta(days=int(quantity) * days) * multiplier
return cls(date, cls.precisions[5])
# Check for an absolute date.
date, ordinal = find_date_and_format(string)
if date is None:
raise InvalidQueryArgumentValueError(string,
'a valid date/time string')
ordinal = string.count('-')
if ordinal >= len(cls.date_formats):
# Too many components.
return None
date_format = cls.date_formats[ordinal]
try:
date = datetime.strptime(string, date_format)
except ValueError:
# Parsing failed.
return None
precision = cls.precisions[ordinal]
return cls(date, precision)
@@ -636,17 +580,11 @@ class Period:
return date.replace(year=date.year + 1, month=1)
elif 'day' == precision:
return date + timedelta(days=1)
elif 'hour' == precision:
return date + timedelta(hours=1)
elif 'minute' == precision:
return date + timedelta(minutes=1)
elif 'second' == precision:
return date + timedelta(seconds=1)
else:
raise ValueError(f'unhandled precision {precision}')
raise ValueError(u'unhandled precision {0}'.format(precision))
class DateInterval:
class DateInterval(object):
"""A closed-open interval of dates.
A left endpoint of None means since the beginning of time.
@@ -655,7 +593,7 @@ class DateInterval:
def __init__(self, start, end):
if start is not None and end is not None and not start < end:
raise ValueError("start date {} is not before end date {}"
raise ValueError(u"start date {0} is not before end date {1}"
.format(start, end))
self.start = start
self.end = end
@@ -676,7 +614,7 @@ class DateInterval:
return True
def __str__(self):
return f'[{self.start}, {self.end})'
return '[{0}, {1})'.format(self.start, self.end)
class DateQuery(FieldQuery):
@@ -688,9 +626,8 @@ class DateQuery(FieldQuery):
The value of a date field can be matched against a date interval by
using an ellipsis interval syntax similar to that of NumericQuery.
"""
def __init__(self, field, pattern, fast=True):
super().__init__(field, pattern, fast)
super(DateQuery, self).__init__(field, pattern, fast)
start, end = _parse_periods(pattern)
self.interval = DateInterval.from_periods(start, end)
@@ -698,7 +635,7 @@ class DateQuery(FieldQuery):
if self.field not in item:
return False
timestamp = float(item[self.field])
date = datetime.fromtimestamp(timestamp)
date = datetime.utcfromtimestamp(timestamp)
return self.interval.contains(date)
_clause_tmpl = "{0} {1} ?"
@@ -732,7 +669,6 @@ class DurationQuery(NumericQuery):
Raises InvalidQueryError when the pattern does not represent an int, float
or M:SS time interval.
"""
def _convert(self, s):
"""Convert a M:SS or numeric string to a float.
@@ -747,14 +683,14 @@ class DurationQuery(NumericQuery):
try:
return float(s)
except ValueError:
raise InvalidQueryArgumentValueError(
raise InvalidQueryArgumentTypeError(
s,
"a M:SS string or a float")
u"a M:SS string or a float")
# Sorting.
class Sort:
class Sort(object):
"""An abstract class representing a sort operation for a query into
the item database.
"""
@@ -841,13 +777,13 @@ class MultipleSort(Sort):
return items
def __repr__(self):
return f'MultipleSort({self.sorts!r})'
return 'MultipleSort({!r})'.format(self.sorts)
def __hash__(self):
return hash(tuple(self.sorts))
def __eq__(self, other):
return super().__eq__(other) and \
return super(MultipleSort, self).__eq__(other) and \
self.sorts == other.sorts
@@ -855,7 +791,6 @@ class FieldSort(Sort):
"""An abstract sort criterion that orders by a specific field (of
any kind).
"""
def __init__(self, field, ascending=True, case_insensitive=True):
self.field = field
self.ascending = ascending
@@ -868,14 +803,14 @@ class FieldSort(Sort):
def key(item):
field_val = item.get(self.field, '')
if self.case_insensitive and isinstance(field_val, str):
if self.case_insensitive and isinstance(field_val, six.text_type):
field_val = field_val.lower()
return field_val
return sorted(objs, key=key, reverse=not self.ascending)
def __repr__(self):
return '<{}: {}{}>'.format(
return '<{0}: {1}{2}>'.format(
type(self).__name__,
self.field,
'+' if self.ascending else '-',
@@ -885,7 +820,7 @@ class FieldSort(Sort):
return hash((self.field, self.ascending))
def __eq__(self, other):
return super().__eq__(other) and \
return super(FieldSort, self).__eq__(other) and \
self.field == other.field and \
self.ascending == other.ascending
@@ -893,7 +828,6 @@ class FieldSort(Sort):
class FixedFieldSort(FieldSort):
"""Sort object to sort on a fixed field.
"""
def order_clause(self):
order = "ASC" if self.ascending else "DESC"
if self.case_insensitive:
@@ -903,21 +837,19 @@ class FixedFieldSort(FieldSort):
'ELSE {0} END)'.format(self.field)
else:
field = self.field
return f"{field} {order}"
return "{0} {1}".format(field, order)
class SlowFieldSort(FieldSort):
"""A sort criterion by some model field other than a fixed field:
i.e., a computed or flexible field.
"""
def is_slow(self):
return True
class NullSort(Sort):
"""No sorting. Leave results unsorted."""
def sort(self, items):
return items
Regular → Executable
+34 -36
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,10 +15,12 @@
"""Parsing of strings into DBCore queries.
"""
from __future__ import division, absolute_import, print_function
import re
import itertools
from . import query
import beets
PARSE_QUERY_PART_REGEX = re.compile(
# Non-capturing optional segment for the keyword.
@@ -86,7 +89,7 @@ def parse_query_part(part, query_classes={}, prefixes={},
assert match # Regex should always match
negate = bool(match.group(1))
key = match.group(2)
term = match.group(3).replace('\\:', ':')
term = match.group(3).replace('\:', ':')
# Check whether there's a prefix in the query and use the
# corresponding query type.
@@ -116,13 +119,12 @@ def construct_query_part(model_cls, prefixes, query_part):
if not query_part:
return query.TrueQuery()
# Use `model_cls` to build up a map from field (or query) names to
# `Query` classes.
# Use `model_cls` to build up a map from field names to `Query`
# classes.
query_classes = {}
for k, t in itertools.chain(model_cls._fields.items(),
model_cls._types.items()):
query_classes[k] = t.query
query_classes.update(model_cls._queries) # Non-field queries.
# Parse the string.
key, pattern, query_class, negate = \
@@ -135,27 +137,26 @@ def construct_query_part(model_cls, prefixes, query_part):
# The query type matches a specific field, but none was
# specified. So we use a version of the query that matches
# any field.
out_query = query.AnyFieldQuery(pattern, model_cls._search_fields,
query_class)
q = query.AnyFieldQuery(pattern, model_cls._search_fields,
query_class)
if negate:
return query.NotQuery(q)
else:
return q
else:
# Non-field query type.
out_query = query_class(pattern)
if negate:
return query.NotQuery(query_class(pattern))
else:
return query_class(pattern)
# Field queries get constructed according to the name of the field
# they are querying.
elif issubclass(query_class, query.FieldQuery):
key = key.lower()
out_query = query_class(key.lower(), pattern, key in model_cls._fields)
# Non-field (named) query.
else:
out_query = query_class(pattern)
# Apply negation.
# Otherwise, this must be a `FieldQuery`. Use the field name to
# construct the query object.
key = key.lower()
q = query_class(key.lower(), pattern, key in model_cls._fields)
if negate:
return query.NotQuery(out_query)
else:
return out_query
return query.NotQuery(q)
return q
def query_from_strings(query_cls, model_cls, prefixes, query_parts):
@@ -171,13 +172,11 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts):
return query_cls(subqueries)
def construct_sort_part(model_cls, part, case_insensitive=True):
def construct_sort_part(model_cls, part):
"""Create a `Sort` from a single string criterion.
`model_cls` is the `Model` being queried. `part` is a single string
ending in ``+`` or ``-`` indicating the sort. `case_insensitive`
indicates whether or not the sort should be performed in a case
sensitive manner.
ending in ``+`` or ``-`` indicating the sort.
"""
assert part, "part must be a field name and + or -"
field = part[:-1]
@@ -186,6 +185,7 @@ def construct_sort_part(model_cls, part, case_insensitive=True):
assert direction in ('+', '-'), "part must end with + or -"
is_ascending = direction == '+'
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
if field in model_cls._sorts:
sort = model_cls._sorts[field](model_cls, is_ascending,
case_insensitive)
@@ -197,23 +197,21 @@ def construct_sort_part(model_cls, part, case_insensitive=True):
return sort
def sort_from_strings(model_cls, sort_parts, case_insensitive=True):
def sort_from_strings(model_cls, sort_parts):
"""Create a `Sort` from a list of sort criteria (strings).
"""
if not sort_parts:
sort = query.NullSort()
elif len(sort_parts) == 1:
sort = construct_sort_part(model_cls, sort_parts[0], case_insensitive)
sort = construct_sort_part(model_cls, sort_parts[0])
else:
sort = query.MultipleSort()
for part in sort_parts:
sort.add_sort(construct_sort_part(model_cls, part,
case_insensitive))
sort.add_sort(construct_sort_part(model_cls, part))
return sort
def parse_sorted_query(model_cls, parts, prefixes={},
case_insensitive=True):
def parse_sorted_query(model_cls, parts, prefixes={}):
"""Given a list of strings, create the `Query` and `Sort` that they
represent.
"""
@@ -224,8 +222,8 @@ def parse_sorted_query(model_cls, parts, prefixes={},
# Split up query in to comma-separated subqueries, each representing
# an AndQuery, which need to be joined together in one OrQuery
subquery_parts = []
for part in parts + [',']:
if part.endswith(','):
for part in parts + [u',']:
if part.endswith(u','):
# Ensure we can catch "foo, bar" as well as "foo , bar"
last_subquery_part = part[:-1]
if last_subquery_part:
@@ -239,8 +237,8 @@ def parse_sorted_query(model_cls, parts, prefixes={},
else:
# Sort parts (1) end in + or -, (2) don't have a field, and
# (3) consist of more than just the + or -.
if part.endswith(('+', '-')) \
and ':' not in part \
if part.endswith((u'+', u'-')) \
and u':' not in part \
and len(part) > 1:
sort_parts.append(part)
else:
@@ -248,5 +246,5 @@ def parse_sorted_query(model_cls, parts, prefixes={},
# Avoid needlessly wrapping single statements in an OR
q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0]
s = sort_from_strings(model_cls, sort_parts, case_insensitive)
s = sort_from_strings(model_cls, sort_parts)
return q, s
Regular → Executable
+26 -44
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,20 +15,25 @@
"""Representation of type information for DBCore model fields.
"""
from __future__ import division, absolute_import, print_function
from . import query
from beets.util import str2bool
import six
if not six.PY2:
buffer = memoryview # sqlite won't accept memoryview in python 2
# Abstract base.
class Type:
class Type(object):
"""An object encapsulating the type of a model field. Includes
information about how to store, query, format, and parse a given
field.
"""
sql = 'TEXT'
sql = u'TEXT'
"""The SQLite column type for the value.
"""
@@ -35,7 +41,7 @@ class Type:
"""The `Query` subclass to be used when querying the field.
"""
model_type = str
model_type = six.text_type
"""The Python type that is used to represent the value in the model.
The model is guaranteed to return a value of this type if the field
@@ -57,11 +63,11 @@ class Type:
value = self.null
# `self.null` might be `None`
if value is None:
value = ''
value = u''
if isinstance(value, bytes):
value = value.decode('utf-8', 'ignore')
return str(value)
return six.text_type(value)
def parse(self, string):
"""Parse a (possibly human-written) string and return the
@@ -91,16 +97,16 @@ class Type:
For fixed fields the type of `value` is determined by the column
type affinity given in the `sql` property and the SQL to Python
mapping of the database adapter. For more information see:
https://www.sqlite.org/datatype3.html
http://www.sqlite.org/datatype3.html
https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types
Flexible fields have the type affinity `TEXT`. This means the
`sql_value` is either a `memoryview` or a `unicode` object`
`sql_value` is either a `buffer`/`memoryview` or a `unicode` object`
and the method must handle these in addition.
"""
if isinstance(sql_value, memoryview):
if isinstance(sql_value, buffer):
sql_value = bytes(sql_value).decode('utf-8', 'ignore')
if isinstance(sql_value, str):
if isinstance(sql_value, six.text_type):
return self.parse(sql_value)
else:
return self.normalize(sql_value)
@@ -121,18 +127,10 @@ class Default(Type):
class Integer(Type):
"""A basic integer type.
"""
sql = 'INTEGER'
sql = u'INTEGER'
query = query.NumericQuery
model_type = int
def normalize(self, value):
try:
return self.model_type(round(float(value)))
except ValueError:
return self.null
except TypeError:
return self.null
class PaddedInt(Integer):
"""An integer field that is formatted with a given number of digits,
@@ -142,25 +140,19 @@ class PaddedInt(Integer):
self.digits = digits
def format(self, value):
return '{0:0{1}d}'.format(value or 0, self.digits)
class NullPaddedInt(PaddedInt):
"""Same as `PaddedInt`, but does not normalize `None` to `0.0`.
"""
null = None
return u'{0:0{1}d}'.format(value or 0, self.digits)
class ScaledInt(Integer):
"""An integer whose formatting operation scales the number by a
constant and adds a suffix. Good for units with large magnitudes.
"""
def __init__(self, unit, suffix=''):
def __init__(self, unit, suffix=u''):
self.unit = unit
self.suffix = suffix
def format(self, value):
return '{}{}'.format((value or 0) // self.unit, self.suffix)
return u'{0}{1}'.format((value or 0) // self.unit, self.suffix)
class Id(Integer):
@@ -171,22 +163,18 @@ class Id(Integer):
def __init__(self, primary=True):
if primary:
self.sql = 'INTEGER PRIMARY KEY'
self.sql = u'INTEGER PRIMARY KEY'
class Float(Type):
"""A basic floating-point type. The `digits` parameter specifies how
many decimal places to use in the human-readable representation.
"""A basic floating-point type.
"""
sql = 'REAL'
sql = u'REAL'
query = query.NumericQuery
model_type = float
def __init__(self, digits=1):
self.digits = digits
def format(self, value):
return '{0:.{1}f}'.format(value or 0, self.digits)
return u'{0:.1f}'.format(value or 0.0)
class NullFloat(Float):
@@ -198,25 +186,19 @@ class NullFloat(Float):
class String(Type):
"""A Unicode string type.
"""
sql = 'TEXT'
sql = u'TEXT'
query = query.SubstringQuery
def normalize(self, value):
if value is None:
return self.null
else:
return self.model_type(value)
class Boolean(Type):
"""A boolean type.
"""
sql = 'INTEGER'
sql = u'INTEGER'
query = query.BooleanQuery
model_type = bool
def format(self, value):
return str(bool(value))
return six.text_type(bool(value))
def parse(self, string):
return str2bool(string)
Regular → Executable
+112 -267
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -12,6 +13,7 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
"""Provides the basic, interface-agnostic workflow for importing and
autotagging music files.
@@ -35,10 +37,10 @@ from beets import dbcore
from beets import plugins
from beets import util
from beets import config
from beets.util import pipeline, sorted_walk, ancestry, MoveOperation
from beets.util import pipeline, sorted_walk, ancestry
from beets.util import syspath, normpath, displayable_path
from enum import Enum
import mediafile
from beets import mediafile
action = Enum('action',
['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG'])
@@ -73,7 +75,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('state file could not be read: {0}', exc)
log.debug(u'state file could not be read: {0}', exc)
return {}
@@ -82,8 +84,8 @@ def _save_state(state):
try:
with open(config['statefile'].as_filename(), 'wb') as f:
pickle.dump(state, f)
except OSError as exc:
log.error('state file could not be written: {0}', exc)
except IOError as exc:
log.error(u'state file could not be written: {0}', exc)
# Utilities for reading and writing the beets progress file, which
@@ -172,11 +174,10 @@ def history_get():
# Abstract session class.
class ImportSession:
class ImportSession(object):
"""Controls an import action. Subclasses should implement methods to
communicate with the user or otherwise make decisions.
"""
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
@@ -186,9 +187,7 @@ class ImportSession:
self.logger = self._setup_logging(loghandler)
self.paths = paths
self.query = query
self._is_resuming = {}
self._merged_items = set()
self._merged_dirs = set()
self._is_resuming = dict()
# Normalize the paths.
if self.paths:
@@ -221,31 +220,19 @@ class ImportSession:
iconfig['resume'] = False
iconfig['incremental'] = False
if iconfig['reflink']:
iconfig['reflink'] = iconfig['reflink'] \
.as_choice(['auto', True, False])
# Copy, move, reflink, link, and hardlink are mutually exclusive.
# Copy, move, link, and hardlink are mutually exclusive.
if iconfig['move']:
iconfig['copy'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
iconfig['reflink'] = False
elif iconfig['link']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['hardlink'] = False
iconfig['reflink'] = False
elif iconfig['hardlink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False
iconfig['reflink'] = False
elif iconfig['reflink']:
iconfig['copy'] = False
iconfig['move'] = False
iconfig['link'] = False
iconfig['hardlink'] = False
# Only delete when copying.
if not iconfig['copy']:
@@ -257,7 +244,7 @@ class ImportSession:
"""Log a message about a given album to the importer log. The status
should reflect the reason the album couldn't be tagged.
"""
self.logger.info('{0} {1}', status, displayable_path(paths))
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
@@ -268,17 +255,17 @@ class ImportSession:
if duplicate:
# Duplicate: log all three choices (skip, keep both, and trump).
if task.should_remove_duplicates:
self.tag_log('duplicate-replace', paths)
self.tag_log(u'duplicate-replace', paths)
elif task.choice_flag in (action.ASIS, action.APPLY):
self.tag_log('duplicate-keep', paths)
self.tag_log(u'duplicate-keep', paths)
elif task.choice_flag is (action.SKIP):
self.tag_log('duplicate-skip', paths)
self.tag_log(u'duplicate-skip', paths)
else:
# Non-duplicate: log "skip" and "asis" choices.
if task.choice_flag is action.ASIS:
self.tag_log('asis', paths)
self.tag_log(u'asis', paths)
elif task.choice_flag is action.SKIP:
self.tag_log('skip', paths)
self.tag_log(u'skip', paths)
def should_resume(self, path):
raise NotImplementedError
@@ -295,7 +282,7 @@ class ImportSession:
def run(self):
"""Run the import task.
"""
self.logger.info('import started {0}', time.asctime())
self.logger.info(u'import started {0}', time.asctime())
self.set_config(config['import'])
# Set up the pipeline.
@@ -324,8 +311,6 @@ class ImportSession:
stages += [import_asis(self)]
# Plugin stages.
for stage_func in plugins.early_import_stages():
stages.append(plugin_stage(self, stage_func))
for stage_func in plugins.import_stages():
stages.append(plugin_stage(self, stage_func))
@@ -365,24 +350,6 @@ class ImportSession:
self._history_dirs = history_get()
return self._history_dirs
def already_merged(self, paths):
"""Returns true if all the paths being imported were part of a merge
during previous tasks.
"""
for path in paths:
if path not in self._merged_items \
and path not in self._merged_dirs:
return False
return True
def mark_merged(self, paths):
"""Mark paths and directories as merged for future reimport tasks.
"""
self._merged_items.update(paths)
dirs = {os.path.dirname(path) if os.path.isfile(path) else path
for path in paths}
self._merged_dirs.update(dirs)
def is_resuming(self, toppath):
"""Return `True` if user wants to resume import of this path.
@@ -400,7 +367,7 @@ class ImportSession:
# Either accept immediately or prompt for input to decide.
if self.want_resume is True or \
self.should_resume(toppath):
log.warning('Resuming interrupted import of {0}',
log.warning(u'Resuming interrupted import of {0}',
util.displayable_path(toppath))
self._is_resuming[toppath] = True
else:
@@ -410,12 +377,11 @@ class ImportSession:
# The importer task class.
class BaseImportTask:
class BaseImportTask(object):
"""An abstract base class for importer tasks.
Tasks flow through the importer pipeline. Each stage can update
them. """
def __init__(self, toppath, paths, items):
"""Create a task. The primary fields that define a task are:
@@ -453,7 +419,7 @@ class ImportTask(BaseImportTask):
from the `candidates` list.
* `find_duplicates()` Returns a list of albums from `lib` with the
same artist and album name as the task.
same artist and album name as the task.
* `apply_metadata()` Sets the attributes of the items from the
task's `match` attribute.
@@ -463,22 +429,17 @@ class ImportTask(BaseImportTask):
* `manipulate_files()` Copy, move, and write files depending on the
session configuration.
* `set_fields()` Sets the fields given at CLI or configuration to
the specified values.
* `finalize()` Update the import progress and cleanup the file
system.
"""
def __init__(self, toppath, paths, items):
super().__init__(toppath, paths, items)
super(ImportTask, self).__init__(toppath, paths, items)
self.choice_flag = None
self.cur_album = None
self.cur_artist = None
self.candidates = []
self.rec = None
self.should_remove_duplicates = False
self.should_merge_duplicates = False
self.is_album = True
self.search_ids = [] # user-supplied candidate IDs.
@@ -549,10 +510,6 @@ class ImportTask(BaseImportTask):
def apply_metadata(self):
"""Copy metadata from match info to the items.
"""
if config['import']['from_scratch']:
for item in self.match.mapping:
item.clear()
autotag.apply_metadata(self.match.info, self.match.mapping)
def duplicate_items(self, lib):
@@ -563,45 +520,23 @@ class ImportTask(BaseImportTask):
def remove_duplicates(self, lib):
duplicate_items = self.duplicate_items(lib)
log.debug('removing {0} old duplicated items', 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('deleting duplicate {0}',
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)
def set_fields(self, lib):
"""Sets the fields given at CLI or configuration to the specified
values, for both the album and all its items.
"""
items = self.imported_items()
for field, view in config['import']['set_fields'].items():
value = view.get()
log.debug('Set field {1}={2} for {0}',
displayable_path(self.paths),
field,
value)
self.album[field] = value
for item in items:
item[field] = value
with lib.transaction():
for item in items:
item.store()
self.album.store()
def finalize(self, session):
"""Save progress, clean up files, and emit plugin event.
"""
# Update progress.
if session.want_resume:
self.save_progress()
if session.config['incremental'] and not (
# Should we skip recording to incremental list?
self.skip and session.config['incremental_skip_later']
):
if session.config['incremental']:
self.save_history()
self.cleanup(copy=session.config['copy'],
@@ -674,18 +609,17 @@ class ImportTask(BaseImportTask):
return []
duplicates = []
task_paths = {i.path for i in self.items if i}
task_paths = set(i.path for i in self.items if i)
duplicate_query = dbcore.AndQuery((
dbcore.MatchQuery('albumartist', artist),
dbcore.MatchQuery('album', album),
))
for album in lib.albums(duplicate_query):
# Check whether the album paths are all present in the task
# i.e. album is being completely re-imported by the task,
# in which case it is not a duplicate (will be replaced).
album_paths = {i.path for i in album.items()}
if not (album_paths <= task_paths):
# Check whether the album is identical in contents, in which
# case it is not a duplicate (will be replaced).
album_paths = set(i.path for i in album.items())
if album_paths != task_paths:
duplicates.append(album)
return duplicates
@@ -725,28 +659,20 @@ class ImportTask(BaseImportTask):
for item in self.items:
item.update(changes)
def manipulate_files(self, operation=None, write=False, session=None):
""" Copy, move, link, hardlink or reflink (depending on `operation`) the files
as well as write metadata.
`operation` should be an instance of `util.MoveOperation`.
If `write` is `True` metadata is written to the files.
"""
def manipulate_files(self, move=False, copy=False, write=False,
link=False, hardlink=False, session=None):
items = self.imported_items()
# Save the original paths of all items for deletion and pruning
# in the next step (finalization).
self.old_paths = [item.path for item in items]
for item in items:
if operation is not None:
if move or copy or link or hardlink:
# In copy and link modes, treat re-imports specially:
# move in-library files. (Out-of-library files are
# copied/moved as usual).
old_path = item.path
if (operation != MoveOperation.MOVE
and self.replaced_items[item]
and session.lib.directory in util.ancestry(old_path)):
if (copy or link or hardlink) and self.replaced_items[item] \
and session.lib.directory in util.ancestry(old_path):
item.move()
# We moved the item, so remove the
# now-nonexistent file from old_paths.
@@ -754,7 +680,7 @@ class ImportTask(BaseImportTask):
else:
# A normal import. Just copy files and keep track of
# old paths.
item.move(operation)
item.move(copy, link, hardlink)
if write and (self.apply or self.choice_flag == action.RETAG):
item.try_write()
@@ -773,8 +699,6 @@ class ImportTask(BaseImportTask):
self.record_replaced(lib)
self.remove_replaced(lib)
self.album = lib.add_album(self.imported_items())
if 'data_source' in self.imported_items()[0]:
self.album.data_source = self.imported_items()[0].data_source
self.reimport_metadata(lib)
def record_replaced(self, lib):
@@ -793,7 +717,7 @@ class ImportTask(BaseImportTask):
if (not dup_item.album_id or
dup_item.album_id in replaced_album_ids):
continue
replaced_album = dup_item._cached_album
replaced_album = dup_item.get_album()
if replaced_album:
replaced_album_ids.add(dup_item.album_id)
self.replaced_albums[replaced_album.path] = replaced_album
@@ -810,8 +734,8 @@ class ImportTask(BaseImportTask):
self.album.artpath = replaced_album.artpath
self.album.store()
log.debug(
'Reimported album: added {0}, flexible '
'attributes {1} from album {2} for {3}',
u'Reimported album: added {0}, flexible '
u'attributes {1} from album {2} for {3}',
self.album.added,
replaced_album._values_flex.keys(),
replaced_album.id,
@@ -824,16 +748,16 @@ class ImportTask(BaseImportTask):
if dup_item.added and dup_item.added != item.added:
item.added = dup_item.added
log.debug(
'Reimported item added {0} '
'from item {1} for {2}',
u'Reimported item added {0} '
u'from item {1} for {2}',
item.added,
dup_item.id,
displayable_path(item.path)
)
item.update(dup_item._values_flex)
log.debug(
'Reimported item flexible attributes {0} '
'from item {1} for {2}',
u'Reimported item flexible attributes {0} '
u'from item {1} for {2}',
dup_item._values_flex.keys(),
dup_item.id,
displayable_path(item.path)
@@ -846,10 +770,10 @@ class ImportTask(BaseImportTask):
"""
for item in self.imported_items():
for dup_item in self.replaced_items[item]:
log.debug('Replacing item {0}: {1}',
log.debug(u'Replacing item {0}: {1}',
dup_item.id, displayable_path(item.path))
dup_item.remove()
log.debug('{0} of {1} items replaced',
log.debug(u'{0} of {1} items replaced',
sum(bool(l) for l in self.replaced_items.values()),
len(self.imported_items()))
@@ -887,7 +811,7 @@ class SingletonImportTask(ImportTask):
"""
def __init__(self, toppath, item):
super().__init__(toppath, [item.path], [item])
super(SingletonImportTask, self).__init__(toppath, [item.path], [item])
self.item = item
self.is_album = False
self.paths = [item.path]
@@ -953,19 +877,6 @@ class SingletonImportTask(ImportTask):
def reload(self):
self.item.load()
def set_fields(self, lib):
"""Sets the fields given at CLI or configuration to the specified
values, for the singleton item.
"""
for field, view in config['import']['set_fields'].items():
value = view.get()
log.debug('Set field {1}={2} for {0}',
displayable_path(self.paths),
field,
value)
self.item[field] = value
self.item.store()
# FIXME The inheritance relationships are inverted. This is why there
# are so many methods which pass. More responsibility should be delegated to
@@ -980,7 +891,7 @@ class SentinelImportTask(ImportTask):
"""
def __init__(self, toppath, paths):
super().__init__(toppath, paths, ())
super(SentinelImportTask, self).__init__(toppath, paths, ())
# TODO Remove the remaining attributes eventually
self.should_remove_duplicates = False
self.is_album = True
@@ -1024,7 +935,7 @@ class ArchiveImportTask(SentinelImportTask):
"""
def __init__(self, toppath):
super().__init__(toppath, ())
super(ArchiveImportTask, self).__init__(toppath, ())
self.extracted = False
@classmethod
@@ -1053,20 +964,14 @@ class ArchiveImportTask(SentinelImportTask):
cls._handlers = []
from zipfile import is_zipfile, ZipFile
cls._handlers.append((is_zipfile, ZipFile))
import tarfile
cls._handlers.append((tarfile.is_tarfile, tarfile.open))
from tarfile import is_tarfile, TarFile
cls._handlers.append((is_tarfile, TarFile))
try:
from rarfile import is_rarfile, RarFile
except ImportError:
pass
else:
cls._handlers.append((is_rarfile, RarFile))
try:
from py7zr import is_7zfile, SevenZipFile
except ImportError:
pass
else:
cls._handlers.append((is_7zfile, SevenZipFile))
return cls._handlers
@@ -1074,7 +979,7 @@ class ArchiveImportTask(SentinelImportTask):
"""Removes the temporary directory the archive was extracted to.
"""
if self.extracted:
log.debug('Removing extracted directory: {0}',
log.debug(u'Removing extracted directory: {0}',
displayable_path(self.toppath))
shutil.rmtree(self.toppath)
@@ -1086,9 +991,9 @@ class ArchiveImportTask(SentinelImportTask):
if path_test(util.py3_path(self.toppath)):
break
extract_to = mkdtemp()
archive = handler_class(util.py3_path(self.toppath), mode='r')
try:
extract_to = mkdtemp()
archive = handler_class(util.py3_path(self.toppath), mode='r')
archive.extractall(extract_to)
finally:
archive.close()
@@ -1096,11 +1001,10 @@ class ArchiveImportTask(SentinelImportTask):
self.toppath = extract_to
class ImportTaskFactory:
class ImportTaskFactory(object):
"""Generate album and singleton import tasks for all media files
indicated by a path.
"""
def __init__(self, toppath, session):
"""Create a new task factory.
@@ -1138,12 +1042,14 @@ class ImportTaskFactory:
if self.session.config['singletons']:
for path in paths:
tasks = self._create(self.singleton(path))
yield from tasks
for task in tasks:
yield task
yield self.sentinel(dirs)
else:
tasks = self._create(self.album(paths, dirs))
yield from tasks
for task in tasks:
yield task
# Produce the final sentinel for this toppath to indicate that
# it is finished. This is usually just a SentinelImportTask, but
@@ -1191,7 +1097,7 @@ class ImportTaskFactory:
"""Return a `SingletonImportTask` for the music file.
"""
if self.session.already_imported(self.toppath, [path]):
log.debug('Skipping previously-imported path: {0}',
log.debug(u'Skipping previously-imported path: {0}',
displayable_path(path))
self.skipped += 1
return None
@@ -1212,10 +1118,10 @@ class ImportTaskFactory:
return None
if dirs is None:
dirs = list({os.path.dirname(p) for p in paths})
dirs = list(set(os.path.dirname(p) for p in paths))
if self.session.already_imported(self.toppath, dirs):
log.debug('Skipping previously-imported path: {0}',
log.debug(u'Skipping previously-imported path: {0}',
displayable_path(dirs))
self.skipped += 1
return None
@@ -1245,22 +1151,22 @@ class ImportTaskFactory:
if not (self.session.config['move'] or
self.session.config['copy']):
log.warning("Archive importing requires either "
"'copy' or 'move' to be enabled.")
log.warning(u"Archive importing requires either "
u"'copy' or 'move' to be enabled.")
return
log.debug('Extracting archive: {0}',
log.debug(u'Extracting archive: {0}',
displayable_path(self.toppath))
archive_task = ArchiveImportTask(self.toppath)
try:
archive_task.extract()
except Exception as exc:
log.error('extraction failed: {0}', exc)
log.error(u'extraction failed: {0}', exc)
return
# Now read albums from the extracted directory.
self.toppath = archive_task.toppath
log.debug('Archive extracted to: {0}', self.toppath)
log.debug(u'Archive extracted to: {0}', self.toppath)
return archive_task
def read_item(self, path):
@@ -1276,33 +1182,12 @@ class ImportTaskFactory:
# Silently ignore non-music files.
pass
elif isinstance(exc.reason, mediafile.UnreadableFileError):
log.warning('unreadable file: {0}', displayable_path(path))
log.warning(u'unreadable file: {0}', displayable_path(path))
else:
log.error('error reading {0}: {1}',
log.error(u'error reading {0}: {1}',
displayable_path(path), exc)
# Pipeline utilities
def _freshen_items(items):
# Clear IDs from re-tagged items so they appear "fresh" when
# we add them back to the library.
for item in items:
item.id = None
item.album_id = None
def _extend_pipeline(tasks, *stages):
# Return pipeline extension for stages with list of tasks
if type(tasks) == list:
task_iter = iter(tasks)
else:
task_iter = tasks
ipl = pipeline.Pipeline([task_iter] + list(stages))
return pipeline.multiple(ipl.pull())
# Full-album pipeline stages.
def read_tasks(session):
@@ -1317,16 +1202,17 @@ def read_tasks(session):
# Generate tasks.
task_factory = ImportTaskFactory(toppath, session)
yield from task_factory.tasks()
for t in task_factory.tasks():
yield t
skipped += task_factory.skipped
if not task_factory.imported:
log.warning('No files imported from {0}',
log.warning(u'No files imported from {0}',
displayable_path(toppath))
# Show skipped directories (due to incremental/resume).
if skipped:
log.info('Skipped {0} paths.', skipped)
log.info(u'Skipped {0} paths.', skipped)
def query_tasks(session):
@@ -1344,10 +1230,15 @@ def query_tasks(session):
else:
# Search for albums.
for album in session.lib.albums(session.query):
log.debug('yielding album {0}: {1} - {2}',
log.debug(u'yielding album {0}: {1} - {2}',
album.id, album.albumartist, album.album)
items = list(album.items())
_freshen_items(items)
# Clear IDs from re-tagged items so they appear "fresh" when
# we add them back to the library.
for item in items:
item.id = None
item.album_id = None
task = ImportTask(None, [album.item_dir()], items)
for task in task.handle_created(session):
@@ -1367,7 +1258,7 @@ def lookup_candidates(session, task):
return
plugins.send('import_task_start', session=session, task=task)
log.debug('Looking up: {0}', displayable_path(task.paths))
log.debug(u'Looking up: {0}', displayable_path(task.paths))
# Restrict the initial lookup to IDs specified by the user via the -m
# option. Currently all the IDs are passed onto the tasks directly.
@@ -1393,9 +1284,6 @@ def user_query(session, task):
if task.skip:
return task
if session.already_merged(task.paths):
return pipeline.BUBBLE
# Ask the user for a choice.
task.choose_match(session)
plugins.send('import_task_choice', session=session, task=task)
@@ -1406,41 +1294,28 @@ def user_query(session, task):
def emitter(task):
for item in task.items:
task = SingletonImportTask(task.toppath, item)
yield from task.handle_created(session)
for new_task in task.handle_created(session):
yield new_task
yield SentinelImportTask(task.toppath, task.paths)
return _extend_pipeline(emitter(task),
lookup_candidates(session),
user_query(session))
ipl = pipeline.Pipeline([
emitter(task),
lookup_candidates(session),
user_query(session),
])
return pipeline.multiple(ipl.pull())
# As albums: group items by albums and create task for each album
if task.choice_flag is action.ALBUMS:
return _extend_pipeline([task],
group_albums(session),
lookup_candidates(session),
user_query(session))
ipl = pipeline.Pipeline([
iter([task]),
group_albums(session),
lookup_candidates(session),
user_query(session)
])
return pipeline.multiple(ipl.pull())
resolve_duplicates(session, task)
if task.should_merge_duplicates:
# Create a new task for tagging the current items
# and duplicates together
duplicate_items = task.duplicate_items(session.lib)
# Duplicates would be reimported so make them look "fresh"
_freshen_items(duplicate_items)
duplicate_paths = [item.path for item in duplicate_items]
# Record merged paths in the session so they are not reimported
session.mark_merged(duplicate_paths)
merged_task = ImportTask(None, task.paths + duplicate_paths,
task.items + duplicate_items)
return _extend_pipeline([merged_task],
lookup_candidates(session),
user_query(session))
apply_choice(session, task)
return task
@@ -1452,32 +1327,28 @@ def resolve_duplicates(session, task):
if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG):
found_duplicates = task.find_duplicates(session.lib)
if found_duplicates:
log.debug('found duplicates: {}'.format(
log.debug(u'found duplicates: {}'.format(
[o.id for o in found_duplicates]
))
# Get the default action to follow from config.
duplicate_action = config['import']['duplicate_action'].as_choice({
'skip': 's',
'keep': 'k',
'remove': 'r',
'merge': 'm',
'ask': 'a',
u'skip': u's',
u'keep': u'k',
u'remove': u'r',
u'ask': u'a',
})
log.debug('default action for duplicates: {0}', duplicate_action)
log.debug(u'default action for duplicates: {0}', duplicate_action)
if duplicate_action == 's':
if duplicate_action == u's':
# Skip new.
task.set_choice(action.SKIP)
elif duplicate_action == 'k':
elif duplicate_action == u'k':
# Keep both. Do nothing; leave the choice intact.
pass
elif duplicate_action == 'r':
elif duplicate_action == u'r':
# Remove old.
task.should_remove_duplicates = True
elif duplicate_action == 'm':
# Merge duplicates together
task.should_merge_duplicates = True
else:
# No default action set; ask the session.
session.resolve_duplicate(task, found_duplicates)
@@ -1495,7 +1366,7 @@ def import_asis(session, task):
if task.skip:
return
log.info('{}', displayable_path(task.paths))
log.info(u'{}', displayable_path(task.paths))
task.set_choice(action.ASIS)
apply_choice(session, task)
@@ -1514,14 +1385,6 @@ def apply_choice(session, task):
task.add(session.lib)
# If ``set_fields`` is set, set those fields to the
# configured values.
# NOTE: This cannot be done before the ``task.add()`` call above,
# because then the ``ImportTask`` won't have an `album` for which
# it can set the fields.
if config['import']['set_fields']:
task.set_fields(session.lib)
@pipeline.mutator_stage
def plugin_stage(session, func, task):
@@ -1550,22 +1413,12 @@ def manipulate_files(session, task):
if task.should_remove_duplicates:
task.remove_duplicates(session.lib)
if session.config['move']:
operation = MoveOperation.MOVE
elif session.config['copy']:
operation = MoveOperation.COPY
elif session.config['link']:
operation = MoveOperation.LINK
elif session.config['hardlink']:
operation = MoveOperation.HARDLINK
elif session.config['reflink']:
operation = MoveOperation.REFLINK
else:
operation = None
task.manipulate_files(
operation,
move=session.config['move'],
copy=session.config['copy'],
write=session.config['write'],
link=session.config['link'],
hardlink=session.config['hardlink'],
session=session,
)
@@ -1578,11 +1431,11 @@ def log_files(session, task):
"""A coroutine (pipeline stage) to log each file to be imported.
"""
if isinstance(task, SingletonImportTask):
log.info('Singleton: {0}', displayable_path(task.item['path']))
log.info(u'Singleton: {0}', displayable_path(task.item['path']))
elif task.items:
log.info('Album: {0}', displayable_path(task.paths[0]))
log.info(u'Album: {0}', displayable_path(task.paths[0]))
for item in task.items:
log.info(' {0}', displayable_path(item['path']))
log.info(u' {0}', displayable_path(item['path']))
def group_albums(session):
@@ -1616,14 +1469,6 @@ MULTIDISC_MARKERS = (br'dis[ck]', br'cd')
MULTIDISC_PAT_FMT = br'^(.*%s[\W_]*)\d'
def is_subdir_of_any_in_list(path, dirs):
"""Returns True if path os a subdirectory of any directory in dirs
(a list). In other case, returns False.
"""
ancestors = ancestry(path)
return any(d in ancestors for d in dirs)
def albums_in_dir(path):
"""Recursively searches the given directory and returns an iterable
of (paths, items) where paths is a list of directories and items is
@@ -1643,7 +1488,7 @@ def albums_in_dir(path):
# and add the current directory. If so, just add the directory
# and move on to the next directory. If not, stop collapsing.
if collapse_paths:
if (is_subdir_of_any_in_list(root, collapse_paths)) or \
if (not collapse_pat and collapse_paths[0] in ancestry(root)) or \
(collapse_pat and
collapse_pat.match(os.path.basename(root))):
# Still collapsing.
Regular → Executable
+258 -453
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+9 -7
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -20,11 +21,13 @@ that when getLogger(name) instantiates a logger that logger uses
{}-style formatting.
"""
from __future__ import division, absolute_import, print_function
from copy import copy
from logging import * # noqa
import subprocess
import threading
import six
def logsafe(val):
@@ -40,7 +43,7 @@ def logsafe(val):
example.
"""
# Already Unicode.
if isinstance(val, str):
if isinstance(val, six.text_type):
return val
# Bytestring: needs decoding.
@@ -54,7 +57,7 @@ def logsafe(val):
# A "problem" object: needs a workaround.
elif isinstance(val, subprocess.CalledProcessError):
try:
return str(val)
return six.text_type(val)
except UnicodeDecodeError:
# An object with a broken __unicode__ formatter. Use __str__
# instead.
@@ -71,7 +74,7 @@ class StrFormatLogger(Logger):
instead of %-style formatting.
"""
class _LogMessage:
class _LogMessage(object):
def __init__(self, msg, args, kwargs):
self.msg = msg
self.args = args
@@ -79,23 +82,22 @@ class StrFormatLogger(Logger):
def __str__(self):
args = [logsafe(a) for a in self.args]
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
kwargs = dict((k, logsafe(v)) for (k, v) in self.kwargs.items())
return self.msg.format(*args, **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 super()._log(level, m, (), exc_info, extra)
return super(StrFormatLogger, self)._log(level, m, (), exc_info, extra)
class ThreadLocalLevelLogger(Logger):
"""A version of `Logger` whose level is thread-local instead of shared.
"""
def __init__(self, name, level=NOTSET):
self._thread_level = threading.local()
self.default_level = NOTSET
super().__init__(name, level)
super(ThreadLocalLevelLogger, self).__init__(name, level)
@property
def level(self):
Regular → Executable
+2047 -8
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+42 -304
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,19 +15,19 @@
"""Support for beets plugins."""
from __future__ import division, absolute_import, print_function
import inspect
import traceback
import re
import inspect
import abc
from collections import defaultdict
from functools import wraps
import beets
from beets import logging
import mediafile
from beets import mediafile
import six
PLUGIN_NAMESPACE = 'beetsplug'
@@ -49,28 +50,26 @@ class PluginLogFilter(logging.Filter):
"""A logging filter that identifies the plugin that emitted a log
message.
"""
def __init__(self, plugin):
self.prefix = f'{plugin.name}: '
self.prefix = u'{0}: '.format(plugin.name)
def filter(self, record):
if hasattr(record.msg, 'msg') and isinstance(record.msg.msg,
str):
six.string_types):
# A _LogMessage from our hacked-up Logging replacement.
record.msg.msg = self.prefix + record.msg.msg
elif isinstance(record.msg, str):
elif isinstance(record.msg, six.string_types):
record.msg = self.prefix + record.msg
return True
# Managing the plugins themselves.
class BeetsPlugin:
class BeetsPlugin(object):
"""The base class for all beets plugins. Plugins provide
functionality by defining a subclass of BeetsPlugin and overriding
the abstract methods defined here.
"""
def __init__(self, name=None):
"""Perform one-time plugin setup.
"""
@@ -82,7 +81,6 @@ class BeetsPlugin:
self.template_fields = {}
if not self.album_template_fields:
self.album_template_fields = {}
self.early_import_stages = []
self.import_stages = []
self._log = log.getChild(self.name)
@@ -96,22 +94,6 @@ class BeetsPlugin:
"""
return ()
def _set_stage_log_level(self, stages):
"""Adjust all the stages in `stages` to WARNING logging level.
"""
return [self._set_log_level_and_params(logging.WARNING, stage)
for stage in stages]
def get_early_import_stages(self):
"""Return a list of functions that should be called as importer
pipelines stages early in the pipeline.
The callables are wrapped versions of the functions in
`self.early_import_stages`. Wrapping provides some bookkeeping for the
plugin: specifically, the logging level is adjusted to WARNING.
"""
return self._set_stage_log_level(self.early_import_stages)
def get_import_stages(self):
"""Return a list of functions that should be called as importer
pipelines stages.
@@ -120,7 +102,8 @@ class BeetsPlugin:
`self.import_stages`. Wrapping provides some bookkeeping for the
plugin: specifically, the logging level is adjusted to WARNING.
"""
return self._set_stage_log_level(self.import_stages)
return [self._set_log_level_and_params(logging.WARNING, import_stage)
for import_stage in self.import_stages]
def _set_log_level_and_params(self, base_log_level, func):
"""Wrap `func` to temporarily set this plugin's logger level to
@@ -128,24 +111,27 @@ class BeetsPlugin:
value after the function returns). Also determines which params may not
be sent for backwards-compatibility.
"""
argspec = inspect.getfullargspec(func)
argspec = inspect.getargspec(func)
@wraps(func)
def wrapper(*args, **kwargs):
assert self._log.level == logging.NOTSET
verbosity = beets.config['verbose'].get(int)
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
self._log.setLevel(log_level)
if argspec.varkw is None:
kwargs = {k: v for k, v in kwargs.items()
if k in argspec.args}
try:
return func(*args, **kwargs)
try:
return func(*args, **kwargs)
except TypeError as exc:
if exc.args[0].startswith(func.__name__):
# caused by 'func' and not stuff internal to 'func'
kwargs = dict((arg, val) for arg, val in kwargs.items()
if arg in argspec.args)
return func(*args, **kwargs)
else:
raise
finally:
self._log.setLevel(logging.NOTSET)
return wrapper
def queries(self):
@@ -165,7 +151,7 @@ class BeetsPlugin:
"""
return beets.autotag.hooks.Distance()
def candidates(self, items, artist, album, va_likely, extra_tags=None):
def candidates(self, items, artist, album, va_likely):
"""Should return a sequence of AlbumInfo objects that match the
album whose items are provided.
"""
@@ -199,7 +185,7 @@ class BeetsPlugin:
``descriptor`` must be an instance of ``mediafile.MediaField``.
"""
# Defer import to prevent circular dependency
# Defer impor to prevent circular dependency
from beets import library
mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name)
@@ -262,14 +248,14 @@ def load_plugins(names=()):
BeetsPlugin subclasses desired.
"""
for name in names:
modname = f'{PLUGIN_NAMESPACE}.{name}'
modname = '{0}.{1}'.format(PLUGIN_NAMESPACE, name)
try:
try:
namespace = __import__(modname, None, None)
except ImportError as exc:
# Again, this is hacky:
if exc.args[0].endswith(' ' + name):
log.warning('** plugin {0} not found', name)
log.warning(u'** plugin {0} not found', name)
else:
raise
else:
@@ -278,9 +264,9 @@ def load_plugins(names=()):
and obj != BeetsPlugin and obj not in _classes:
_classes.add(obj)
except Exception:
except:
log.warning(
'** error loading plugin {}:\n{}',
u'** error loading plugin {}:\n{}',
name,
traceback.format_exc(),
)
@@ -294,11 +280,6 @@ def find_plugins():
currently loaded beets plugins. Loads the default plugin set
first.
"""
if _instances:
# After the first call, use cached instances for performance reasons.
# See https://github.com/beetbox/beets/pull/3810
return list(_instances.values())
load_plugins()
plugins = []
for cls in _classes:
@@ -332,31 +313,21 @@ def queries():
def types(model_cls):
# Gives us `item_types` and `album_types`
attr_name = f'{model_cls.__name__.lower()}_types'
attr_name = '{0}_types'.format(model_cls.__name__.lower())
types = {}
for plugin in find_plugins():
plugin_types = getattr(plugin, attr_name, {})
for field in plugin_types:
if field in types and plugin_types[field] != types[field]:
raise PluginConflictException(
'Plugin {} defines flexible field {} '
'which has already been defined with '
'another type.'.format(plugin.name, field)
u'Plugin {0} defines flexible field {1} '
u'which has already been defined with '
u'another type.'.format(plugin.name, field)
)
types.update(plugin_types)
return types
def named_queries(model_cls):
# Gather `item_queries` and `album_queries` from the plugins.
attr_name = f'{model_cls.__name__.lower()}_queries'
queries = {}
for plugin in find_plugins():
plugin_queries = getattr(plugin, attr_name, {})
queries.update(plugin_queries)
return queries
def track_distance(item, info):
"""Gets the track distance calculated by all loaded plugins.
Returns a Distance object.
@@ -377,19 +348,20 @@ def album_distance(items, album_info, mapping):
return dist
def candidates(items, artist, album, va_likely, extra_tags=None):
def candidates(items, artist, album, va_likely):
"""Gets MusicBrainz candidates for an album from each plugin.
"""
for plugin in find_plugins():
yield from plugin.candidates(items, artist, album, va_likely,
extra_tags)
for candidate in plugin.candidates(items, artist, album, va_likely):
yield candidate
def item_candidates(item, artist, title):
"""Gets MusicBrainz candidates for an item from the plugins.
"""
for plugin in find_plugins():
yield from plugin.item_candidates(item, artist, title)
for item_candidate in plugin.item_candidates(item, artist, title):
yield item_candidate
def album_for_id(album_id):
@@ -421,14 +393,6 @@ def template_funcs():
return funcs
def early_import_stages():
"""Get a list of early import stage functions defined by plugins."""
stages = []
for plugin in find_plugins():
stages += plugin.get_early_import_stages()
return stages
def import_stages():
"""Get a list of import stage functions defined by plugins."""
stages = []
@@ -482,7 +446,7 @@ def send(event, **arguments):
Return a list of non-None values returned from the handlers.
"""
log.debug('Sending event: {0}', event)
log.debug(u'Sending event: {0}', event)
results = []
for handler in event_handlers()[event]:
result = handler(**arguments)
@@ -500,7 +464,7 @@ def feat_tokens(for_artist=True):
feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.']
if for_artist:
feat_words += ['with', 'vs', 'and', 'con', '&']
return r'(?<=\s)(?:{})(?=\s)'.format(
return '(?<=\s)(?:{0})(?=\s)'.format(
'|'.join(re.escape(x) for x in feat_words)
)
@@ -514,50 +478,9 @@ def sanitize_choices(choices, choices_all):
others = [x for x in choices_all if x not in choices]
res = []
for s in choices:
if s not in seen:
if s in list(choices_all):
res.append(s)
elif s == '*':
res.extend(others)
seen.add(s)
return res
def sanitize_pairs(pairs, pairs_all):
"""Clean up a single-element mapping configuration attribute as returned
by Confuse's `Pairs` template: keep only two-element tuples present in
pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
wildcards while keeping the original order. Note that ('*', '*') and
('*', 'whatever') have the same effect.
For example,
>>> sanitize_pairs(
... [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
... [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
... ('key', 'value')]
... )
[('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
"""
pairs_all = list(pairs_all)
seen = set()
others = [x for x in pairs_all if x not in pairs]
res = []
for k, values in pairs:
for v in values.split():
x = (k, v)
if x in pairs_all:
if x not in seen:
seen.add(x)
res.append(x)
elif k == '*':
new = [o for o in others if o not in seen]
seen.update(new)
res.extend(new)
elif v == '*':
new = [o for o in others if o not in seen and o[0] == k]
seen.update(new)
res.extend(new)
if s in list(choices_all) + ['*']:
if not (s in seen or seen.add(s)):
res.extend(list(others) if s == '*' else [s])
return res
@@ -575,188 +498,3 @@ def notify_info_yielded(event):
yield v
return decorated
return decorator
def get_distance(config, data_source, info):
"""Returns the ``data_source`` weight and the maximum source weight
for albums or individual tracks.
"""
dist = beets.autotag.Distance()
if info.data_source == data_source:
dist.add('source', config['source_weight'].as_number())
return dist
def apply_item_changes(lib, item, move, pretend, write):
"""Store, move, and write the item according to the arguments.
:param lib: beets library.
:type lib: beets.library.Library
:param item: Item whose changes to apply.
:type item: beets.library.Item
:param move: Move the item if it's in the library.
:type move: bool
:param pretend: Return without moving, writing, or storing the item's
metadata.
:type pretend: bool
:param write: Write the item's metadata to its media file.
:type write: bool
"""
if pretend:
return
from beets import util
# Move the item if it's in the library.
if move and lib.directory in util.ancestry(item.path):
item.move(with_album=False)
if write:
item.try_write()
item.store()
class MetadataSourcePlugin(metaclass=abc.ABCMeta):
def __init__(self):
super().__init__()
self.config.add({'source_weight': 0.5})
@abc.abstractproperty
def id_regex(self):
raise NotImplementedError
@abc.abstractproperty
def data_source(self):
raise NotImplementedError
@abc.abstractproperty
def search_url(self):
raise NotImplementedError
@abc.abstractproperty
def album_url(self):
raise NotImplementedError
@abc.abstractproperty
def track_url(self):
raise NotImplementedError
@abc.abstractmethod
def _search_api(self, query_type, filters, keywords=''):
raise NotImplementedError
@abc.abstractmethod
def album_for_id(self, album_id):
raise NotImplementedError
@abc.abstractmethod
def track_for_id(self, track_id=None, track_data=None):
raise NotImplementedError
@staticmethod
def get_artist(artists, id_key='id', name_key='name'):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of artist object dicts.
For each artist, this function moves articles (such as 'a', 'an',
and 'the') to the front and strips trailing disambiguation numbers. It
returns a tuple containing the comma-separated string of all
normalized artists and the ``id`` of the main/first artist.
:param artists: Iterable of artist dicts or lists returned by API.
:type artists: list[dict] or list[list]
:param id_key: Key or index corresponding to the value of ``id`` for
the main/first artist. Defaults to 'id'.
:type id_key: str or int
:param name_key: Key or index corresponding to values of names
to concatenate for the artist string (containing all artists).
Defaults to 'name'.
:type name_key: str or int
:return: Normalized artist string.
:rtype: str
"""
artist_id = None
artist_names = []
for artist in artists:
if not artist_id:
artist_id = artist[id_key]
name = artist[name_key]
# Strip disambiguation number.
name = re.sub(r' \(\d+\)$', '', name)
# Move articles to the front.
name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
artist_names.append(name)
artist = ', '.join(artist_names).replace(' ,', ',') or None
return artist, artist_id
def _get_id(self, url_type, id_):
"""Parse an ID from its URL if necessary.
:param url_type: Type of URL. Either 'album' or 'track'.
:type url_type: str
:param id_: Album/track ID or URL.
:type id_: str
:return: Album/track ID.
:rtype: str
"""
self._log.debug(
"Searching {} for {} '{}'", self.data_source, url_type, id_
)
match = re.search(self.id_regex['pattern'].format(url_type), str(id_))
if match:
id_ = match.group(self.id_regex['match_group'])
if id_:
return id_
return None
def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Returns a list of AlbumInfo objects for Search API results
matching an ``album`` and ``artist`` (if not various).
:param items: List of items comprised by an album to be matched.
:type items: list[beets.library.Item]
:param artist: The artist of the album to be matched.
:type artist: str
:param album: The name of the album to be matched.
:type album: str
:param va_likely: True if the album to be matched likely has
Various Artists.
:type va_likely: bool
:return: Candidate AlbumInfo objects.
:rtype: list[beets.autotag.hooks.AlbumInfo]
"""
query_filters = {'album': album}
if not va_likely:
query_filters['artist'] = artist
results = self._search_api(query_type='album', filters=query_filters)
albums = [self.album_for_id(album_id=r['id']) for r in results]
return [a for a in albums if a is not None]
def item_candidates(self, item, artist, title):
"""Returns a list of TrackInfo objects for Search API results
matching ``title`` and ``artist``.
:param item: Singleton item to be matched.
:type item: beets.library.Item
:param artist: The artist of the track to be matched.
:type artist: str
:param title: The title of the track to be matched.
:type title: str
:return: Candidate TrackInfo objects.
:rtype: list[beets.autotag.hooks.TrackInfo]
"""
tracks = self._search_api(
query_type='track', keywords=title, filters={'artist': artist}
)
return [self.track_for_id(track_data=track) for track in tracks]
def album_distance(self, items, album_info, mapping):
return get_distance(
data_source=self.data_source, info=album_info, config=self.config
)
def track_distance(self, item, track_info):
return get_distance(
data_source=self.data_source, info=track_info, config=self.config
)
-113
View File
@@ -1,113 +0,0 @@
# This file is part of beets.
# Copyright 2016, Philippe Mongeau.
#
# 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.
"""Get a random song or album from the library.
"""
import random
from operator import attrgetter
from itertools import groupby
def _length(obj, album):
"""Get the duration of an item or album.
"""
if album:
return sum(i.length for i in obj.items())
else:
return obj.length
def _equal_chance_permutation(objs, field='albumartist', random_gen=None):
"""Generate (lazily) a permutation of the objects where every group
with equal values for `field` have an equal chance of appearing in
any given position.
"""
rand = random_gen or random
# Group the objects by artist so we can sample from them.
key = attrgetter(field)
objs.sort(key=key)
objs_by_artists = {}
for artist, v in groupby(objs, key):
objs_by_artists[artist] = list(v)
# While we still have artists with music to choose from, pick one
# randomly and pick a track from that artist.
while objs_by_artists:
# Choose an artist and an object for that artist, removing
# this choice from the pool.
artist = rand.choice(list(objs_by_artists.keys()))
objs_from_artist = objs_by_artists[artist]
i = rand.randint(0, len(objs_from_artist) - 1)
yield objs_from_artist.pop(i)
# Remove the artist if we've used up all of its objects.
if not objs_from_artist:
del objs_by_artists[artist]
def _take(iter, num):
"""Return a list containing the first `num` values in `iter` (or
fewer, if the iterable ends early).
"""
out = []
for val in iter:
out.append(val)
num -= 1
if num <= 0:
break
return out
def _take_time(iter, secs, album):
"""Return a list containing the first values in `iter`, which should
be Item or Album objects, that add up to the given amount of time in
seconds.
"""
out = []
total_time = 0.0
for obj in iter:
length = _length(obj, album)
if total_time + length <= secs:
out.append(obj)
total_time += length
return out
def random_objs(objs, album, number=1, time=None, equal_chance=False,
random_gen=None):
"""Get a random subset of the provided `objs`.
If `number` is provided, produce that many matches. Otherwise, if
`time` is provided, instead select a list whose total time is close
to that number of minutes. If `equal_chance` is true, give each
artist an equal chance of being included so that artists with more
songs are not represented disproportionately.
"""
rand = random_gen or random
# Permute the objects either in a straightforward way or an
# artist-balanced way.
if equal_chance:
perm = _equal_chance_permutation(objs)
else:
perm = objs
rand.shuffle(perm) # N.B. This shuffles the original list.
# Select objects by time our count.
if time:
return _take_time(perm, time * 60, album)
else:
return _take(perm, number)
Regular → Executable
+157 -229
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -17,6 +18,7 @@ interface. To invoke the CLI, just call beets.ui.main(). The actual
CLI commands are implemented in the ui.commands module.
"""
from __future__ import division, absolute_import, print_function
import optparse
import textwrap
@@ -28,18 +30,18 @@ import re
import struct
import traceback
import os.path
from six.moves import input
from beets import logging
from beets import library
from beets import plugins
from beets import util
from beets.util.functemplate import template
from beets.util.functemplate import Template
from beets import config
from beets.util import as_string
from beets.util import confit, as_string
from beets.autotag import mb
from beets.dbcore import query as db_query
from beets.dbcore import db
import confuse
import six
# On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == 'win32':
@@ -58,8 +60,8 @@ log.propagate = False # Don't propagate to root handler.
PF_KEY_QUERIES = {
'comp': 'comp:true',
'singleton': 'singleton:true',
'comp': u'comp:true',
'singleton': u'singleton:true',
}
@@ -109,7 +111,10 @@ def decargs(arglist):
"""Given a list of command-line argument bytestrings, attempts to
decode them to Unicode strings when running under Python 2.
"""
return arglist
if six.PY2:
return [s.decode(util.arg_encoding()) for s in arglist]
else:
return arglist
def print_(*strings, **kwargs):
@@ -124,25 +129,29 @@ def print_(*strings, **kwargs):
(it defaults to a newline).
"""
if not strings:
strings = ['']
assert isinstance(strings[0], str)
strings = [u'']
assert isinstance(strings[0], six.text_type)
txt = ' '.join(strings)
txt += kwargs.get('end', '\n')
txt = u' '.join(strings)
txt += kwargs.get('end', u'\n')
# Encode the string and write it to stdout.
# On Python 3, sys.stdout expects text strings and uses the
# exception-throwing encoding error policy. To avoid throwing
# errors and use our configurable encoding override, we use the
# underlying bytes buffer instead.
if hasattr(sys.stdout, 'buffer'):
if six.PY2:
# On Python 2, sys.stdout expects bytes.
out = txt.encode(_out_encoding(), 'replace')
sys.stdout.buffer.write(out)
sys.stdout.buffer.flush()
sys.stdout.write(out)
else:
# In our test harnesses (e.g., DummyOut), sys.stdout.buffer
# does not exist. We instead just record the text string.
sys.stdout.write(txt)
# On Python 3, sys.stdout expects text strings and uses the
# exception-throwing encoding error policy. To avoid throwing
# errors and use our configurable encoding override, we use the
# underlying bytes buffer instead.
if hasattr(sys.stdout, 'buffer'):
out = txt.encode(_out_encoding(), 'replace')
sys.stdout.buffer.write(out)
else:
# In our test harnesses (e.g., DummyOut), sys.stdout.buffer
# does not exist. We instead just record the text string.
sys.stdout.write(txt)
# Configuration wrappers.
@@ -192,16 +201,19 @@ def input_(prompt=None):
"""
# raw_input incorrectly sends prompts to stderr, not stdout, so we
# use print_() explicitly to display prompts.
# https://bugs.python.org/issue1927
# http://bugs.python.org/issue1927
if prompt:
print_(prompt, end=' ')
print_(prompt, end=u' ')
try:
resp = input()
except EOFError:
raise UserError('stdin stream ended while input required')
raise UserError(u'stdin stream ended while input required')
return resp
if six.PY2:
return resp.decode(_in_encoding(), 'ignore')
else:
return resp
def input_options(options, require=False, prompt=None, fallback_prompt=None,
@@ -245,7 +257,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
found_letter = letter
break
else:
raise ValueError('no unambiguous lettering found')
raise ValueError(u'no unambiguous lettering found')
letters[found_letter.lower()] = option
index = option.index(found_letter)
@@ -253,7 +265,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
# Mark the option's shortcut letter for display.
if not require and (
(default is None and not numrange and first) or
(isinstance(default, str) and
(isinstance(default, six.string_types) and
found_letter.lower() == default.lower())):
# The first option is the default; mark it.
show_letter = '[%s]' % found_letter.upper()
@@ -289,11 +301,11 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
prompt_part_lengths = []
if numrange:
if isinstance(default, int):
default_name = str(default)
default_name = six.text_type(default)
default_name = colorize('action_default', default_name)
tmpl = '# selection (default %s)'
prompt_parts.append(tmpl % default_name)
prompt_part_lengths.append(len(tmpl % str(default)))
prompt_part_lengths.append(len(tmpl % six.text_type(default)))
else:
prompt_parts.append('# selection')
prompt_part_lengths.append(len(prompt_parts[-1]))
@@ -328,9 +340,9 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
# Make a fallback prompt too. This is displayed if the user enters
# something that is not recognized.
if not fallback_prompt:
fallback_prompt = 'Enter one of '
fallback_prompt = u'Enter one of '
if numrange:
fallback_prompt += '%i-%i, ' % numrange
fallback_prompt += u'%i-%i, ' % numrange
fallback_prompt += ', '.join(display_letters) + ':'
resp = input_(prompt)
@@ -369,41 +381,34 @@ def input_yn(prompt, require=False):
"yes" unless `require` is `True`, in which case there is no default.
"""
sel = input_options(
('y', 'n'), require, prompt, 'Enter Y or N:'
('y', 'n'), require, prompt, u'Enter Y or N:'
)
return sel == 'y'
return sel == u'y'
def input_select_objects(prompt, objs, rep, prompt_all=None):
def input_select_objects(prompt, objs, rep):
"""Prompt to user to choose all, none, or some of the given objects.
Return the list of selected objects.
`prompt` is the prompt string to use for each question (it should be
phrased as an imperative verb). If `prompt_all` is given, it is used
instead of `prompt` for the first (yes(/no/select) question.
`rep` is a function to call on each object to print it out when confirming
objects individually.
phrased as an imperative verb). `rep` is a function to call on each
object to print it out when confirming objects individually.
"""
choice = input_options(
('y', 'n', 's'), False,
'%s? (Yes/no/select)' % (prompt_all or prompt))
(u'y', u'n', u's'), False,
u'%s? (Yes/no/select)' % prompt)
print() # Blank line.
if choice == 'y': # Yes.
if choice == u'y': # Yes.
return objs
elif choice == 's': # Select.
elif choice == u's': # Select.
out = []
for obj in objs:
rep(obj)
answer = input_options(
('y', 'n', 'q'), True, '%s? (yes/no/quit)' % prompt,
'Enter Y or N:'
)
if answer == 'y':
if input_yn(u'%s? (yes/no)' % prompt, True):
out.append(obj)
elif answer == 'q':
return out
print() # go to a new line
return out
else: # No.
@@ -414,14 +419,14 @@ def input_select_objects(prompt, objs, rep, prompt_all=None):
def human_bytes(size):
"""Formats size, a number of bytes, in a human-readable way."""
powers = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'H']
powers = [u'', u'K', u'M', u'G', u'T', u'P', u'E', u'Z', u'Y', u'H']
unit = 'B'
for power in powers:
if size < 1024:
return f"{size:3.1f} {power}{unit}"
return u"%3.1f %s%s" % (size, power, unit)
size /= 1024.0
unit = 'iB'
return "big"
unit = u'iB'
return u"big"
def human_seconds(interval):
@@ -429,13 +434,13 @@ def human_seconds(interval):
interval using English words.
"""
units = [
(1, 'second'),
(60, 'minute'),
(60, 'hour'),
(24, 'day'),
(7, 'week'),
(52, 'year'),
(10, 'decade'),
(1, u'second'),
(60, u'minute'),
(60, u'hour'),
(24, u'day'),
(7, u'week'),
(52, u'year'),
(10, u'decade'),
]
for i in range(len(units) - 1):
increment, suffix = units[i]
@@ -448,7 +453,7 @@ def human_seconds(interval):
increment, suffix = units[-1]
interval /= float(increment)
return f"{interval:3.1f} {suffix}s"
return u"%3.1f %ss" % (interval, suffix)
def human_seconds_short(interval):
@@ -456,13 +461,13 @@ def human_seconds_short(interval):
string.
"""
interval = int(interval)
return '%i:%02i' % (interval // 60, interval % 60)
return u'%i:%02i' % (interval // 60, interval % 60)
# Colorization.
# ANSI terminal colorization code heavily inspired by pygments:
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
COLOR_ESCAPE = "\x1b["
DARK_COLORS = {
@@ -509,7 +514,7 @@ def _colorize(color, text):
elif color in LIGHT_COLORS:
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30)
else:
raise ValueError('no such color %s', color)
raise ValueError(u'no such color %s', color)
return escape + text + RESET_COLOR
@@ -517,22 +522,22 @@ def colorize(color_name, text):
"""Colorize text if colored output is enabled. (Like _colorize but
conditional.)
"""
if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
if config['ui']['color']:
global COLORS
if not COLORS:
COLORS = dict((name,
config['ui']['colors'][name].as_str())
for name in COLOR_NAMES)
# In case a 3rd party plugin is still passing the actual color ('red')
# instead of the abstract color name ('text_error')
color = COLORS.get(color_name)
if not color:
log.debug(u'Invalid color_name: {0}', color_name)
color = color_name
return _colorize(color, text)
else:
return text
global COLORS
if not COLORS:
COLORS = {name:
config['ui']['colors'][name].as_str()
for name in COLOR_NAMES}
# In case a 3rd party plugin is still passing the actual color ('red')
# instead of the abstract color name ('text_error')
color = COLORS.get(color_name)
if not color:
log.debug('Invalid color_name: {0}', color_name)
color = color_name
return _colorize(color, text)
def _colordiff(a, b, highlight='text_highlight',
minor_highlight='text_highlight_minor'):
@@ -541,11 +546,11 @@ def _colordiff(a, b, highlight='text_highlight',
highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety.
"""
if not isinstance(a, str) \
or not isinstance(b, str):
if not isinstance(a, six.string_types) \
or not isinstance(b, six.string_types):
# Non-strings: use ordinary equality.
a = str(a)
b = str(b)
a = six.text_type(a)
b = six.text_type(b)
if a == b:
return a, b
else:
@@ -583,7 +588,7 @@ def _colordiff(a, b, highlight='text_highlight',
else:
assert(False)
return ''.join(a_out), ''.join(b_out)
return u''.join(a_out), u''.join(b_out)
def colordiff(a, b, highlight='text_highlight'):
@@ -593,7 +598,7 @@ def colordiff(a, b, highlight='text_highlight'):
if config['ui']['color']:
return _colordiff(a, b, highlight)
else:
return str(a), str(b)
return six.text_type(a), six.text_type(b)
def get_path_formats(subview=None):
@@ -604,12 +609,12 @@ def get_path_formats(subview=None):
subview = subview or config['paths']
for query, view in subview.items():
query = PF_KEY_QUERIES.get(query, query) # Expand common queries.
path_formats.append((query, template(view.as_str())))
path_formats.append((query, Template(view.as_str())))
return path_formats
def get_replacements():
"""Confuse validation function that reads regex/string pairs.
"""Confit validation function that reads regex/string pairs.
"""
replacements = []
for pattern, repl in config['replace'].get(dict).items():
@@ -618,7 +623,7 @@ def get_replacements():
replacements.append((re.compile(pattern), repl))
except re.error:
raise UserError(
'malformed regular expression in replace: {}'.format(
u'malformed regular expression in replace: {0}'.format(
pattern
)
)
@@ -639,7 +644,7 @@ def term_width():
try:
buf = fcntl.ioctl(0, termios.TIOCGWINSZ, ' ' * 4)
except OSError:
except IOError:
return fallback
try:
height, width = struct.unpack('hh', buf)
@@ -651,10 +656,10 @@ def term_width():
FLOAT_EPSILON = 0.01
def _field_diff(field, old, old_fmt, new, new_fmt):
"""Given two Model objects and their formatted views, format their values
for `field` and highlight changes among them. Return a human-readable
string. If the value has not changed, return None instead.
def _field_diff(field, old, new):
"""Given two Model objects, format their values for `field` and
highlight changes among them. Return a human-readable string. If the
value has not changed, return None instead.
"""
oldval = old.get(field)
newval = new.get(field)
@@ -667,18 +672,18 @@ def _field_diff(field, old, old_fmt, new, new_fmt):
return None
# Get formatted values for output.
oldstr = old_fmt.get(field, '')
newstr = new_fmt.get(field, '')
oldstr = old.formatted().get(field, u'')
newstr = new.formatted().get(field, u'')
# For strings, highlight changes. For others, colorize the whole
# thing.
if isinstance(oldval, str):
if isinstance(oldval, six.string_types):
oldstr, newstr = colordiff(oldval, newstr)
else:
oldstr = colorize('text_error', oldstr)
newstr = colorize('text_error', newstr)
return f'{oldstr} -> {newstr}'
return u'{0} -> {1}'.format(oldstr, newstr)
def show_model_changes(new, old=None, fields=None, always=False):
@@ -693,11 +698,6 @@ def show_model_changes(new, old=None, fields=None, always=False):
"""
old = old or new._db._get(type(new), new.id)
# Keep the formatted views around instead of re-creating them in each
# iteration step
old_fmt = old.formatted()
new_fmt = new.formatted()
# Build up lines showing changed fields.
changes = []
for field in old:
@@ -706,25 +706,25 @@ def show_model_changes(new, old=None, fields=None, always=False):
continue
# Detect and show difference for this field.
line = _field_diff(field, old, old_fmt, new, new_fmt)
line = _field_diff(field, old, new)
if line:
changes.append(f' {field}: {line}')
changes.append(u' {0}: {1}'.format(field, line))
# New fields.
for field in set(new) - set(old):
if fields and field not in fields:
continue
changes.append(' {}: {}'.format(
changes.append(u' {0}: {1}'.format(
field,
colorize('text_highlight', new_fmt[field])
colorize('text_highlight', new.formatted()[field])
))
# Print changes.
if changes or always:
print_(format(old))
if changes:
print_('\n'.join(changes))
print_(u'\n'.join(changes))
return bool(changes)
@@ -757,55 +757,18 @@ def show_path_changes(path_changes):
if max_width > col_width:
# Print every change over two lines
for source, dest in zip(sources, destinations):
color_source, color_dest = colordiff(source, dest)
print_('{0} \n -> {1}'.format(color_source, color_dest))
log.info(u'{0} \n -> {1}', source, dest)
else:
# Print every change on a single line, and add a header
title_pad = max_width - len('Source ') + len(' -> ')
print_('Source {0} Destination'.format(' ' * title_pad))
log.info(u'Source {0} Destination', ' ' * title_pad)
for source, dest in zip(sources, destinations):
pad = max_width - len(source)
color_source, color_dest = colordiff(source, dest)
print_('{0} {1} -> {2}'.format(
color_source,
' ' * pad,
color_dest,
))
log.info(u'{0} {1} -> {2}', source, ' ' * pad, dest)
# Helper functions for option parsing.
def _store_dict(option, opt_str, value, parser):
"""Custom action callback to parse options which have ``key=value``
pairs as values. All such pairs passed for this option are
aggregated into a dictionary.
"""
dest = option.dest
option_values = getattr(parser.values, dest, None)
if option_values is None:
# This is the first supplied ``key=value`` pair of option.
# Initialize empty dictionary and get a reference to it.
setattr(parser.values, dest, {})
option_values = getattr(parser.values, dest)
# Decode the argument using the platform's argument encoding.
value = util.text_string(value, util.arg_encoding())
try:
key, value = value.split('=', 1)
if not (key and value):
raise ValueError
except ValueError:
raise UserError(
"supplied argument `{}' is not of the form `key=value'"
.format(value))
option_values[key] = value
class CommonOptionsParser(optparse.OptionParser):
class CommonOptionsParser(optparse.OptionParser, object):
"""Offers a simple way to add common formatting options.
Options available include:
@@ -820,9 +783,8 @@ class CommonOptionsParser(optparse.OptionParser):
Each method is fully documented in the related method.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CommonOptionsParser, self).__init__(*args, **kwargs)
self._album_flags = False
# this serves both as an indicator that we offer the feature AND allows
# us to check whether it has been specified on the CLI - bypassing the
@@ -836,7 +798,7 @@ class CommonOptionsParser(optparse.OptionParser):
Sets the album property on the options extracted from the CLI.
"""
album = optparse.Option(*flags, action='store_true',
help='match albums instead of tracks')
help=u'match albums instead of tracks')
self.add_option(album)
self._album_flags = set(flags)
@@ -854,7 +816,7 @@ class CommonOptionsParser(optparse.OptionParser):
elif value:
value, = decargs([value])
else:
value = ''
value = u''
parser.values.format = value
if target:
@@ -881,14 +843,14 @@ class CommonOptionsParser(optparse.OptionParser):
By default this affects both items and albums. If add_album_option()
is used then the target will be autodetected.
Sets the format property to '$path' on the options extracted from the
Sets the format property to u'$path' on the options extracted from the
CLI.
"""
path = optparse.Option(*flags, nargs=0, action='callback',
callback=self._set_format,
callback_kwargs={'fmt': '$path',
callback_kwargs={'fmt': u'$path',
'store_true': True},
help='print paths for matched items or albums')
help=u'print paths for matched items or albums')
self.add_option(path)
def add_format_option(self, flags=('-f', '--format'), target=None):
@@ -908,7 +870,7 @@ class CommonOptionsParser(optparse.OptionParser):
"""
kwargs = {}
if target:
if isinstance(target, str):
if isinstance(target, six.string_types):
target = {'item': library.Item,
'album': library.Album}[target]
kwargs['target'] = target
@@ -916,7 +878,7 @@ class CommonOptionsParser(optparse.OptionParser):
opt = optparse.Option(*flags, action='callback',
callback=self._set_format,
callback_kwargs=kwargs,
help='print with custom format')
help=u'print with custom format')
self.add_option(opt)
def add_all_common_options(self):
@@ -931,15 +893,14 @@ class CommonOptionsParser(optparse.OptionParser):
#
# This is a fairly generic subcommand parser for optparse. It is
# maintained externally here:
# https://gist.github.com/462717
# http://gist.github.com/462717
# There you will also find a better description of the code and a more
# succinct example program.
class Subcommand:
class Subcommand(object):
"""A subcommand of a root command-line application that may be
invoked by a SubcommandOptionParser.
"""
def __init__(self, name, parser=None, help='', aliases=(), hide=False):
"""Creates a new subcommand. name is the primary way to invoke
the subcommand; aliases are alternate names. parser is an
@@ -967,7 +928,7 @@ class Subcommand:
@root_parser.setter
def root_parser(self, root_parser):
self._root_parser = root_parser
self.parser.prog = '{} {}'.format(
self.parser.prog = '{0} {1}'.format(
as_string(root_parser.get_prog_name()), self.name)
@@ -983,13 +944,13 @@ class SubcommandsOptionParser(CommonOptionsParser):
"""
# A more helpful default usage.
if 'usage' not in kwargs:
kwargs['usage'] = """
kwargs['usage'] = u"""
%prog COMMAND [ARGS...]
%prog help COMMAND"""
kwargs['add_help_option'] = False
# Super constructor.
super().__init__(*args, **kwargs)
super(SubcommandsOptionParser, self).__init__(*args, **kwargs)
# Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args()
@@ -1006,7 +967,7 @@ class SubcommandsOptionParser(CommonOptionsParser):
# Add the list of subcommands to the help message.
def format_help(self, formatter=None):
# Get the original help message, to which we will append.
out = super().format_help(formatter)
out = super(SubcommandsOptionParser, self).format_help(formatter)
if formatter is None:
formatter = self.formatter
@@ -1092,7 +1053,7 @@ class SubcommandsOptionParser(CommonOptionsParser):
cmdname = args.pop(0)
subcommand = self._subcommand_for_name(cmdname)
if not subcommand:
raise UserError(f"unknown command '{cmdname}'")
raise UserError(u"unknown command '{0}'".format(cmdname))
suboptions, subargs = subcommand.parse_args(args)
return subcommand, suboptions, subargs
@@ -1103,32 +1064,26 @@ optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
# The main entry point and bootstrapping.
def _load_plugins(options, config):
"""Load the plugins specified on the command line or in the configuration.
def _load_plugins(config):
"""Load the plugins specified in the configuration.
"""
paths = config['pluginpath'].as_str_seq(split=False)
paths = [util.normpath(p) for p in paths]
log.debug('plugin paths: {0}', util.displayable_path(paths))
log.debug(u'plugin paths: {0}', util.displayable_path(paths))
# On Python 3, the search paths need to be unicode.
paths = [util.py3_path(p) for p in paths]
# Extend the `beetsplug` package to include the plugin paths.
import beetsplug
beetsplug.__path__ = paths + list(beetsplug.__path__)
beetsplug.__path__ = paths + beetsplug.__path__
# For backwards compatibility, also support plugin paths that
# *contain* a `beetsplug` package.
sys.path += paths
# If we were given any plugins on the command line, use those.
if options.plugins is not None:
plugin_list = (options.plugins.split(',')
if len(options.plugins) > 0 else [])
else:
plugin_list = config['plugins'].as_str_seq()
plugins.load_plugins(plugin_list)
plugins.load_plugins(config['plugins'].as_str_seq())
plugins.send("pluginload")
return plugins
@@ -1142,20 +1097,7 @@ def _setup(options, lib=None):
config = _configure(options)
plugins = _load_plugins(options, config)
# Add types and queries defined by plugins.
plugin_types_album = plugins.types(library.Album)
library.Album._types.update(plugin_types_album)
item_types = plugin_types_album.copy()
item_types.update(library.Item._types)
item_types.update(plugins.types(library.Item))
library.Item._types = item_types
library.Item._queries.update(plugins.named_queries(library.Item))
library.Album._queries.update(plugins.named_queries(library.Album))
plugins.send("pluginload")
plugins = _load_plugins(config)
# Get the default subcommands.
from beets.ui.commands import default_commands
@@ -1166,6 +1108,8 @@ def _setup(options, lib=None):
if lib is None:
lib = _open_library(config)
plugins.send("library_opened", lib=lib)
library.Item._types.update(plugins.types(library.Item))
library.Album._types.update(plugins.types(library.Album))
return subcommands, plugins, lib
@@ -1177,11 +1121,9 @@ def _configure(options):
# special handling lets specified plugins get loaded before we
# finish parsing the command line.
if getattr(options, 'config', None) is not None:
overlay_path = options.config
config_path = options.config
del options.config
config.set_file(overlay_path)
else:
overlay_path = None
config.set_file(config_path)
config.set_args(options)
# Configure the logger.
@@ -1190,19 +1132,15 @@ def _configure(options):
else:
log.set_global_level(logging.INFO)
if overlay_path:
log.debug('overlaying configuration: {0}',
util.displayable_path(overlay_path))
config_path = config.user_config_path()
if os.path.isfile(config_path):
log.debug('user configuration: {0}',
log.debug(u'user configuration: {0}',
util.displayable_path(config_path))
else:
log.debug('no user configuration found at {0}',
log.debug(u'no user configuration found at {0}',
util.displayable_path(config_path))
log.debug('data directory: {0}',
log.debug(u'data directory: {0}',
util.displayable_path(config.config_dir()))
return config
@@ -1219,14 +1157,13 @@ def _open_library(config):
get_replacements(),
)
lib.get_item(0) # Test database connection.
except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error:
log.debug('{}', traceback.format_exc())
raise UserError("database file {} cannot not be opened: {}".format(
util.displayable_path(dbpath),
db_error
except (sqlite3.OperationalError, sqlite3.DatabaseError):
log.debug(u'{}', traceback.format_exc())
raise UserError(u"database file {0} could not be opened".format(
util.displayable_path(dbpath)
))
log.debug('library database: {0}\n'
'library directory: {1}',
log.debug(u'library database: {0}\n'
u'library directory: {1}',
util.displayable_path(lib.path),
util.displayable_path(lib.directory))
return lib
@@ -1240,17 +1177,15 @@ def _raw_main(args, lib=None):
parser.add_format_option(flags=('--format-item',), target=library.Item)
parser.add_format_option(flags=('--format-album',), target=library.Album)
parser.add_option('-l', '--library', dest='library',
help='library database file to use')
help=u'library database file to use')
parser.add_option('-d', '--directory', dest='directory',
help="destination music directory")
help=u"destination music directory")
parser.add_option('-v', '--verbose', dest='verbose', action='count',
help='log more details (use twice for even more)')
help=u'log more details (use twice for even more)')
parser.add_option('-c', '--config', dest='config',
help='path to configuration file')
parser.add_option('-p', '--plugins', dest='plugins',
help='a comma-separated list of plugins to load')
help=u'path to configuration file')
parser.add_option('-h', '--help', dest='help', action='store_true',
help='show this help message and exit')
help=u'show this help message and exit')
parser.add_option('--version', dest='version', action='store_true',
help=optparse.SUPPRESS_HELP)
@@ -1285,7 +1220,7 @@ def main(args=None):
_raw_main(args)
except UserError as exc:
message = exc.args[0] if exc.args else None
log.error('error: {0}', message)
log.error(u'error: {0}', message)
sys.exit(1)
except util.HumanReadableException as exc:
exc.log(log)
@@ -1296,25 +1231,18 @@ def main(args=None):
log.debug('{}', traceback.format_exc())
log.error('{}', exc)
sys.exit(1)
except confuse.ConfigError as exc:
log.error('configuration error: {0}', exc)
except confit.ConfigError as exc:
log.error(u'configuration error: {0}', exc)
sys.exit(1)
except db_query.InvalidQueryError as exc:
log.error('invalid query: {0}', exc)
log.error(u'invalid query: {0}', exc)
sys.exit(1)
except OSError as exc:
except IOError as exc:
if exc.errno == errno.EPIPE:
# "Broken pipe". End silently.
sys.stderr.close()
pass
else:
raise
except KeyboardInterrupt:
# Silently ignore ^C except in verbose mode.
log.debug('{}', traceback.format_exc())
except db.DBAccessError as exc:
log.error(
'database access error: {0}\n'
'the library file might have a permissions problem',
exc
)
sys.exit(1)
log.debug(u'{}', traceback.format_exc())
+326 -436
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+5 -5
View File
@@ -70,7 +70,7 @@ _beet_dispatch() {
# Replace command shortcuts
if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then
eval "cmd=\$alias__${cmd//-/_}"
eval "cmd=\$alias__$cmd"
fi
case $cmd in
@@ -94,8 +94,8 @@ _beet_dispatch() {
_beet_complete() {
if [[ $cur == -* ]]; then
local opts flags completions
eval "opts=\$opts__${cmd//-/_}"
eval "flags=\$flags__${cmd//-/_}"
eval "opts=\$opts__$cmd"
eval "flags=\$flags__$cmd"
completions="${flags___common} ${opts} ${flags}"
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
else
@@ -129,7 +129,7 @@ _beet_complete_global() {
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then
local cmd
eval "cmd=\$alias__${cur//-/_}"
eval "cmd=\$alias__$cur"
COMPREPLY+=( "$cmd" )
else
COMPREPLY+=( $(compgen -W "$commands" -- $cur) )
@@ -138,7 +138,7 @@ _beet_complete_global() {
_beet_complete_query() {
local opts
eval "opts=\$opts__${cmd//-/_}"
eval "opts=\$opts__$cmd"
if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then
_beet_complete
Regular → Executable
+100 -210
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -14,28 +15,27 @@
"""Miscellaneous utility functions."""
from __future__ import division, absolute_import, print_function
import os
import sys
import errno
import locale
import re
import tempfile
import shutil
import fnmatch
import functools
from collections import Counter, namedtuple
from multiprocessing.pool import ThreadPool
from collections import Counter
import traceback
import subprocess
import platform
import shlex
from beets.util import hidden
import six
from unidecode import unidecode
from enum import Enum
MAX_FILENAME_LENGTH = 200
WINDOWS_MAGIC_PREFIX = '\\\\?\\'
WINDOWS_MAGIC_PREFIX = u'\\\\?\\'
SNI_SUPPORTED = sys.version_info >= (2, 7, 9)
class HumanReadableException(Exception):
@@ -57,27 +57,27 @@ class HumanReadableException(Exception):
self.reason = reason
self.verb = verb
self.tb = tb
super().__init__(self.get_message())
super(HumanReadableException, self).__init__(self.get_message())
def _gerund(self):
"""Generate a (likely) gerund form of the English verb.
"""
if ' ' in self.verb:
if u' ' in self.verb:
return self.verb
gerund = self.verb[:-1] if self.verb.endswith('e') else self.verb
gerund += 'ing'
gerund = self.verb[:-1] if self.verb.endswith(u'e') else self.verb
gerund += u'ing'
return gerund
def _reasonstr(self):
"""Get the reason as a string."""
if isinstance(self.reason, str):
if isinstance(self.reason, six.text_type):
return self.reason
elif isinstance(self.reason, bytes):
return self.reason.decode('utf-8', 'ignore')
elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError
return self.reason.strerror
else:
return '"{}"'.format(str(self.reason))
return u'"{0}"'.format(six.text_type(self.reason))
def get_message(self):
"""Create the human-readable description of the error, sans
@@ -91,7 +91,7 @@ class HumanReadableException(Exception):
"""
if self.tb:
logger.debug(self.tb)
logger.error('{0}: {1}', self.error_kind, self.args[0])
logger.error(u'{0}: {1}', self.error_kind, self.args[0])
class FilesystemError(HumanReadableException):
@@ -99,41 +99,29 @@ class FilesystemError(HumanReadableException):
via a function in this module. The `paths` field is a sequence of
pathnames involved in the operation.
"""
def __init__(self, reason, verb, paths, tb=None):
self.paths = paths
super().__init__(reason, verb, tb)
super(FilesystemError, self).__init__(reason, verb, tb)
def get_message(self):
# Use a nicer English phrasing for some specific verbs.
if self.verb in ('move', 'copy', 'rename'):
clause = 'while {} {} to {}'.format(
clause = u'while {0} {1} to {2}'.format(
self._gerund(),
displayable_path(self.paths[0]),
displayable_path(self.paths[1])
)
elif self.verb in ('delete', 'write', 'create', 'read'):
clause = 'while {} {}'.format(
clause = u'while {0} {1}'.format(
self._gerund(),
displayable_path(self.paths[0])
)
else:
clause = 'during {} of paths {}'.format(
self.verb, ', '.join(displayable_path(p) for p in self.paths)
clause = u'during {0} of paths {1}'.format(
self.verb, u', '.join(displayable_path(p) for p in self.paths)
)
return f'{self._reasonstr()} {clause}'
class MoveOperation(Enum):
"""The file operations that e.g. various move functions can carry out.
"""
MOVE = 0
COPY = 1
LINK = 2
HARDLINK = 3
REFLINK = 4
REFLINK_AUTO = 5
return u'{0} {1}'.format(self._reasonstr(), clause)
def normpath(path):
@@ -184,7 +172,7 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
contents = os.listdir(syspath(path))
except OSError as exc:
if logger:
logger.warning('could not list directory {}: {}'.format(
logger.warning(u'could not list directory {0}: {1}'.format(
displayable_path(path), exc.strerror
))
return
@@ -197,10 +185,6 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
skip = False
for pat in ignore:
if fnmatch.fnmatch(base, pat):
if logger:
logger.debug('ignoring {} due to ignore rule {}'.format(
base, pat
))
skip = True
break
if skip:
@@ -223,14 +207,8 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
for base in dirs:
cur = os.path.join(path, base)
# yield from sorted_walk(...)
yield from sorted_walk(cur, ignore, ignore_hidden, logger)
def path_as_posix(path):
"""Return the string representation of the path with forward (/)
slashes.
"""
return path.replace(b'\\', b'/')
for res in sorted_walk(cur, ignore, ignore_hidden, logger):
yield res
def mkdirall(path):
@@ -241,7 +219,7 @@ def mkdirall(path):
if not os.path.isdir(syspath(ancestor)):
try:
os.mkdir(syspath(ancestor))
except OSError as exc:
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'create', (ancestor,),
traceback.format_exc())
@@ -294,13 +272,13 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
continue
clutter = [bytestring_path(c) for c in clutter]
match_paths = [bytestring_path(d) for d in os.listdir(directory)]
try:
if fnmatch_all(match_paths, clutter):
# Directory contains only clutter (or nothing).
if fnmatch_all(match_paths, clutter):
# Directory contains only clutter (or nothing).
try:
shutil.rmtree(directory)
else:
except OSError:
break
except OSError:
else:
break
@@ -379,18 +357,18 @@ def bytestring_path(path):
PATH_SEP = bytestring_path(os.sep)
def displayable_path(path, separator='; '):
def displayable_path(path, separator=u'; '):
"""Attempts to decode a bytestring path to a unicode object for the
purpose of displaying it to the user. If the `path` argument is a
list or a tuple, the elements are joined with `separator`.
"""
if isinstance(path, (list, tuple)):
return separator.join(displayable_path(p) for p in path)
elif isinstance(path, str):
elif isinstance(path, six.text_type):
return path
elif not isinstance(path, bytes):
# A non-string object: just get its unicode representation.
return str(path)
return six.text_type(path)
try:
return path.decode(_fsencoding(), 'ignore')
@@ -409,7 +387,7 @@ def syspath(path, prefix=True):
if os.path.__name__ != 'ntpath':
return path
if not isinstance(path, str):
if not isinstance(path, six.text_type):
# Beets currently represents Windows paths internally with UTF-8
# arbitrarily. But earlier versions used MBCS because it is
# reported as the FS encoding by Windows. Try both.
@@ -422,11 +400,11 @@ def syspath(path, prefix=True):
path = path.decode(encoding, 'replace')
# Add the magic prefix if it isn't already there.
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
if path.startswith('\\\\'):
if path.startswith(u'\\\\'):
# UNC path. Final path should look like \\?\UNC\...
path = 'UNC' + path[1:]
path = u'UNC' + path[1:]
path = WINDOWS_MAGIC_PREFIX + path
return path
@@ -434,8 +412,6 @@ def syspath(path, prefix=True):
def samefile(p1, p2):
"""Safer equality for paths."""
if p1 == p2:
return True
return shutil._samefile(syspath(p1), syspath(p2))
@@ -448,7 +424,7 @@ def remove(path, soft=True):
return
try:
os.remove(path)
except OSError as exc:
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'delete', (path,), traceback.format_exc())
@@ -463,10 +439,10 @@ def copy(path, dest, replace=False):
path = syspath(path)
dest = syspath(dest)
if not replace and os.path.exists(dest):
raise FilesystemError('file exists', 'copy', (path, dest))
raise FilesystemError(u'file exists', 'copy', (path, dest))
try:
shutil.copyfile(path, dest)
except OSError as exc:
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'copy', (path, dest),
traceback.format_exc())
@@ -479,37 +455,24 @@ def move(path, dest, replace=False):
instead, in which case metadata will *not* be preserved. Paths are
translated to system paths.
"""
if os.path.isdir(path):
raise FilesystemError(u'source is directory', 'move', (path, dest))
if os.path.isdir(dest):
raise FilesystemError(u'destination is directory', 'move',
(path, dest))
if samefile(path, dest):
return
path = syspath(path)
dest = syspath(dest)
if os.path.exists(dest) and not replace:
raise FilesystemError('file exists', 'rename', (path, dest))
raise FilesystemError(u'file exists', 'rename', (path, dest))
# First, try renaming the file.
try:
os.replace(path, dest)
os.rename(path, dest)
except OSError:
tmp = tempfile.mktemp(suffix='.beets',
prefix=py3_path(b'.' + os.path.basename(dest)),
dir=py3_path(os.path.dirname(dest)))
tmp = syspath(tmp)
# Otherwise, copy and delete the original.
try:
shutil.copyfile(path, tmp)
os.replace(tmp, dest)
tmp = None
shutil.copyfile(path, dest)
os.remove(path)
except OSError as exc:
except (OSError, IOError) as exc:
raise FilesystemError(exc, 'move', (path, dest),
traceback.format_exc())
finally:
if tmp is not None:
os.remove(tmp)
def link(path, dest, replace=False):
@@ -521,18 +484,18 @@ def link(path, dest, replace=False):
return
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError('file exists', 'rename', (path, dest))
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
os.symlink(syspath(path), syspath(dest))
except NotImplementedError:
# raised on python >= 3.2 and Windows versions before Vista
raise FilesystemError('OS does not support symbolic links.'
raise FilesystemError(u'OS does not support symbolic links.'
'link', (path, dest), traceback.format_exc())
except OSError as exc:
# TODO: Windows version checks can be removed for python 3
if hasattr('sys', 'getwindowsversion'):
if sys.getwindowsversion()[0] < 6: # is before Vista
exc = 'OS does not support symbolic links.'
exc = u'OS does not support symbolic links.'
raise FilesystemError(exc, 'link', (path, dest),
traceback.format_exc())
@@ -546,50 +509,21 @@ def hardlink(path, dest, replace=False):
return
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError('file exists', 'rename', (path, dest))
raise FilesystemError(u'file exists', 'rename', (path, dest))
try:
os.link(syspath(path), syspath(dest))
except NotImplementedError:
raise FilesystemError('OS does not support hard links.'
raise FilesystemError(u'OS does not support hard links.'
'link', (path, dest), traceback.format_exc())
except OSError as exc:
if exc.errno == errno.EXDEV:
raise FilesystemError('Cannot hard link across devices.'
raise FilesystemError(u'Cannot hard link across devices.'
'link', (path, dest), traceback.format_exc())
else:
raise FilesystemError(exc, 'link', (path, dest),
traceback.format_exc())
def reflink(path, dest, replace=False, fallback=False):
"""Create a reflink from `dest` to `path`.
Raise an `OSError` if `dest` already exists, unless `replace` is
True. If `path` == `dest`, then do nothing.
If reflinking fails and `fallback` is enabled, try copying the file
instead. Otherwise, raise an error without trying a plain copy.
May raise an `ImportError` if the `reflink` module is not available.
"""
import reflink as pyreflink
if samefile(path, dest):
return
if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError('file exists', 'rename', (path, dest))
try:
pyreflink.reflink(path, dest)
except (NotImplementedError, pyreflink.ReflinkImpossibleError):
if fallback:
copy(path, dest, replace)
else:
raise FilesystemError('OS/filesystem does not support reflinks.',
'link', (path, dest), traceback.format_exc())
def unique_path(path):
"""Returns a version of ``path`` that does not exist on the
filesystem. Specifically, if ``path` itself already exists, then
@@ -607,23 +541,22 @@ def unique_path(path):
num = 0
while True:
num += 1
suffix = f'.{num}'.encode() + ext
suffix = u'.{}'.format(num).encode() + ext
new_path = base + suffix
if not os.path.exists(new_path):
return new_path
# Note: The Windows "reserved characters" are, of course, allowed on
# Unix. They are forbidden here because they cause problems on Samba
# shares, which are sufficiently common as to cause frequent problems.
# https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
CHAR_REPLACE = [
(re.compile(r'[\\/]'), '_'), # / and \ -- forbidden everywhere.
(re.compile(r'^\.'), '_'), # Leading dot (hidden files on Unix).
(re.compile(r'[\x00-\x1f]'), ''), # Control characters.
(re.compile(r'[<>:"\?\*\|]'), '_'), # Windows "reserved characters".
(re.compile(r'\.$'), '_'), # Trailing dots.
(re.compile(r'\s+$'), ''), # Trailing whitespace.
(re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere.
(re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix).
(re.compile(r'[\x00-\x1f]'), u''), # Control characters.
(re.compile(r'[<>:"\?\*\|]'), u'_'), # Windows "reserved characters".
(re.compile(r'\.$'), u'_'), # Trailing dots.
(re.compile(r'\s+$'), u''), # Trailing whitespace.
]
@@ -747,29 +680,36 @@ def py3_path(path):
it is. So this function helps us "smuggle" the true bytes data
through APIs that took Python 3's Unicode mandate too seriously.
"""
if isinstance(path, str):
if isinstance(path, six.text_type):
return path
assert isinstance(path, bytes)
if six.PY2:
return path
return os.fsdecode(path)
def str2bool(value):
"""Returns a boolean reflecting a human-entered string."""
return value.lower() in ('yes', '1', 'true', 't', 'y')
return value.lower() in (u'yes', u'1', u'true', u't', u'y')
def as_string(value):
"""Convert a value to a Unicode object for matching with a query.
None becomes the empty string. Bytestrings are silently decoded.
"""
if six.PY2:
buffer_types = buffer, memoryview # noqa: F821
else:
buffer_types = memoryview
if value is None:
return ''
elif isinstance(value, memoryview):
return u''
elif isinstance(value, buffer_types):
return bytes(value).decode('utf-8', 'ignore')
elif isinstance(value, bytes):
return value.decode('utf-8', 'ignore')
else:
return str(value)
return six.text_type(value)
def text_string(value, encoding='utf-8'):
@@ -792,7 +732,7 @@ def plurality(objs):
"""
c = Counter(objs)
if not c:
raise ValueError('sequence must be non-empty')
raise ValueError(u'sequence must be non-empty')
return c.most_common(1)[0]
@@ -809,11 +749,7 @@ def cpu_count():
num = 0
elif sys.platform == 'darwin':
try:
num = int(command_output([
'/usr/sbin/sysctl',
'-n',
'hw.ncpu',
]).stdout)
num = int(command_output(['/usr/sbin/sysctl', '-n', 'hw.ncpu']))
except (ValueError, OSError, subprocess.CalledProcessError):
num = 0
else:
@@ -833,23 +769,20 @@ def convert_command_args(args):
assert isinstance(args, list)
def convert(arg):
if isinstance(arg, bytes):
arg = arg.decode(arg_encoding(), 'surrogateescape')
if six.PY2:
if isinstance(arg, six.text_type):
arg = arg.encode(arg_encoding())
else:
if isinstance(arg, bytes):
arg = arg.decode(arg_encoding(), 'surrogateescape')
return arg
return [convert(a) for a in args]
# stdout and stderr as bytes
CommandOutput = namedtuple("CommandOutput", ("stdout", "stderr"))
def command_output(cmd, shell=False):
"""Runs the command and returns its output after it has exited.
Returns a CommandOutput. The attributes ``stdout`` and ``stderr`` contain
byte strings of the respective output streams.
``cmd`` is a list of arguments starting with the command names. The
arguments are bytes on Unix and strings on Windows.
If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a
@@ -864,16 +797,10 @@ def command_output(cmd, shell=False):
"""
cmd = convert_command_args(cmd)
try: # python >= 3.3
devnull = subprocess.DEVNULL
except AttributeError:
devnull = open(os.devnull, 'r+b')
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=devnull,
close_fds=platform.system() != 'Windows',
shell=shell
)
@@ -884,7 +811,7 @@ def command_output(cmd, shell=False):
cmd=' '.join(cmd),
output=stdout + stderr,
)
return CommandOutput(stdout, stderr)
return stdout
def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
@@ -931,6 +858,25 @@ def editor_command():
return open_anything()
def shlex_split(s):
"""Split a Unicode or bytes string according to shell lexing rules.
Raise `ValueError` if the string is not a well-formed shell string.
This is a workaround for a bug in some versions of Python.
"""
if not six.PY2 or isinstance(s, bytes): # Shlex works fine.
return shlex.split(s)
elif isinstance(s, six.text_type):
# Work around a Python bug.
# http://bugs.python.org/issue6988
bs = s.encode('utf-8')
return [c.decode('utf-8') for c in shlex.split(bs)]
else:
raise TypeError(u'shlex_split called with non-string')
def interactive_open(targets, command):
"""Open the files in `targets` by `exec`ing a new `command`, given
as a Unicode string. (The new program takes over, and Python
@@ -942,7 +888,7 @@ def interactive_open(targets, command):
# Split the command string into its arguments.
try:
args = shlex.split(command)
args = shlex_split(command)
except ValueError: # Malformed shell tokens.
args = [command]
@@ -957,7 +903,7 @@ def _windows_long_path_name(short_path):
"""Use Windows' `GetLongPathNameW` via ctypes to get the canonical,
long path given a short filename.
"""
if not isinstance(short_path, str):
if not isinstance(short_path, six.text_type):
short_path = short_path.decode(_fsencoding())
import ctypes
@@ -1018,7 +964,7 @@ def raw_seconds_short(string):
"""
match = re.match(r'^(\d+):([0-5]\d)$', string)
if not match:
raise ValueError('String not in M:SS format')
raise ValueError(u'String not in M:SS format')
minutes, seconds = map(int, match.groups())
return float(minutes * 60 + seconds)
@@ -1045,59 +991,3 @@ def asciify_path(path, sep_replace):
sep_replace
)
return os.sep.join(path_components)
def par_map(transform, items):
"""Apply the function `transform` to all the elements in the
iterable `items`, like `map(transform, items)` but with no return
value. The map *might* happen in parallel: it's parallel on Python 3
and sequential on Python 2.
The parallelism uses threads (not processes), so this is only useful
for IO-bound `transform`s.
"""
pool = ThreadPool()
pool.map(transform, items)
pool.close()
pool.join()
def lazy_property(func):
"""A decorator that creates a lazily evaluated property. On first access,
the property is assigned the return value of `func`. This first value is
stored, so that future accesses do not have to evaluate `func` again.
This behaviour is useful when `func` is expensive to evaluate, and it is
not certain that the result will be needed.
"""
field_name = '_' + func.__name__
@property
@functools.wraps(func)
def wrapper(self):
if hasattr(self, field_name):
return getattr(self, field_name)
value = func(self)
setattr(self, field_name, value)
return value
return wrapper
def decode_commandline_path(path):
"""Prepare a path for substitution into commandline template.
On Python 3, we need to construct the subprocess commands to invoke as a
Unicode string. On Unix, this is a little unfortunate---the OS is
expecting bytes---so we use surrogate escaping and decode with the
argument encoding, which is the same encoding that will then be
*reversed* to recover the same bytes before invoking the OS. On
Windows, we want to preserve the Unicode filename "as is."
"""
# On Python 3, the template is a Unicode string, which only supports
# substitution of Unicode variables.
if platform.system() == 'Windows':
return path.decode(_fsencoding())
else:
return path.decode(arg_encoding(), 'surrogateescape')
Regular → Executable
+75 -305
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Fabrice Laporte
#
@@ -15,39 +16,38 @@
"""Abstraction layer to resize images using PIL, ImageMagick, or a
public resizing proxy if neither is available.
"""
from __future__ import division, absolute_import, print_function
import subprocess
import os
import os.path
import re
from tempfile import NamedTemporaryFile
from urllib.parse import urlencode
from six.moves.urllib.parse import urlencode
from beets import logging
from beets import util
import six
# Resizing methods
PIL = 1
IMAGEMAGICK = 2
WEBPROXY = 3
PROXY_URL = 'https://images.weserv.nl/'
if util.SNI_SUPPORTED:
PROXY_URL = 'https://images.weserv.nl/'
else:
PROXY_URL = 'http://images.weserv.nl/'
log = logging.getLogger('beets')
def resize_url(url, maxwidth, quality=0):
def resize_url(url, maxwidth):
"""Return a proxied image URL that resizes the original image to
maxwidth (preserving aspect ratio).
"""
params = {
return '{0}?{1}'.format(PROXY_URL, urlencode({
'url': url.replace('http://', ''),
'w': maxwidth,
}
if quality > 0:
params['q'] = quality
return '{}?{}'.format(PROXY_URL, urlencode(params))
}))
def temp_file_for(path):
@@ -59,102 +59,49 @@ def temp_file_for(path):
return util.bytestring_path(f.name)
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
def pil_resize(maxwidth, path_in, path_out=None):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
path_out = path_out or temp_file_for(path_in)
from PIL import Image
log.debug('artresizer: PIL resizing {0} to {1}',
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))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
if quality == 0:
# Use PIL's default quality.
quality = -1
# progressive=False only affects JPEGs and is the default,
# but we include it here for explicitness.
im.save(util.py3_path(path_out), quality=quality, progressive=False)
if max_filesize > 0:
# If maximum filesize is set, we attempt to lower the quality of
# jpeg conversion by a proportional amount, up to 3 attempts
# First, set the maximum quality to either provided, or 95
if quality > 0:
lower_qual = quality
else:
lower_qual = 95
for i in range(5):
# 5 attempts is an abitrary choice
filesize = os.stat(util.syspath(path_out)).st_size
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
if filesize <= max_filesize:
return path_out
# The relationship between filesize & quality will be
# image dependent.
lower_qual -= 10
# Restrict quality dropping below 10
if lower_qual < 10:
lower_qual = 10
# Use optimize flag to improve filesize decrease
im.save(util.py3_path(path_out), quality=lower_qual,
optimize=True, progressive=False)
log.warning("PIL Failed to resize file to below {0}B",
max_filesize)
return path_out
else:
return path_out
except OSError:
log.error("PIL cannot create thumbnail for '{0}'",
im.save(path_out)
return path_out
except IOError:
log.error(u"PIL cannot create thumbnail for '{0}'",
util.displayable_path(path_in))
return path_in
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using ImageMagick.
Use the ``magick`` program or ``convert`` on older versions. Return
the output path of resized image.
def im_resize(maxwidth, path_in, path_out=None):
"""Resize using ImageMagick's ``convert`` tool.
Return the output path of resized image.
"""
path_out = path_out or temp_file_for(path_in)
log.debug('artresizer: ImageMagick resizing {0} to {1}',
log.debug(u'artresizer: ImageMagick resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out))
# "-resize WIDTHx>" shrinks images with the width larger
# than the given width while maintaining the aspect ratio
# with regards to the height.
# ImageMagick already seems to default to no interlace, but we include it
# here for the sake of explicitness.
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(path_in, prefix=False),
'-resize', f'{maxwidth}x>',
'-interlace', 'none',
]
if quality > 0:
cmd += ['-quality', f'{quality}']
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to
# SIZE in bytes.
if max_filesize > 0:
cmd += ['-define', f'jpeg:extent={max_filesize}b']
cmd.append(util.syspath(path_out, prefix=False))
# "-resize widthxheight>" shrinks images with dimension(s) larger
# than the corresponding width and/or height dimension(s). The >
# "only shrink" flag is prefixed by ^ escape char for Windows
# compatibility.
try:
util.command_output(cmd)
util.command_output([
'convert', util.syspath(path_in, prefix=False),
'-resize', '{0}x^>'.format(maxwidth),
util.syspath(path_out, prefix=False),
])
except subprocess.CalledProcessError:
log.warning('artresizer: IM convert failed for {0}',
log.warning(u'artresizer: IM convert failed for {0}',
util.displayable_path(path_in))
return path_in
return path_out
@@ -166,33 +113,31 @@ BACKEND_FUNCS = {
def pil_getsize(path_in):
from PIL import Image
try:
im = Image.open(util.syspath(path_in))
return im.size
except OSError as exc:
log.error("PIL could not read file {}: {}",
except IOError as exc:
log.error(u"PIL could not read file {}: {}",
util.displayable_path(path_in), exc)
def im_getsize(path_in):
cmd = ArtResizer.shared.im_identify_cmd + \
['-format', '%w %h', util.syspath(path_in, prefix=False)]
cmd = ['identify', '-format', '%w %h',
util.syspath(path_in, prefix=False)]
try:
out = util.command_output(cmd).stdout
out = util.command_output(cmd)
except subprocess.CalledProcessError as exc:
log.warning('ImageMagick size query failed')
log.warning(u'ImageMagick size query failed')
log.debug(
'`convert` exited with (status {}) when '
'getting size with command {}:\n{}',
u'`convert` exited with (status {}) when '
u'getting size with command {}:\n{}',
exc.returncode, cmd, exc.output.strip()
)
return
try:
return tuple(map(int, out.split(b' ')))
except IndexError:
log.warning('Could not understand IM output: {0!r}', out)
log.warning(u'Could not understand IM output: {0!r}', out)
BACKEND_GET_SIZE = {
@@ -201,125 +146,24 @@ BACKEND_GET_SIZE = {
}
def pil_deinterlace(path_in, path_out=None):
path_out = path_out or temp_file_for(path_in)
from PIL import Image
try:
im = Image.open(util.syspath(path_in))
im.save(util.py3_path(path_out), progressive=False)
return path_out
except IOError:
return path_in
def im_deinterlace(path_in, path_out=None):
path_out = path_out or temp_file_for(path_in)
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(path_in, prefix=False),
'-interlace', 'none',
util.syspath(path_out, prefix=False),
]
try:
util.command_output(cmd)
return path_out
except subprocess.CalledProcessError:
return path_in
DEINTERLACE_FUNCS = {
PIL: pil_deinterlace,
IMAGEMAGICK: im_deinterlace,
}
def im_get_format(filepath):
cmd = ArtResizer.shared.im_identify_cmd + [
'-format', '%[magick]',
util.syspath(filepath)
]
try:
return util.command_output(cmd).stdout
except subprocess.CalledProcessError:
return None
def pil_get_format(filepath):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(util.syspath(filepath)) as im:
return im.format
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
log.exception("failed to detect image format for {}", filepath)
return None
BACKEND_GET_FORMAT = {
PIL: pil_get_format,
IMAGEMAGICK: im_get_format,
}
def im_convert_format(source, target, deinterlaced):
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(source),
*(["-interlace", "none"] if deinterlaced else []),
util.syspath(target),
]
try:
subprocess.check_call(
cmd,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL
)
return target
except subprocess.CalledProcessError:
return source
def pil_convert_format(source, target, deinterlaced):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(util.syspath(source)) as im:
im.save(util.py3_path(target), progressive=not deinterlaced)
return target
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
OSError):
log.exception("failed to convert image {} -> {}", source, target)
return source
BACKEND_CONVERT_IMAGE_FORMAT = {
PIL: pil_convert_format,
IMAGEMAGICK: im_convert_format,
}
class Shareable(type):
"""A pseudo-singleton metaclass that allows both shared and
non-shared instances. The ``MyClass.shared`` property holds a
lazily-created shared instance of ``MyClass`` while calling
``MyClass()`` to construct a new object works as usual.
"""
def __init__(cls, name, bases, dict):
super().__init__(name, bases, dict)
cls._instance = None
def __init__(self, name, bases, dict):
super(Shareable, self).__init__(name, bases, dict)
self._instance = None
@property
def shared(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def shared(self):
if self._instance is None:
self._instance = self()
return self._instance
class ArtResizer(metaclass=Shareable):
class ArtResizer(six.with_metaclass(Shareable, object)):
"""A singleton class that performs image resizes.
"""
@@ -327,44 +171,21 @@ class ArtResizer(metaclass=Shareable):
"""Create a resizer object with an inferred method.
"""
self.method = self._check_method()
log.debug("artresizer: method is {0}", self.method)
log.debug(u"artresizer: method is {0}", self.method)
self.can_compare = self._can_compare()
# Use ImageMagick's magick binary when it's available. If it's
# not, fall back to the older, separate convert and identify
# commands.
if self.method[0] == IMAGEMAGICK:
self.im_legacy = self.method[2]
if self.im_legacy:
self.im_convert_cmd = ['convert']
self.im_identify_cmd = ['identify']
else:
self.im_convert_cmd = ['magick']
self.im_identify_cmd = ['magick', 'identify']
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
def resize(self, maxwidth, path_in, path_out=None):
"""Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file and encodes with the specified quality level.
For WEBPROXY, returns `path_in` unmodified.
temporary file. For WEBPROXY, returns `path_in` unmodified.
"""
if self.local:
func = BACKEND_FUNCS[self.method[0]]
return func(maxwidth, path_in, path_out,
quality=quality, max_filesize=max_filesize)
return func(maxwidth, path_in, path_out)
else:
return path_in
def deinterlace(self, path_in, path_out=None):
if self.local:
func = DEINTERLACE_FUNCS[self.method[0]]
return func(path_in, path_out)
else:
return path_in
def proxy_url(self, maxwidth, url, quality=0):
def proxy_url(self, maxwidth, url):
"""Modifies an image URL according the method, returning a new
URL. For WEBPROXY, a URL on the proxy server is returned.
Otherwise, the URL is returned unmodified.
@@ -372,7 +193,7 @@ class ArtResizer(metaclass=Shareable):
if self.local:
return url
else:
return resize_url(url, maxwidth, quality)
return resize_url(url, maxwidth)
@property
def local(self):
@@ -385,50 +206,12 @@ class ArtResizer(metaclass=Shareable):
"""Return the size of an image file as an int couple (width, height)
in pixels.
Only available locally.
Only available locally
"""
if self.local:
func = BACKEND_GET_SIZE[self.method[0]]
return func(path_in)
def get_format(self, path_in):
"""Returns the format of the image as a string.
Only available locally.
"""
if self.local:
func = BACKEND_GET_FORMAT[self.method[0]]
return func(path_in)
def reformat(self, path_in, new_format, deinterlaced=True):
"""Converts image to desired format, updating its extension, but
keeping the same filename.
Only available locally.
"""
if not self.local:
return path_in
new_format = new_format.lower()
# A nonexhaustive map of image "types" to extensions overrides
new_format = {
'jpeg': 'jpg',
}.get(new_format, new_format)
fname, ext = os.path.splitext(path_in)
path_new = fname + b'.' + new_format.encode('utf8')
func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]]
# allows the exception to propagate, while still making sure a changed
# file path was removed
result_path = path_in
try:
result_path = func(path_in, path_new, deinterlaced)
finally:
if result_path != path_in:
os.unlink(path_in)
return result_path
def _can_compare(self):
"""A boolean indicating whether image comparison is available"""
@@ -436,20 +219,10 @@ class ArtResizer(metaclass=Shareable):
@staticmethod
def _check_method():
"""Return a tuple indicating an available method and its version.
The result has at least two elements:
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK.
- The version.
If the method is IMAGEMAGICK, there is also a third element: a
bool flag indicating whether to use the `magick` binary or
legacy single-purpose executables (`convert`, `identify`, etc.)
"""
"""Return a tuple indicating an available method and its version."""
version = get_im_version()
if version:
version, legacy = version
return IMAGEMAGICK, version, legacy
return IMAGEMAGICK, version
version = get_pil_version()
if version:
@@ -459,34 +232,31 @@ class ArtResizer(metaclass=Shareable):
def get_im_version():
"""Get the ImageMagick version and legacy flag as a pair. Or return
None if ImageMagick is not available.
"""Return Image Magick version or None if it is unavailable
Try invoking ImageMagick's "convert".
"""
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
cmd = cmd_name + ['--version']
try:
out = util.command_output(['convert', '--version'])
try:
out = util.command_output(cmd).stdout
except (subprocess.CalledProcessError, OSError) as exc:
log.debug('ImageMagick version check failed: {}', exc)
else:
if b'imagemagick' in out.lower():
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
match = re.search(pattern, out)
if match:
version = (int(match.group(1)),
int(match.group(2)),
int(match.group(3)))
return version, legacy
if b'imagemagick' in out.lower():
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
match = re.search(pattern, out)
if match:
return (int(match.group(1)),
int(match.group(2)),
int(match.group(3)))
return (0,)
return None
except (subprocess.CalledProcessError, OSError) as exc:
log.debug(u'ImageMagick check `convert --version` failed: {}', exc)
return None
def get_pil_version():
"""Get the PIL/Pillow version, or None if it is unavailable.
"""
"""Return Image Magick version or None if it is unavailable
Try importing PIL."""
try:
__import__('PIL', fromlist=['Image'])
__import__('PIL', fromlist=[str('Image')])
return (0,)
except ImportError:
return None
Regular → Executable
+14 -14
View File
@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
"""Extremely simple pure-Python implementation of coroutine-style
asynchronous socket I/O. Inspired by, but inferior to, Eventlet.
Bluelet can also be thought of as a less-terrible replacement for
@@ -5,7 +7,9 @@ asyncore.
Bluelet: easy concurrency without all the messy parallelism.
"""
from __future__ import division, absolute_import, print_function
import six
import socket
import select
import sys
@@ -18,7 +22,7 @@ import collections
# Basic events used for thread scheduling.
class Event:
class Event(object):
"""Just a base class identifying Bluelet events. An event is an
object yielded from a Bluelet thread coroutine to suspend operation
and communicate with the scheduler.
@@ -197,7 +201,7 @@ class ThreadException(Exception):
self.exc_info = exc_info
def reraise(self):
raise self.exc_info[1].with_traceback(self.exc_info[2])
six.reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2])
SUSPENDED = Event() # Special sentinel placeholder for suspended threads.
@@ -265,7 +269,7 @@ def run(root_coro):
except StopIteration:
# Thread is done.
complete_thread(coro, None)
except BaseException:
except:
# Thread raised some other exception.
del threads[coro]
raise ThreadException(coro, sys.exc_info())
@@ -332,20 +336,16 @@ def run(root_coro):
break
# Wait and fire.
event2coro = {v: k for k, v in threads.items()}
event2coro = dict((v, k) for k, v in threads.items())
for event in _event_select(threads.values()):
# Run the IO operation, but catch socket errors.
try:
value = event.fire()
except OSError as exc:
except socket.error as exc:
if isinstance(exc.args, tuple) and \
exc.args[0] == errno.EPIPE:
# Broken pipe. Remote host disconnected.
pass
elif isinstance(exc.args, tuple) and \
exc.args[0] == errno.ECONNRESET:
# Connection was reset by peer.
pass
else:
traceback.print_exc()
# Abort the coroutine.
@@ -366,7 +366,7 @@ def run(root_coro):
exit_te = te
break
except BaseException:
except:
# For instance, KeyboardInterrupt during select(). Raise
# into root thread and terminate others.
threads = {root_coro: ExceptionEvent(sys.exc_info())}
@@ -386,7 +386,7 @@ class SocketClosedError(Exception):
pass
class Listener:
class Listener(object):
"""A socket wrapper object for listening sockets.
"""
def __init__(self, host, port):
@@ -416,7 +416,7 @@ class Listener:
self.sock.close()
class Connection:
class Connection(object):
"""A socket wrapper object for connected sockets.
"""
def __init__(self, sock, addr):
@@ -541,7 +541,7 @@ def spawn(coro):
and child coroutines run concurrently.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError('%s is not a coroutine' % coro)
raise ValueError(u'%s is not a coroutine' % coro)
return SpawnEvent(coro)
@@ -551,7 +551,7 @@ def call(coro):
returns a value using end(), then this event returns that value.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError('%s is not a coroutine' % coro)
raise ValueError(u'%s is not a coroutine' % coro)
return DelegationEvent(coro)
Regular → Executable
+1442 -11
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+2
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -12,6 +13,7 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
from enum import Enum
Regular → Executable
+87 -89
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -26,30 +27,30 @@ This is sort of like a tiny, horrible degeneration of a real templating
engine like Jinja2 or Mustache.
"""
from __future__ import division, absolute_import, print_function
import re
import ast
import dis
import types
import sys
import functools
import six
SYMBOL_DELIM = '$'
FUNC_DELIM = '%'
GROUP_OPEN = '{'
GROUP_CLOSE = '}'
ARG_SEP = ','
ESCAPE_CHAR = '$'
SYMBOL_DELIM = u'$'
FUNC_DELIM = u'%'
GROUP_OPEN = u'{'
GROUP_CLOSE = u'}'
ARG_SEP = u','
ESCAPE_CHAR = u'$'
VARIABLE_PREFIX = '__var_'
FUNCTION_PREFIX = '__func_'
class Environment:
class Environment(object):
"""Contains the values and functions to be substituted into a
template.
"""
def __init__(self, values, functions):
self.values = values
self.functions = functions
@@ -71,7 +72,15 @@ def ex_literal(val):
"""An int, float, long, bool, string, or None literal with the given
value.
"""
return ast.Constant(val)
if val is None:
return ast.Name('None', ast.Load())
elif isinstance(val, six.integer_types):
return ast.Num(val)
elif isinstance(val, bool):
return ast.Name(bytes(val), ast.Load())
elif isinstance(val, six.string_types):
return ast.Str(val)
raise TypeError(u'no literal for {0}'.format(type(val)))
def ex_varassign(name, expr):
@@ -88,7 +97,7 @@ def ex_call(func, args):
function may be an expression or the name of a function. Each
argument may be an expression or a value to be used as a literal.
"""
if isinstance(func, str):
if isinstance(func, six.string_types):
func = ex_rvalue(func)
args = list(args)
@@ -96,7 +105,10 @@ def ex_call(func, args):
if not isinstance(args[i], ast.expr):
args[i] = ex_literal(args[i])
return ast.Call(func, args, [])
if sys.version_info[:2] < (3, 5):
return ast.Call(func, args, [], None, None)
else:
return ast.Call(func, args, [])
def compile_func(arg_names, statements, name='_the_func', debug=False):
@@ -104,30 +116,32 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
the resulting Python function. If `debug`, then print out the
bytecode of the compiled function.
"""
args_fields = {
'args': [ast.arg(arg=n, annotation=None) for n in arg_names],
'kwonlyargs': [],
'kw_defaults': [],
'defaults': [ex_literal(None) for _ in arg_names],
}
if 'posonlyargs' in ast.arguments._fields: # Added in Python 3.8.
args_fields['posonlyargs'] = []
args = ast.arguments(**args_fields)
func_def = ast.FunctionDef(
name=name,
args=args,
body=statements,
decorator_list=[],
)
# The ast.Module signature changed in 3.8 to accept a list of types to
# ignore.
if sys.version_info >= (3, 8):
mod = ast.Module([func_def], [])
if six.PY2:
func_def = ast.FunctionDef(
name=name.encode('utf-8'),
args=ast.arguments(
args=[ast.Name(n, ast.Param()) for n in arg_names],
vararg=None,
kwarg=None,
defaults=[ex_literal(None) for _ in arg_names],
),
body=statements,
decorator_list=[],
)
else:
mod = ast.Module([func_def])
func_def = ast.FunctionDef(
name=name,
args=ast.arguments(
args=[ast.arg(arg=n, annotation=None) for n in arg_names],
kwonlyargs=[],
kw_defaults=[],
defaults=[ex_literal(None) for _ in arg_names],
),
body=statements,
decorator_list=[],
)
mod = ast.Module([func_def])
ast.fix_missing_locations(mod)
prog = compile(mod, '<generated>', 'exec')
@@ -146,15 +160,14 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
# AST nodes for the template language.
class Symbol:
class Symbol(object):
"""A variable-substitution symbol in a template."""
def __init__(self, ident, original):
self.ident = ident
self.original = original
def __repr__(self):
return 'Symbol(%s)' % repr(self.ident)
return u'Symbol(%s)' % repr(self.ident)
def evaluate(self, env):
"""Evaluate the symbol in the environment, returning a Unicode
@@ -169,22 +182,24 @@ class Symbol:
def translate(self):
"""Compile the variable lookup."""
ident = self.ident
if six.PY2:
ident = self.ident.encode('utf-8')
else:
ident = self.ident
expr = ex_rvalue(VARIABLE_PREFIX + ident)
return [expr], {ident}, set()
return [expr], set([ident]), set()
class Call:
class Call(object):
"""A function call in a template."""
def __init__(self, ident, args, original):
self.ident = ident
self.args = args
self.original = original
def __repr__(self):
return 'Call({}, {}, {})'.format(repr(self.ident), repr(self.args),
repr(self.original))
return u'Call(%s, %s, %s)' % (repr(self.ident), repr(self.args),
repr(self.original))
def evaluate(self, env):
"""Evaluate the function call in the environment, returning a
@@ -197,15 +212,19 @@ class Call:
except Exception as exc:
# Function raised exception! Maybe inlining the name of
# the exception will help debug.
return '<%s>' % str(exc)
return str(out)
return u'<%s>' % six.text_type(exc)
return six.text_type(out)
else:
return self.original
def translate(self):
"""Compile the function call."""
varnames = set()
funcnames = {self.ident}
if six.PY2:
ident = self.ident.encode('utf-8')
else:
ident = self.ident
funcnames = set([ident])
arg_exprs = []
for arg in self.args:
@@ -216,33 +235,32 @@ class Call:
# Create a subexpression that joins the result components of
# the arguments.
arg_exprs.append(ex_call(
ast.Attribute(ex_literal(''), 'join', ast.Load()),
ast.Attribute(ex_literal(u''), 'join', ast.Load()),
[ex_call(
'map',
[
ex_rvalue(str.__name__),
ex_rvalue(six.text_type.__name__),
ast.List(subexprs, ast.Load()),
]
)],
))
subexpr_call = ex_call(
FUNCTION_PREFIX + self.ident,
FUNCTION_PREFIX + ident,
arg_exprs
)
return [subexpr_call], varnames, funcnames
class Expression:
class Expression(object):
"""Top-level template construct: contains a list of text blobs,
Symbols, and Calls.
"""
def __init__(self, parts):
self.parts = parts
def __repr__(self):
return 'Expression(%s)' % (repr(self.parts))
return u'Expression(%s)' % (repr(self.parts))
def evaluate(self, env):
"""Evaluate the entire expression in the environment, returning
@@ -250,11 +268,11 @@ class Expression:
"""
out = []
for part in self.parts:
if isinstance(part, str):
if isinstance(part, six.string_types):
out.append(part)
else:
out.append(part.evaluate(env))
return ''.join(map(str, out))
return u''.join(map(six.text_type, out))
def translate(self):
"""Compile the expression to a list of Python AST expressions, a
@@ -264,7 +282,7 @@ class Expression:
varnames = set()
funcnames = set()
for part in self.parts:
if isinstance(part, str):
if isinstance(part, six.string_types):
expressions.append(ex_literal(part))
else:
e, v, f = part.translate()
@@ -280,7 +298,7 @@ class ParseError(Exception):
pass
class Parser:
class Parser(object):
"""Parses a template expression string. Instantiate the class with
the template source and call ``parse_expression``. The ``pos`` field
will indicate the character after the expression finished and
@@ -293,7 +311,6 @@ class Parser:
replaced with a real, accepted parsing technique (PEG, parser
generator, etc.).
"""
def __init__(self, string, in_argument=False):
""" Create a new parser.
:param in_arguments: boolean that indicates the parser is to be
@@ -308,8 +325,8 @@ class Parser:
# Common parsing resources.
special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE,
ESCAPE_CHAR)
special_char_re = re.compile(r'[%s]|\Z' %
''.join(re.escape(c) for c in special_chars))
special_char_re = re.compile(r'[%s]|$' %
u''.join(re.escape(c) for c in special_chars))
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
terminator_chars = (GROUP_CLOSE,)
@@ -326,11 +343,8 @@ class Parser:
if self.in_argument:
extra_special_chars = (ARG_SEP,)
special_char_re = re.compile(
r'[%s]|\Z' % ''.join(
re.escape(c) for c in
self.special_chars + extra_special_chars
)
)
r'[%s]|$' % u''.join(re.escape(c) for c in
self.special_chars + extra_special_chars))
text_parts = []
@@ -370,7 +384,7 @@ class Parser:
# Shift all characters collected so far into a single string.
if text_parts:
self.parts.append(''.join(text_parts))
self.parts.append(u''.join(text_parts))
text_parts = []
if char == SYMBOL_DELIM:
@@ -392,7 +406,7 @@ class Parser:
# If any parsed characters remain, shift them into a string.
if text_parts:
self.parts.append(''.join(text_parts))
self.parts.append(u''.join(text_parts))
def parse_symbol(self):
"""Parse a variable reference (like ``$foo`` or ``${foo}``)
@@ -530,27 +544,11 @@ def _parse(template):
return Expression(parts)
def cached(func):
"""Like the `functools.lru_cache` decorator, but works (as a no-op)
on Python < 3.2.
"""
if hasattr(functools, 'lru_cache'):
return functools.lru_cache(maxsize=128)(func)
else:
# Do nothing when lru_cache is not available.
return func
@cached
def template(fmt):
return Template(fmt)
# External interface.
class Template:
class Template(object):
"""A string template, including text, Symbols, and Calls.
"""
def __init__(self, template):
self.expr = _parse(template)
self.original = template
@@ -572,7 +570,7 @@ class Template:
"""
try:
res = self.compiled(values, functions)
except Exception: # Handle any exceptions thrown by compiled version.
except: # Handle any exceptions thrown by compiled version.
res = self.interpret(values, functions)
return res
@@ -599,7 +597,7 @@ class Template:
for funcname in funcnames:
args[FUNCTION_PREFIX + funcname] = functions[funcname]
parts = func(**args)
return ''.join(parts)
return u''.join(parts)
return wrapper_func
@@ -608,9 +606,9 @@ class Template:
if __name__ == '__main__':
import timeit
_tmpl = Template('foo $bar %baz{foozle $bar barzle} $bar')
_tmpl = Template(u'foo $bar %baz{foozle $bar barzle} $bar')
_vars = {'bar': 'qux'}
_funcs = {'baz': str.upper}
_funcs = {'baz': six.text_type.upper}
interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)',
'from __main__ import _tmpl, _vars, _funcs',
number=10000)
@@ -619,4 +617,4 @@ if __name__ == '__main__':
'from __main__ import _tmpl, _vars, _funcs',
number=10000)
print(comp_time)
print('Speedup:', interp_time / comp_time)
print(u'Speedup:', interp_time / comp_time)
Regular → Executable
+2
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -13,6 +14,7 @@
# included in all copies or substantial portions of the Software.
"""Simple library to work out if a file is hidden on different platforms."""
from __future__ import division, absolute_import, print_function
import os
import stat
Regular → Executable
+28 -30
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -31,10 +32,12 @@ To do so, pass an iterable of coroutines to the Pipeline constructor
in place of any single coroutine.
"""
from __future__ import division, absolute_import, print_function
import queue
from six.moves import queue
from threading import Thread, Lock
import sys
import six
BUBBLE = '__PIPELINE_BUBBLE__'
POISON = '__PIPELINE_POISON__'
@@ -88,7 +91,6 @@ class CountedQueue(queue.Queue):
still feeding into it. The queue is poisoned when all threads are
finished with the queue.
"""
def __init__(self, maxsize=0):
queue.Queue.__init__(self, maxsize)
self.nthreads = 0
@@ -133,11 +135,10 @@ class CountedQueue(queue.Queue):
_invalidate_queue(self, POISON, False)
class MultiMessage:
class MultiMessage(object):
"""A message yielded by a pipeline stage encapsulating multiple
values to be sent to the next stage.
"""
def __init__(self, messages):
self.messages = messages
@@ -209,9 +210,8 @@ def _allmsgs(obj):
class PipelineThread(Thread):
"""Abstract base class for pipeline-stage threads."""
def __init__(self, all_threads):
super().__init__()
super(PipelineThread, self).__init__()
self.abort_lock = Lock()
self.abort_flag = False
self.all_threads = all_threads
@@ -241,13 +241,15 @@ class FirstPipelineThread(PipelineThread):
"""The thread running the first stage in a parallel pipeline setup.
The coroutine should just be a generator.
"""
def __init__(self, coro, out_queue, all_threads):
super().__init__(all_threads)
super(FirstPipelineThread, self).__init__(all_threads)
self.coro = coro
self.out_queue = out_queue
self.out_queue.acquire()
self.abort_lock = Lock()
self.abort_flag = False
def run(self):
try:
while True:
@@ -268,7 +270,7 @@ class FirstPipelineThread(PipelineThread):
return
self.out_queue.put(msg)
except BaseException:
except:
self.abort_all(sys.exc_info())
return
@@ -280,9 +282,8 @@ class MiddlePipelineThread(PipelineThread):
"""A thread running any stage in the pipeline except the first or
last.
"""
def __init__(self, coro, in_queue, out_queue, all_threads):
super().__init__(all_threads)
super(MiddlePipelineThread, self).__init__(all_threads)
self.coro = coro
self.in_queue = in_queue
self.out_queue = out_queue
@@ -317,7 +318,7 @@ class MiddlePipelineThread(PipelineThread):
return
self.out_queue.put(msg)
except BaseException:
except:
self.abort_all(sys.exc_info())
return
@@ -329,9 +330,8 @@ class LastPipelineThread(PipelineThread):
"""A thread running the last stage in a pipeline. The coroutine
should yield nothing.
"""
def __init__(self, coro, in_queue, all_threads):
super().__init__(all_threads)
super(LastPipelineThread, self).__init__(all_threads)
self.coro = coro
self.in_queue = in_queue
@@ -357,23 +357,22 @@ class LastPipelineThread(PipelineThread):
# Send to consumer.
self.coro.send(msg)
except BaseException:
except:
self.abort_all(sys.exc_info())
return
class Pipeline:
class Pipeline(object):
"""Represents a staged pattern of work. Each stage in the pipeline
is a coroutine that receives messages from the previous stage and
yields messages to be sent to the next stage.
"""
def __init__(self, stages):
"""Makes a new pipeline from a list of coroutines. There must
be at least two stages.
"""
if len(stages) < 2:
raise ValueError('pipeline must have at least two stages')
raise ValueError(u'pipeline must have at least two stages')
self.stages = []
for stage in stages:
if isinstance(stage, (list, tuple)):
@@ -426,7 +425,7 @@ class Pipeline:
while threads[-1].is_alive():
threads[-1].join(1)
except BaseException:
except:
# Stop all the threads immediately.
for thread in threads:
thread.abort()
@@ -443,7 +442,7 @@ class Pipeline:
exc_info = thread.exc_info
if exc_info:
# Make the exception appear as it was raised originally.
raise exc_info[1].with_traceback(exc_info[2])
six.reraise(exc_info[0], exc_info[1], exc_info[2])
def pull(self):
"""Yield elements from the end of the pipeline. Runs the stages
@@ -470,7 +469,6 @@ class Pipeline:
for msg in msgs:
yield msg
# Smoke test.
if __name__ == '__main__':
import time
@@ -479,14 +477,14 @@ if __name__ == '__main__':
# in parallel.
def produce():
for i in range(5):
print('generating %i' % i)
print(u'generating %i' % i)
time.sleep(1)
yield i
def work():
num = yield
while True:
print('processing %i' % num)
print(u'processing %i' % num)
time.sleep(2)
num = yield num * 2
@@ -494,7 +492,7 @@ if __name__ == '__main__':
while True:
num = yield
time.sleep(1)
print('received %i' % num)
print(u'received %i' % num)
ts_start = time.time()
Pipeline([produce(), work(), consume()]).run_sequential()
@@ -503,22 +501,22 @@ if __name__ == '__main__':
ts_par = time.time()
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
ts_end = time.time()
print('Sequential time:', ts_seq - ts_start)
print('Parallel time:', ts_par - ts_seq)
print('Multiply-parallel time:', ts_end - ts_par)
print(u'Sequential time:', ts_seq - ts_start)
print(u'Parallel time:', ts_par - ts_seq)
print(u'Multiply-parallel time:', ts_end - ts_par)
print()
# Test a pipeline that raises an exception.
def exc_produce():
for i in range(10):
print('generating %i' % i)
print(u'generating %i' % i)
time.sleep(1)
yield i
def exc_work():
num = yield
while True:
print('processing %i' % num)
print(u'processing %i' % num)
time.sleep(3)
if num == 3:
raise Exception()
@@ -527,6 +525,6 @@ if __name__ == '__main__':
def exc_consume():
while True:
num = yield
print('received %i' % num)
print(u'received %i' % num)
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
Regular → Executable
+2
View File
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
@@ -15,6 +16,7 @@
"""A simple utility for constructing filesystem-like trees from beets
libraries.
"""
from __future__ import division, absolute_import, print_function
from collections import namedtuple
from beets import util
+1 -2
View File
@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
# Copyright 2013, 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 @@
"""A namespace package for beets plugins."""
# Make this a namespace package.
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)

Some files were not shown because too many files have changed in this diff Show More