Compare commits

...
46 Commits
Author SHA1 Message Date
rembo10 a78f38c174 Update changelog for v0.6.0 2022-11-13 09:39:20 +05:30
rembo10 14f2a6d22c Merge branch 'develop' 2022-11-13 09:34:39 +05:30
rembo10 2e4299efa7 Merge branch 'lastfm-api-key' into develop 2022-11-13 09:31:24 +05:30
rembo10 0610c2fa93 Fix formatting of Last.fm in log messages 2022-11-12 16:45:43 +05:30
rembo10 9add571886 Re-enable Last.fm similar tag cloud 2022-11-12 16:44:01 +05:30
rembo10 fcf59a9b38 add an option for a personal last.fm api key 2022-11-12 15:48:31 +05:30
AdeHub 74f9e91afc Various
xld - use native plistlib instead of biplist

rutracker - use new Result object

seacher - small tweaks

transmission - str no longer required
2022-03-05 19:08:12 +13:00
rembo10 83398cb102 metadata: Fix typo in DISC_TOTAL 2022-02-18 15:04:42 +05:30
rembo10 61c2e1f821 Add option to ignore disc# for single disc albums (#3297) 2022-02-18 11:59:15 +05:30
rembo10 3e3047aef2 Use str(e) instead of e.message in searcher.py 2022-02-15 15:11:27 +05:30
rembo10 fff44e4631 Various fixes from last commit, fixes an issue where the hash couldn't be calculated from the torrent data 2022-02-15 15:06:26 +05:30
rembo10 0964371de8 Require python 3.7+ for dataclasses 2022-02-15 14:28:44 +05:30
rembo10 654f923a8d A little cleanup in searcher.py 2022-02-15 14:14:06 +05:30
rembo10 b91206c64a Remove tests from libs 2022-02-14 13:43:44 +05:30
rembo10 c9ba59ee9a Add zipp lib 2022-02-14 13:42:40 +05:30
rembo10 b7e35d5ff0 Decode the b64 data to utf-8 for nzbget (#3294) 2022-02-14 10:14:44 +05:30
rembo10 9d82143abe Remove errant print statements 2022-02-14 08:51:39 +05:30
rembo10 eaf2db6c59 Open blackhole nzb file as binary 2022-02-14 08:12:47 +05:30
rembo10 586b9ed3c8 Add importlib_resources to lib 2022-02-14 07:45:09 +05:30
rembo10 d89f4171da Disable interpolation in ConfigParser 2022-02-13 10:13:47 +05:30
rembo10 9f7be5348b Prevent accessing error.message in db.py action 2022-02-11 10:42:11 +05:30
rembo10 9c254ff222 Fix for trying to access e.message in findArtist 2022-02-11 10:34:44 +05:30
rembo10 ba969fd3b8 Fix for sending invalid dates to helpers.age 2022-02-10 07:37:49 +05:30
rembo10 c851d5ed1a Remove ordereddict 2022-02-09 21:24:20 +05:30
rembo10 2223928958 Fix sort by have in web ui, a little import cleanup in webserve.py 2022-02-09 21:10:14 +05:30
rembo10 164c3cacbc Disable last.fm getSimilar in importer 2022-02-09 17:51:12 +05:30
rembo10 16d4ac8895 Fixed some imports in helpers.py 2022-02-09 02:48:50 +05:30
rembo10 f4d60226b3 Disable last.fm getSimilar 2022-02-08 19:37:33 +05:30
rembo10 9ca87e23b2 A less insane library scan interval 2022-02-08 19:27:44 +05:30
rembo10 d934c865c6 Fix for adding albums with no release date 2022-02-08 18:41:24 +05:30
rembo10 de74cd2502 Fix typo in librarysync: track['Location'] 2022-02-08 16:24:11 +05:30
rembo10 f41db714a9 Remove any quotes from str/path values in the config 2022-02-08 16:14:06 +05:30
rembo10 f03b82e5f6 Update mutagen to 1.45.1 2022-02-07 09:30:17 +05:30
rembo10 e2db680b9e Catch NoneType in helpers.py/multikeysort 2022-02-07 08:07:18 +05:30
rembo10 a3db89c11d Update minimum version 2022-02-04 07:42:57 +05:30
rembo10 517d0eb327 Merge branch 'develop' 2022-02-01 20:11:24 +05:30
rembo10 2bacd5a0fc Merge branch 'https' into develop 2022-01-23 14:23:42 +05:30
rembo10 5a559c526d http -> https for album art with small refactor 2022-01-23 14:23:27 +05:30
rembo10 095cee9368 http -> https for internet calls 2022-01-23 14:08:36 +05:30
rembo10 79cb133d1d Remove non-ascii chars in nzb folder name 2022-01-22 08:34:47 +05:30
rembo10 3a9b749017 Merge branch 'develop' 2022-01-22 08:11:01 +05:30
rembo10 0182be2f27 Merge pull request #3284 from hypsometric/py3
Fixes for Python 3.10
2022-01-22 08:01:58 +05:30
hypsometric b6388f7daa Fixes for Python 3.10
- deluge: b64encode returns bytes, transform into string before
  serializing to JSON.
- deluge: no more need to try different encodings for the torrent
  filename or the base64-encoded content, should all be unicode strings
  with Python 3
- collections.abc: Iterable, Mapping, MutableMapping, MutableSequence
  moved from collections to collections.abc since Python 3.3
- pygazelle: html.parser.HTMLParser().unescape() moved to
  html.unescape() in Python 3.4
2022-01-19 18:21:16 +01:00
rembo10 0b3d0242fc headphones/__init__.py: file() -> open() 2022-01-15 21:47:45 +05:30
rembo10 9551e1b04a Update pytz lib 2022-01-15 21:46:34 +05:30
rembo10 ab4dd18be4 Initial python3 changes
Mostly just updating libraries, removing string encoding/decoding,
fixing some edge cases. No new functionality was added in this
commit.
2022-01-14 10:38:06 +05:30
1440 changed files with 153397 additions and 68606 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## v0.6.0
Released 13 November 2022
Highlights:
* Updated to python 3
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.20...v0.6.0).
## v0.5.20 ## v0.5.20
Released 15 October 2021 Released 15 October 2021
+4
View File
@@ -17,6 +17,10 @@
import os import os
import sys import sys
if sys.version_info <= (3, 6):
sys.stdout.write("Headphones requires Python >= 3.7\n")
sys.exit(1)
# Ensure lib added to path, before any other imports # Ensure lib added to path, before any other imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib/')) 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. # Ensure that we use the Headphones provided libraries.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../lib"))
import urlparse import urllib.parse
def can_import(module): def can_import(module):
@@ -89,7 +89,7 @@ def main():
url = sys.argv[1] url = sys.argv[1]
# Check if it is a HTTPS website. # Check if it is a HTTPS website.
parts = urlparse.urlparse(url) parts = urllib.parse.urlparse(url)
if parts.scheme.lower() != "https": if parts.scheme.lower() != "https":
sys.stderr.write( sys.stderr.write(
+18 -5
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> <input type="checkbox" name="synoindex_enabled" id="synoindex" value="1" ${config['synoindex_enabled']} /><label for="synoindex"><span class="option">Synology NAS</span></label>
</div> </div>
</fieldset> </fieldset>
<!--
<fieldset> <fieldset>
<div class="row checkbox left"> <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> <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>
</div> </div>
</fieldset> </fieldset>
-->
<fieldset> <fieldset>
<div class="row checkbox left"> <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> <input type="checkbox" class="bigcheck" name="slack_enabled" id="slack" value="1" ${config['slack_enabled']} /><label for="slack"><span class="option">Slack</span></label>
@@ -1370,17 +1370,20 @@
<div class="row"> <div class="row">
<label>File Format</label> <label>File Format</label>
<input type="text" name="file_format" value="${config['file_format']}" size="43"> <input type="text" name="file_format" value="${config['file_format']}" size="43">
<small>Use: $Disc/$disc (disc #), $Track/$track (track #), $Title/$title, $Artist/$artist, $Album/$album and $Year/$year. Put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').</small> <small>Use: In addition to the above, there is also $Title/$title (track title), $Track (track #), $Disc (disc #), $DiscTotal.</small>
</div> </div>
<div class="checkbox row clearfix"> <div class="checkbox row left clearfix nopad">
<input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label> <input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label>
</div> </div>
<div class="checkbox row left clearfix nopad">
<input type="checkbox" name="rename_single_disc_ignore" id="rename_single_disc_ignore" value="1" ${config['rename_single_disc_ignore']}/><label>Don't include disc# for single disc albums</label>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Re-Encoding Options</legend> <legend>Re-Encoding Options</legend>
<small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small> <small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small>
<div class="checkbox row clearfix"> <div class="checkbox row left clearfix nopad">
<input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label> <input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label>
</div> </div>
<div id="encoderoptions" class="row clearfix checkbox"> <div id="encoderoptions" class="row clearfix checkbox">
@@ -1651,6 +1654,16 @@
</div> </div>
</fieldset> </fieldset>
<fieldset>
<legend>Last.fm</legend>
<div id="lastfmoptions">
<div class="row">
<label>API Key</label>
<input type="text" name="lastfm_apikey" value="${config['lastfm_apikey']}" size="40" />
</div>
</div>
</fieldset>
<fieldset> <fieldset>
<legend>Songkick</legend> <legend>Songkick</legend>
<div class="row checkbox"> <div class="row checkbox">
+4 -4
View File
@@ -1,7 +1,7 @@
<%inherit file="base.html"/> <%inherit file="base.html"/>
<%! <%!
from headphones import helpers from headphones import helpers
import cgi from html import escape as html_escape
%> %>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
@@ -62,11 +62,11 @@
%> %>
<tr class="grade${grade}"> <tr class="grade${grade}">
<td id="dateadded">${item['DateAdded']}</td> <td id="dateadded">${item['DateAdded']}</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="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="size">${helpers.bytes_to_mb(item['Size'])}</td> <td id="size">${helpers.bytes_to_mb(item['Size'])}</td>
<td title="${folder}" id="status">${item['Status']}</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 '${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="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=${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> <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>
</tr> </tr>
%endfor %endfor
</tbody> </tbody>
+4 -4
View File
@@ -218,7 +218,7 @@ def daemonize():
pid = os.fork() # @UndefinedVariable - only available in UNIX pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0: if pid != 0:
sys.exit(0) sys.exit(0)
except OSError, e: except OSError as e:
raise RuntimeError("1st fork failed: %s [%d]", e.strerror, e.errno) raise RuntimeError("1st fork failed: %s [%d]", e.strerror, e.errno)
os.setsid() os.setsid()
@@ -232,10 +232,10 @@ def daemonize():
pid = os.fork() # @UndefinedVariable - only available in UNIX pid = os.fork() # @UndefinedVariable - only available in UNIX
if pid != 0: if pid != 0:
sys.exit(0) sys.exit(0)
except OSError, e: except OSError as e:
raise RuntimeError("2nd fork failed: %s [%d]", e.strerror, e.errno) raise RuntimeError("2nd fork failed: %s [%d]", e.strerror, e.errno)
dev_null = file('/dev/null', 'r') dev_null = open('/dev/null', 'r')
os.dup2(dev_null.fileno(), sys.stdin.fileno()) os.dup2(dev_null.fileno(), sys.stdin.fileno())
si = open('/dev/null', "r") si = open('/dev/null', "r")
@@ -251,7 +251,7 @@ def daemonize():
if CREATEPID: if CREATEPID:
logger.info("Writing PID %d to %s", pid, PIDFILE) logger.info("Writing PID %d to %s", pid, PIDFILE)
with file(PIDFILE, 'w') as fp: with open(PIDFILE, 'w') as fp:
fp.write("%s\n" % pid) fp.write("%s\n" % pid)
+15 -8
View File
@@ -28,7 +28,7 @@ def getAlbumArt(albumid):
# CAA # CAA
logger.info("Searching for artwork at CAA") logger.info("Searching for artwork at CAA")
artwork_path = 'http://coverartarchive.org/release-group/%s/front' % albumid artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
artwork = getartwork(artwork_path) artwork = getartwork(artwork_path)
if artwork: if artwork:
logger.info("Artwork found at CAA") logger.info("Artwork found at CAA")
@@ -41,7 +41,7 @@ def getAlbumArt(albumid):
'SELECT ArtistName, AlbumTitle, ReleaseID, AlbumASIN FROM albums WHERE AlbumID=?', 'SELECT ArtistName, AlbumTitle, ReleaseID, AlbumASIN FROM albums WHERE AlbumID=?',
[albumid]).fetchone() [albumid]).fetchone()
if dbalbum['AlbumASIN']: if dbalbum['AlbumASIN']:
artwork_path = 'http://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % dbalbum['AlbumASIN'] artwork_path = 'https://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % dbalbum['AlbumASIN']
artwork = getartwork(artwork_path) artwork = getartwork(artwork_path)
if artwork: if artwork:
logger.info("Artwork found at Amazon") logger.info("Artwork found at Amazon")
@@ -156,12 +156,19 @@ def getartwork(artwork_path):
break break
elif maxwidth and img_width > maxwidth: elif maxwidth and img_width > maxwidth:
# Downsize using proxy service to max width # 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() artwork = bytes()
r = request.request_response(artwork_path, timeout=20, stream=True, whitelist_status_code=404) 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
)
if r: if r:
for chunk in r.iter_content(chunk_size=1024): for chunk in r.iter_content(chunk_size=1024):
artwork += chunk artwork += chunk
@@ -182,7 +189,7 @@ def getCachedArt(albumid):
if not artwork_path: if not artwork_path:
return return
if artwork_path.startswith('http://'): if artwork_path.startswith("http"):
artwork = request.request_content(artwork_path, timeout=20) artwork = request.request_content(artwork_path, timeout=20)
if not artwork: if not artwork:
+7 -7
View File
@@ -86,7 +86,7 @@ class Api(object):
methodToCall = getattr(self, "_" + self.cmd) methodToCall = getattr(self, "_" + self.cmd)
methodToCall(**self.kwargs) methodToCall(**self.kwargs)
if 'callback' not in self.kwargs: if 'callback' not in self.kwargs:
if isinstance(self.data, basestring): if isinstance(self.data, str):
return self.data return self.data
else: else:
return json.dumps(self.data) return json.dumps(self.data)
@@ -106,7 +106,7 @@ class Api(object):
rows_as_dic = [] rows_as_dic = []
for row in rows: for row in rows:
row_as_dic = dict(zip(row.keys(), row)) row_as_dic = dict(list(zip(list(row.keys()), row)))
rows_as_dic.append(row_as_dic) rows_as_dic.append(row_as_dic)
return rows_as_dic return rows_as_dic
@@ -474,17 +474,17 @@ class Api(object):
# Handle situations where the torrent url contains arguments that are # Handle situations where the torrent url contains arguments that are
# parsed # parsed
if kwargs: if kwargs:
import urllib import urllib.request, urllib.parse, urllib.error
import urllib2 import urllib.request, urllib.error, urllib.parse
url = urllib2.quote( url = urllib.parse.quote(
url, safe=":?/=&") + '&' + urllib.urlencode(kwargs) url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
try: try:
result = [(title, int(size), url, provider, kind)] result = [(title, int(size), url, provider, kind)]
except ValueError: except ValueError:
result = [(title, float(size), url, provider, kind)] result = [(title, float(size), url, provider, kind)]
logger.info(u"Making sure we can download the chosen result") logger.info("Making sure we can download the chosen result")
(data, bestqual) = searcher.preprocess(result) (data, bestqual) = searcher.preprocess(result)
if data and bestqual: if data and bestqual:
+13 -8
View File
@@ -240,7 +240,7 @@ class Cache(object):
# fallback to 1st album cover if none of the above # fallback to 1st album cover if none of the above
elif 'albums' in data: elif 'albums' in data:
for mbid, art in data.get('albums', dict()).items(): for mbid, art in list(data.get('albums', dict()).items()):
if 'albumcover' in art: if 'albumcover' in art:
image_url = art['albumcover'][0]['url'] image_url = art['albumcover'][0]['url']
break break
@@ -352,7 +352,7 @@ class Cache(object):
# fallback to 1st album cover if none of the above # fallback to 1st album cover if none of the above
elif 'albums' in data: elif 'albums' in data:
for mbid, art in data.get('albums', dict()).items(): for mbid, art in list(data.get('albums', dict()).items()):
if 'albumcover' in art: if 'albumcover' in art:
image_url = art['albumcover'][0]['url'] image_url = art['albumcover'][0]['url']
break break
@@ -540,12 +540,17 @@ class Cache(object):
artwork_thumb = None artwork_thumb = None
if 'fanart' in thumb_url: if 'fanart' in thumb_url:
# Create thumb using image resizing service # Create thumb using image resizing service
artwork_path = '{0}?{1}'.format('http://images.weserv.nl/', urlencode({ url = "https://images.weserv.nl"
'url': thumb_url.replace('http://', ''), params = {
'w': 300, "url": thumb_url,
})) "w": 300
artwork_thumb = request.request_content(artwork_path, timeout=20, whitelist_status_code=404) }
artwork_thumb = request.request_content(
url,
params=params,
timeout=20,
whitelist_status_code=404
)
if artwork_thumb: if artwork_thumb:
with open(thumb_path, 'wb') as f: with open(thumb_path, 'wb') as f:
f.write(artwork_thumb) f.write(artwork_thumb)
+4 -4
View File
@@ -18,12 +18,12 @@
####################################### #######################################
import urllib import urllib.request, urllib.parse, urllib.error
from common import USER_AGENT from .common import USER_AGENT
class HeadphonesURLopener(urllib.FancyURLopener): class HeadphonesURLopener(urllib.request.FancyURLopener):
version = USER_AGENT version = USER_AGENT
@@ -44,7 +44,7 @@ class AuthURLOpener(HeadphonesURLopener):
self.numTries = 0 self.numTries = 0
# call the base class # call the base class
urllib.FancyURLopener.__init__(self) urllib.request.FancyURLopener.__init__(self)
def prompt_user_passwd(self, host, realm): def prompt_user_passwd(self, host, realm):
""" """
+7 -6
View File
@@ -24,6 +24,7 @@ import operator
import os import os
import re import re
from headphones import version from headphones import version
from functools import reduce
# Identify Our Application # Identify Our Application
@@ -74,7 +75,7 @@ class Quality:
@staticmethod @staticmethod
def _getStatusStrings(status): def _getStatusStrings(status):
toReturn = {} toReturn = {}
for x in Quality.qualityStrings.keys(): for x in list(Quality.qualityStrings.keys()):
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \ toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \
Quality.qualityStrings[x] + ")" Quality.qualityStrings[x] + ")"
return toReturn return toReturn
@@ -93,7 +94,7 @@ class Quality:
def splitQuality(quality): def splitQuality(quality):
anyQualities = [] anyQualities = []
bestQualities = [] bestQualities = []
for curQual in Quality.qualityStrings.keys(): for curQual in list(Quality.qualityStrings.keys()):
if curQual & quality: if curQual & quality:
anyQualities.append(curQual) anyQualities.append(curQual)
if curQual << 16 & quality: if curQual << 16 & quality:
@@ -151,7 +152,7 @@ class Quality:
@staticmethod @staticmethod
def splitCompositeStatus(status): def splitCompositeStatus(status):
"""Returns a tuple containing (status, quality)""" """Returns a tuple containing (status, quality)"""
for x in sorted(Quality.qualityStrings.keys(), reverse=True): for x in sorted(list(Quality.qualityStrings.keys()), reverse=True):
if status > x * 100: if status > x * 100:
return (status - x * 100, x) return (status - x * 100, x)
@@ -169,10 +170,10 @@ class Quality:
SNATCHED_PROPER = None SNATCHED_PROPER = None
Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()] Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in list(Quality.qualityStrings.keys())]
Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()] Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in list(Quality.qualityStrings.keys())]
Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in
Quality.qualityStrings.keys()] list(Quality.qualityStrings.keys())]
MP3 = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR], []) MP3 = Quality.combineQualities([Quality.B192, Quality.B256, Quality.B320, Quality.VBR], [])
LOSSLESS = Quality.combineQualities([Quality.FLAC], []) LOSSLESS = Quality.combineQualities([Quality.FLAC], [])
+38 -22
View File
@@ -2,15 +2,16 @@ import itertools
import os import os
import re import re
import ast
from configparser import ConfigParser
import headphones.logger import headphones.logger
from configobj import ConfigObj
def bool_int(value): def bool_int(value):
""" """
Casts a config value into a 0 or 1 Casts a config value into a 0 or 1
""" """
if isinstance(value, basestring): if isinstance(value, str):
if value.lower() in ('', '0', 'false', 'f', 'no', 'n', 'off'): if value.lower() in ('', '0', 'false', 'f', 'no', 'n', 'off'):
value = 0 value = 0
return int(bool(value)) return int(bool(value))
@@ -154,9 +155,10 @@ _CONFIG_DEFINITIONS = {
'KEEP_TORRENT_FILES': (int, 'General', 0), 'KEEP_TORRENT_FILES': (int, 'General', 0),
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''), 'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
'LASTFM_USERNAME': (str, 'General', ''), 'LASTFM_USERNAME': (str, 'General', ''),
'LASTFM_APIKEY': (str, 'General', ''),
'LAUNCH_BROWSER': (int, 'General', 1), 'LAUNCH_BROWSER': (int, 'General', 1),
'LIBRARYSCAN': (int, 'General', 1), 'LIBRARYSCAN': (int, 'General', 1),
'LIBRARYSCAN_INTERVAL': (int, 'General', 300), 'LIBRARYSCAN_INTERVAL': (int, 'General', 24),
'LMS_ENABLED': (int, 'LMS', 0), 'LMS_ENABLED': (int, 'LMS', 0),
'LMS_HOST': (str, 'LMS', ''), 'LMS_HOST': (str, 'LMS', ''),
'LOG_DIR': (path, 'General', ''), 'LOG_DIR': (path, 'General', ''),
@@ -239,6 +241,7 @@ _CONFIG_DEFINITIONS = {
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''), 'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''), 'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
'RENAME_FILES': (int, 'General', 0), 'RENAME_FILES': (int, 'General', 0),
'RENAME_SINGLE_DISC_IGNORE': (int, 'General', 0),
'RENAME_UNPROCESSED': (bool_int, 'General', 1), 'RENAME_UNPROCESSED': (bool_int, 'General', 1),
'RENAME_FROZEN': (bool_int, 'General', 1), 'RENAME_FROZEN': (bool_int, 'General', 1),
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0), 'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
@@ -326,8 +329,9 @@ class Config(object):
def __init__(self, config_file): def __init__(self, config_file):
""" Initialize the config with values from a file """ """ Initialize the config with values from a file """
self._config_file = config_file self._config_file = config_file
self._config = ConfigObj(self._config_file, encoding='utf-8') self._config = ConfigParser(interpolation=None)
for key in _CONFIG_DEFINITIONS.keys(): self._config.read(self._config_file)
for key in list(_CONFIG_DEFINITIONS.keys()):
self.check_setting(key) self.check_setting(key)
self.ENCODER_MULTICORE_COUNT = max(0, self.ENCODER_MULTICORE_COUNT) self.ENCODER_MULTICORE_COUNT = max(0, self.ENCODER_MULTICORE_COUNT)
self._upgrade() self._upgrade()
@@ -344,7 +348,7 @@ class Config(object):
def check_section(self, section): def check_section(self, section):
""" Check if INI section exists, if not create it """ """ Check if INI section exists, if not create it """
if section not in self._config: if not self._config.has_section(section):
self._config[section] = {} self._config[section] = {}
return True return True
else: else:
@@ -354,28 +358,38 @@ class Config(object):
""" Cast any value in the config to the right type or use the default """ """ Cast any value in the config to the right type or use the default """
key, definition_type, section, ini_key, default = self._define(key) key, definition_type, section, ini_key, default = self._define(key)
self.check_section(section) 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: try:
my_val = definition_type(self._config[section][ini_key]) my_val = definition_type(self._config[section][ini_key])
# ConfigParser interprets quotes 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 = 'some_val'
if type(my_val) in [str, path]:
my_val = my_val.strip('"').strip("'")
except Exception: except Exception:
my_val = definition_type(default) my_val = default
self._config[section][ini_key] = my_val self._config[section][ini_key] = str(my_val)
return my_val return my_val
def write(self): def write(self):
""" Make a copy of the stored config and write it to the configured file """ """ Make a copy of the stored config and write it to the configured file """
new_config = ConfigObj(encoding="UTF-8") new_config = ConfigParser(interpolation=None)
new_config.filename = self._config_file
# first copy over everything from the old config, even if it is not # first copy over everything from the old config, even if it is not
# correctly defined to keep from losing data # correctly defined to keep from losing data
for key, subkeys in self._config.items(): for key, subkeys in list(self._config.items()):
if key not in new_config: if key not in new_config:
new_config[key] = {} new_config[key] = {}
for subkey, value in subkeys.items(): for subkey, value in list(subkeys.items()):
new_config[key][subkey] = value new_config[key][subkey] = value
# next make sure that everything we expect to have defined is so # next make sure that everything we expect to have defined is so
for key in _CONFIG_DEFINITIONS.keys(): for key in list(_CONFIG_DEFINITIONS.keys()):
key, definition_type, section, ini_key, default = self._define(key) key, definition_type, section, ini_key, default = self._define(key)
self.check_setting(key) self.check_setting(key)
if section not in new_config: if section not in new_config:
@@ -386,14 +400,15 @@ class Config(object):
headphones.logger.info("Writing configuration to file") headphones.logger.info("Writing configuration to file")
try: try:
new_config.write() with open(self._config_file, 'w') as configfile:
new_config.write(configfile)
except IOError as e: except IOError as e:
headphones.logger.error("Error writing configuration file: %s", e) headphones.logger.error("Error writing configuration file: %s", e)
def get_extra_newznabs(self): def get_extra_newznabs(self):
""" Return the extra newznab tuples """ """ Return the extra newznab tuples """
extra_newznabs = list( extra_newznabs = list(
itertools.izip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3) zip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3)
for i in range(3)]) for i in range(3)])
) )
return extra_newznabs return extra_newznabs
@@ -412,7 +427,7 @@ class Config(object):
def get_extra_torznabs(self): def get_extra_torznabs(self):
""" Return the extra torznab tuples """ """ Return the extra torznab tuples """
extra_torznabs = list( extra_torznabs = list(
itertools.izip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4) zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4)
for i in range(4)]) for i in range(4)])
) )
return extra_torznabs return extra_torznabs
@@ -448,20 +463,21 @@ class Config(object):
return value return value
else: else:
key, definition_type, section, ini_key, default = self._define(name) key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = definition_type(value) self._config[section][ini_key] = str(value)
return self._config[section][ini_key] return self._config[section][ini_key]
def process_kwargs(self, kwargs): def process_kwargs(self, kwargs):
""" """
Given a big bunch of key value pairs, apply them to the ini. Given a big bunch of key value pairs, apply them to the ini.
""" """
for name, value in kwargs.items(): for name, value in list(kwargs.items()):
key, definition_type, section, ini_key, default = self._define(name) key, definition_type, section, ini_key, default = self._define(name)
self._config[section][ini_key] = definition_type(value) self._config[section][ini_key] = str(value)
def _upgrade(self): def _upgrade(self):
""" """
Bring old configs up to date 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
""" """
if self.CONFIG_VERSION == '2': if self.CONFIG_VERSION == '2':
# Update the config to use direct path to the encoder rather than the encoder folder # Update the config to use direct path to the encoder rather than the encoder folder
@@ -488,12 +504,12 @@ class Config(object):
# Add Seed Ratio to Torznabs # Add Seed Ratio to Torznabs
if self.EXTRA_TORZNABS: if self.EXTRA_TORZNABS:
extra_torznabs = list( extra_torznabs = list(
itertools.izip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3) zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
for i in range(3)]) for i in range(3)])
) )
new_torznabs = [] new_torznabs = []
for torznab in extra_torznabs: for torznab in extra_torznabs:
new_torznabs.extend([torznab[0], torznab[1], u'', torznab[2]]) new_torznabs.extend([torznab[0], torznab[1], '', torznab[2]])
if new_torznabs: if new_torznabs:
self.EXTRA_TORZNABS = new_torznabs self.EXTRA_TORZNABS = new_torznabs
+3 -3
View File
@@ -2,8 +2,8 @@ import mock
from mock import MagicMock from mock import MagicMock
import headphones.config import headphones.config
import re import re
import unittestcompat from . import unittestcompat
from unittestcompat import TestCase, TestArgs from .unittestcompat import TestCase, TestArgs
class ConfigApiTest(TestCase): class ConfigApiTest(TestCase):
@@ -101,7 +101,7 @@ class ConfigApiTest(TestCase):
# call methods # call methods
c = headphones.config.Config(path) c = headphones.config.Config(path)
# assertions: # assertions:
with self.assertRaisesRegexp(KeyError, exc_regex): with self.assertRaisesRegex(KeyError, exc_regex):
c.check_setting(setting_name) c.check_setting(setting_name)
pass pass
+1 -1
View File
@@ -21,7 +21,7 @@ def cry():
main_thread = t main_thread = t
# Loop over each thread's current frame, writing info about it # Loop over each thread's current frame, writing info about it
for tid, frame in sys._current_frames().iteritems(): for tid, frame in sys._current_frames().items():
thread = tmap.get(tid, main_thread) thread = tmap.get(tid, main_thread)
lines = [] lines = []
+10 -10
View File
@@ -87,7 +87,7 @@ def check_splitter(command):
def split_baby(split_file, split_cmd): def split_baby(split_file, split_cmd):
'''Let's split baby''' '''Let's split baby'''
logger.info('Splitting %s...', split_file.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Splitting {split_file}...")
logger.debug(subprocess.list2cmdline(split_cmd)) logger.debug(subprocess.list2cmdline(split_cmd))
# Prevent Windows from opening a terminal window # 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, process = subprocess.Popen(split_cmd, startupinfo=startupinfo,
stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE, stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env) stderr=subprocess.PIPE, env=env, text=True)
stdout, stderr = process.communicate() stdout, stderr = process.communicate()
if process.returncode: if process.returncode:
logger.error('Split failed for %s', split_file.decode(headphones.SYS_ENCODING, 'replace')) logger.error(f"Split failed for {split_file}")
out = stdout if stdout else stderr out = stdout or stderr
logger.error('Error details: %s', out.decode(headphones.SYS_ENCODING, 'replace')) logger.error(f"Error details: {out}")
return False return False
else: else:
logger.info('Split success %s', split_file.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Split succeeded for {split_file}")
return True return True
@@ -232,7 +232,7 @@ class Directory:
for i in list_dir: for i in list_dir:
if not check_match(i): if not check_match(i):
# music file # music file
if os.path.splitext(i)[-1] in WAVE_FILE_TYPE_BY_EXTENSION.keys(): if os.path.splitext(i)[-1] in list(WAVE_FILE_TYPE_BY_EXTENSION.keys()):
track_nr = identify_track_number(i) track_nr = identify_track_number(i)
if track_nr: if track_nr:
self.content.append(WaveFile(self.path + os.sep + i, track_nr=track_nr)) self.content.append(WaveFile(self.path + os.sep + i, track_nr=track_nr))
@@ -378,7 +378,7 @@ class CueFile(File):
except: except:
raise ValueError('Cant encode CUE Sheet.') raise ValueError('Cant encode CUE Sheet.')
if self.content[0] == u'\ufeff': if self.content[0] == '\ufeff':
self.content = self.content[1:] self.content = self.content[1:]
header = header_parser() header = header_parser()
@@ -581,7 +581,7 @@ def split(albumpath):
# use xld profile to split cue # use xld profile to split cue
if headphones.CONFIG.ENCODER == 'xld' and headphones.CONFIG.MUSIC_ENCODER and headphones.CONFIG.XLDPROFILE: if headphones.CONFIG.ENCODER == 'xld' and headphones.CONFIG.MUSIC_ENCODER and headphones.CONFIG.XLDPROFILE:
import getXldProfile from . import getXldProfile
xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE) xldprofile, xldformat, _ = getXldProfile.getXldProfile(headphones.CONFIG.XLDPROFILE)
if not xldformat: if not xldformat:
raise ValueError( raise ValueError(
@@ -601,7 +601,7 @@ def split(albumpath):
raise ValueError('Command not found, ensure shntool or xld installed') raise ValueError('Command not found, ensure shntool or xld installed')
# Determine if file can be split # Determine if file can be split
if wave.name_ext not in WAVE_FILE_TYPE_BY_EXTENSION.keys(): if wave.name_ext not in list(WAVE_FILE_TYPE_BY_EXTENSION.keys()):
raise ValueError('Cannot split, audio file has unsupported extension') raise ValueError('Cannot split, audio file has unsupported extension')
# Split with xld # Split with xld
+8 -8
View File
@@ -17,7 +17,7 @@
# Stolen from Sick-Beard's db.py # # Stolen from Sick-Beard's db.py #
################################### ###################################
from __future__ import with_statement
import time import time
@@ -116,8 +116,8 @@ class DBConnection:
break break
except sqlite3.OperationalError, e: except sqlite3.OperationalError as e:
if "unable to open database file" in e.message or "database is locked" in e.message: if "unable to open database file" in str(e) or "database is locked" in str(e):
dberror = e dberror = e
if args is None: if args is None:
logger.debug('Database error: %s. Query: %s', e, query) logger.debug('Database error: %s. Query: %s', e, query)
@@ -128,7 +128,7 @@ class DBConnection:
else: else:
logger.error('Database error: %s', e) logger.error('Database error: %s', e)
raise raise
except sqlite3.DatabaseError, e: except sqlite3.DatabaseError as e:
logger.error('Fatal Error executing %s :: %s', query, e) logger.error('Fatal Error executing %s :: %s', query, e)
raise raise
@@ -156,14 +156,14 @@ class DBConnection:
If the table is not updated then the 'WHERE changes' will be 0 and the table inserted If the table is not updated then the 'WHERE changes' will be 0 and the table inserted
""" """
def genParams(myDict): def genParams(myDict):
return [x + " = ?" for x in myDict.keys()] return [x + " = ?" for x in list(myDict.keys())]
update_query = "UPDATE " + tableName + " SET " + ", ".join(genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict)) update_query = "UPDATE " + tableName + " SET " + ", ".join(genParams(valueDict)) + " WHERE " + " AND ".join(genParams(keyDict))
insert_query = ("INSERT INTO " + tableName + " (" + ", ".join(valueDict.keys() + keyDict.keys()) + ")" + " SELECT " + ", ".join( insert_query = ("INSERT INTO " + tableName + " (" + ", ".join(list(valueDict.keys()) + list(keyDict.keys())) + ")" + " SELECT " + ", ".join(
["?"] * len(valueDict.keys() + keyDict.keys())) + " WHERE changes()=0") ["?"] * len(list(valueDict.keys()) + list(keyDict.keys()))) + " WHERE changes()=0")
try: try:
self.action(update_query, valueDict.values() + keyDict.values(), upsert_insert_qry=insert_query) self.action(update_query, list(valueDict.values()) + list(keyDict.values()), upsert_insert_qry=insert_query)
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
logger.info('Queries failed: %s and %s', update_query, insert_query) logger.info('Queries failed: %s and %s', update_query, insert_query)
+2 -21
View File
@@ -34,7 +34,7 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with SickRage. If not, see <http://www.gnu.org/licenses/>. # along with SickRage. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from headphones import logger from headphones import logger
@@ -472,32 +472,13 @@ def _add_torrent_file(result):
# content is torrent file contents that needs to be encoded to base64 # content is torrent file contents that needs to be encoded to base64
post_data = json.dumps({"method": "core.add_torrent_file", post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent', "params": [result['name'] + '.torrent',
b64encode(result['content'].encode('utf8')), {}], b64encode(result['content']).decode(), {}],
"id": 2}) "id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, 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'] result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result'] 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: except Exception as e:
logger.error('Deluge: Adding torrent file failed: %s' % str(e)) logger.error('Deluge: Adding torrent file failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
+4 -3
View File
@@ -1,6 +1,6 @@
import os.path import os.path
import biplist import plistlib
from headphones import logger from headphones import logger
@@ -14,8 +14,9 @@ def getXldProfile(xldProfile):
# Get xld preferences plist # Get xld preferences plist
try: try:
preferences = biplist.readPlist(expanded) with open(expanded, 'rb') as _f:
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException), e: preferences = plistlib.load(_f)
except Exception as e:
logger.error("Error reading xld preferences plist: %s", e) logger.error("Error reading xld preferences plist: %s", e)
return (xldProfileNotFound, None, None) return (xldProfileNotFound, None, None)
+130 -111
View File
@@ -14,23 +14,25 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
from operator import itemgetter import os
import unicodedata import re
import datetime
import shutil import shutil
import time
import sys import sys
import tempfile import tempfile
import glob import time
import unicodedata
from contextlib import contextmanager
from datetime import datetime, date
from fnmatch import fnmatch
from functools import cmp_to_key
from glob import glob
from operator import itemgetter
from beets import logging as beetslogging from beets import logging as beetslogging
import six from mediafile import MediaFile, FileTypeError, UnreadableFileError
from contextlib import contextmanager from six import text_type
from unidecode import unidecode
import fnmatch
import re
import os
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
import headphones import headphones
@@ -40,6 +42,24 @@ RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I) 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) 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
"""
if x is None and y is None:
return 0
elif x is None:
return -1
elif y is None:
return 1
else:
return (x > y) - (x < y)
def multikeysort(items, columns): def multikeysort(items, columns):
comparers = [ comparers = [
@@ -54,7 +74,7 @@ def multikeysort(items, columns):
else: else:
return 0 return 0
return sorted(items, cmp=comparer) return sorted(items, key=cmp_to_key(comparer))
def checked(variable): def checked(variable):
@@ -136,28 +156,25 @@ def convert_seconds(s):
def today(): def today():
today = datetime.date.today() return date.isoformat(date.today())
yyyymmdd = datetime.date.isoformat(today)
return yyyymmdd
def now(): def now():
now = datetime.datetime.now() now = datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S") return now.strftime("%Y-%m-%d %H:%M:%S")
def get_age(date): def is_valid_date(d):
try: if not d:
split_date = date.split('-')
except:
return False return False
else:
return bool(re.match(r'\d{4}-\d{2}-\d{2}', d))
try:
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
except (IndexError, ValueError):
days_old = False
return days_old def age(d):
'''Requires a valid date'''
delta = date.today() - date.fromisoformat(d)
return delta.days
def bytes_to_mb(bytes): def bytes_to_mb(bytes):
@@ -210,7 +227,7 @@ def pattern_substitute(pattern, dic, normalize=False):
if normalize: if normalize:
new_dic = {} new_dic = {}
for i, j in dic.iteritems(): for i, j in dic.items():
if j is not None: if j is not None:
try: try:
if sys.platform == 'darwin': if sys.platform == 'darwin':
@@ -229,7 +246,7 @@ def replace_all(text, dic):
if not text: if not text:
return '' return ''
for i, j in dic.iteritems(): for i, j in dic.items():
text = text.replace(i, j) text = text.replace(i, j)
return text return text
@@ -242,8 +259,8 @@ def replace_illegal_chars(string, type="file"):
return string return string
_CN_RE1 = re.compile(ur'[^\w]+', re.UNICODE) _CN_RE1 = re.compile(r'[^\w]+', re.UNICODE)
_CN_RE2 = re.compile(ur'[\s_]+', re.UNICODE) _CN_RE2 = re.compile(r'[\s_]+', re.UNICODE)
_XLATE_GRAPHICAL_AND_DIACRITICAL = { _XLATE_GRAPHICAL_AND_DIACRITICAL = {
@@ -253,33 +270,33 @@ _XLATE_GRAPHICAL_AND_DIACRITICAL = {
# ©ª«®²³¹»¼½¾ÆÐØÞßæðøþĐđĦħıIJijĸĿŀŁłŒœŦŧDŽDždžLJLjljNJNjnjǤǥDZDzdzȤȥ. This # ©ª«®²³¹»¼½¾ÆÐØÞßæðøþĐđĦħıIJijĸĿŀŁłŒœŦŧDŽDždžLJLjljNJNjnjǤǥDZDzdzȤȥ. This
# includes also some graphical symbols which can be easily replaced and # includes also some graphical symbols which can be easily replaced and
# usually are written by people who don't have appropriate keyboard layout. # usually are written by people who don't have appropriate keyboard layout.
u'©': '(C)', u'ª': 'a.', u'«': '<<', u'®': '(R)', u'²': '2', u'³': '3', '©': '(C)', 'ª': 'a.', '«': '<<', '®': '(R)', '²': '2', '³': '3',
u'¹': '1', u'»': '>>', u'¼': ' 1/4 ', u'½': ' 1/2 ', u'¾': ' 3/4 ', '¹': '1', '»': '>>', '¼': ' 1/4 ', '½': ' 1/2 ', '¾': ' 3/4 ',
u'Æ': 'AE', u'Ð': 'D', u'Ø': 'O', u'Þ': 'Th', u'ß': 'ss', u'æ': 'ae', 'Æ': 'AE', 'Ð': 'D', 'Ø': 'O', 'Þ': 'Th', 'ß': 'ss', 'æ': 'ae',
u'ð': 'd', u'ø': 'o', u'þ': 'th', u'Đ': 'D', u'đ': 'd', u'Ħ': 'H', 'ð': 'd', 'ø': 'o', 'þ': 'th', 'Đ': 'D', 'đ': 'd', 'Ħ': 'H',
u'ħ': 'h', u'ı': 'i', u'IJ': 'IJ', u'ij': 'ij', u'ĸ': 'q', u'Ŀ': 'L', 'ħ': 'h', 'ı': 'i', 'IJ': 'IJ', 'ij': 'ij', 'ĸ': 'q', 'Ŀ': 'L',
u'ŀ': 'l', u'Ł': 'L', u'ł': 'l', u'Œ': 'OE', u'œ': 'oe', u'Ŧ': 'T', 'ŀ': 'l', 'Ł': 'L', 'ł': 'l', 'Œ': 'OE', 'œ': 'oe', 'Ŧ': 'T',
u'ŧ': 't', u'DŽ': 'DZ', u'Dž': 'Dz', u'LJ': 'LJ', u'Lj': 'Lj', 'ŧ': 't', 'DŽ': 'DZ', 'Dž': 'Dz', 'LJ': 'LJ', 'Lj': 'Lj',
u'lj': 'lj', u'NJ': 'NJ', u'Nj': 'Nj', u'nj': 'nj', 'lj': 'lj', 'NJ': 'NJ', 'Nj': 'Nj', 'nj': 'nj',
u'Ǥ': 'G', u'ǥ': 'g', u'DZ': 'DZ', u'Dz': 'Dz', u'dz': 'dz', 'Ǥ': 'G', 'ǥ': 'g', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz',
u'Ȥ': 'Z', u'ȥ': 'z', u'': 'No.', 'Ȥ': 'Z', 'ȥ': 'z', '': 'No.',
u'º': 'o.', # normalize Nº abbrev (popular w/ classical music), 'º': 'o.', # normalize Nº abbrev (popular w/ classical music),
# this is 'masculine ordering indicator', not degree # this is 'masculine ordering indicator', not degree
} }
_XLATE_SPECIAL = { _XLATE_SPECIAL = {
# Translation table. # Translation table.
# Cover additional special characters processing normalization. # Cover additional special characters processing normalization.
u"'": '', # replace apostrophe with nothing "'": '', # replace apostrophe with nothing
u"": '', # replace musicbrainz style apostrophe with nothing "": '', # replace musicbrainz style apostrophe with nothing
u'&': ' and ', # expand & to ' and ' '&': ' and ', # expand & to ' and '
} }
_XLATE_MUSICBRAINZ = { _XLATE_MUSICBRAINZ = {
# Translation table for Musicbrainz. # Translation table for Musicbrainz.
u"": '...', # HORIZONTAL ELLIPSIS (U+2026) "": '...', # HORIZONTAL ELLIPSIS (U+2026)
u"": "'", # APOSTROPHE (U+0027) "": "'", # APOSTROPHE (U+0027)
u"": "-", # EN DASH (U+2013) "": "-", # EN DASH (U+2013)
} }
@@ -314,10 +331,10 @@ def _transliterate(u, xlate):
Perform transliteration using the specified dictionary Perform transliteration using the specified dictionary
""" """
u = unicodedata.normalize('NFD', u) u = unicodedata.normalize('NFD', u)
u = u''.join([u'' if _is_unicode_combining(x) else x for x in u]) u = ''.join(['' if _is_unicode_combining(x) else x for x in u])
u = _translate(u, xlate) u = _translate(u, xlate)
# at this point output is either unicode, or plain ascii # at this point output is either unicode, or plain ascii
return unicode(u) return str(u)
def clean_name(s): def clean_name(s):
@@ -327,10 +344,10 @@ def clean_name(s):
:param s: string to clean up, possibly unicode one. :param s: string to clean up, possibly unicode one.
:return: cleaned-up version of input string. :return: cleaned-up version of input string.
""" """
if not isinstance(s, unicode): if not isinstance(s, str):
# ignore extended chars if someone was dumb enough to pass non-ascii # ignore extended chars if someone was dumb enough to pass non-ascii
# narrow string here, use only unicode for meaningful texts # narrow string here, use only unicode for meaningful texts
u = unicode(s, 'ascii', 'replace') u = str(s, 'ascii', 'replace')
else: else:
u = s u = s
# 1. don't bother doing normalization NFKC, rather transliterate # 1. don't bother doing normalization NFKC, rather transliterate
@@ -341,9 +358,9 @@ def clean_name(s):
# 3. translate spacials # 3. translate spacials
u = _translate(u, _XLATE_SPECIAL) u = _translate(u, _XLATE_SPECIAL)
# 4. replace any non-alphanumeric character sequences by spaces # 4. replace any non-alphanumeric character sequences by spaces
u = _CN_RE1.sub(u' ', u) u = _CN_RE1.sub(' ', u)
# 5. coalesce interleaved space/underscore sequences # 5. coalesce interleaved space/underscore sequences
u = _CN_RE2.sub(u' ', u) u = _CN_RE2.sub(' ', u)
# 6. trim # 6. trim
u = u.strip() u = u.strip()
# 7. lowercase # 7. lowercase
@@ -357,8 +374,8 @@ def clean_musicbrainz_name(s, return_as_string=True):
:param s: string to clean up, probably unicode. :param s: string to clean up, probably unicode.
:return: cleaned-up version of input string. :return: cleaned-up version of input string.
""" """
if not isinstance(s, unicode): if not isinstance(s, str):
u = unicode(s, 'ascii', 'replace') u = str(s, 'ascii', 'replace')
else: else:
u = s u = s
u = _translate(u, _XLATE_MUSICBRAINZ) u = _translate(u, _XLATE_MUSICBRAINZ)
@@ -452,8 +469,7 @@ def expand_subfolders(f):
if difference > 0: if difference > 0:
logger.info( logger.info(
"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.", 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.")
len(media_folders), difference)
# While already failed, advice the user what he could try. We assume the # 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 # directory may contain separate CD's and maybe some extra's. The
@@ -465,8 +481,7 @@ def expand_subfolders(f):
set([os.path.join(*media_folder) for media_folder in extra_media_folders])) set([os.path.join(*media_folder) for media_folder in extra_media_folders]))
logger.info( logger.info(
"Please look at the following folder(s), since they cause the depth difference: %s", f"Please look at the following folder(s), since they cause the depth difference: {extra_media_folders}")
extra_media_folders)
return return
# Convert back to paths and remove duplicates, which may be there after # Convert back to paths and remove duplicates, which may be there after
@@ -480,7 +495,7 @@ def expand_subfolders(f):
logger.debug("Did not expand subfolder, as it resulted in one folder.") logger.debug("Did not expand subfolder, as it resulted in one folder.")
return return
logger.debug("Expanded subfolders in folder: %s", media_folders) logger.debug(f"Expanded subfolders in folder: {media_folders}")
return media_folders return media_folders
@@ -491,14 +506,14 @@ def path_match_patterns(path, patterns):
""" """
for pattern in patterns: for pattern in patterns:
if fnmatch.fnmatch(path, pattern): if fnmatch(path, pattern):
return True return True
# No match # No match
return False return False
def path_filter_patterns(paths, patterns, root=None): def path_filter_patterns(paths, patterns, root=''):
""" """
Scan for ignored paths based on glob patterns. Note that the whole path Scan for ignored paths based on glob patterns. Note that the whole path
will be matched, therefore paths should only contain the relative paths. will be matched, therefore paths should only contain the relative paths.
@@ -512,8 +527,7 @@ def path_filter_patterns(paths, patterns, root=None):
for path in paths[:]: for path in paths[:]:
if path_match_patterns(path, patterns): if path_match_patterns(path, patterns):
logger.debug("Path ignored by pattern: %s", logger.debug(f"Path ignored by pattern: {os.path.join(root, path)}")
os.path.join(root or "", path))
ignored += 1 ignored += 1
paths.remove(path) paths.remove(path)
@@ -595,7 +609,7 @@ def extract_metadata(f):
count_ratio = 0.75 count_ratio = 0.75
if count < (count_ratio * len(results)): if count < (count_ratio * len(results)):
logger.info("Counted %d media files, but only %d have tags, ignoring.", count, len(results)) logger.info(f"Counted {count} media files, but only {len(results)} have tags, ignoring.")
return (None, None, None) return (None, None, None)
# Count distinct values # Count distinct values
@@ -613,8 +627,7 @@ def extract_metadata(f):
old_album = new_albums[index] old_album = new_albums[index]
new_albums[index] = RE_CD_ALBUM.sub("", album).strip() new_albums[index] = RE_CD_ALBUM.sub("", album).strip()
logger.debug("Stripped albumd number identifier: %s -> %s", old_album, logger.debug(f"Stripped album number identifier: {old_album} -> {new_albums[index]}")
new_albums[index])
# Remove duplicates # Remove duplicates
new_albums = list(set(new_albums)) new_albums = list(set(new_albums))
@@ -632,7 +645,7 @@ def extract_metadata(f):
if len(artists) > 1 and len(albums) == 1: if len(artists) > 1 and len(albums) == 1:
split_artists = [RE_FEATURING.split(x) for x in artists] split_artists = [RE_FEATURING.split(x) for x in artists]
featurings = [len(split_artist) - 1 for split_artist in split_artists] featurings = [len(split_artist) - 1 for split_artist in split_artists]
logger.info("Album seem to feature %d different artists", sum(featurings)) logger.info("Album seem to feature {sum(featurings)} different artists")
if sum(featurings) > 0: if sum(featurings) > 0:
# Find the artist of which the least splits have been generated. # Find the artist of which the least splits have been generated.
@@ -644,9 +657,11 @@ def extract_metadata(f):
return (artist, albums[0], years[0]) return (artist, albums[0], years[0])
# Not sure what to do here. # Not sure what to do here.
logger.info("Found %d artists, %d albums and %d years in metadata, so ignoring", len(artists), logger.info(
len(albums), len(years)) f"Found {len(artists)} artists, {len(albums)} albums and "
logger.debug("Artists: %s, Albums: %s, Years: %s", artists, albums, years) f"{len(years)} years in metadata, so ignoring"
)
logger.debug("Artists: {artists}, Albums: {albums}, Years: {years}")
return (None, None, None) return (None, None, None)
@@ -678,8 +693,7 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
else: else:
tempdir = tempfile.gettempdir() tempdir = tempfile.gettempdir()
logger.info("Preparing to copy to a temporary directory for post processing: " + albumpath.decode( logger.info(f"Preparing to copy to a temporary directory for post processing: {albumpath}")
headphones.SYS_ENCODING, 'replace'))
try: try:
file_name = os.path.basename(os.path.normpath(albumpath)) file_name = os.path.basename(os.path.normpath(albumpath))
@@ -689,8 +703,7 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
prefix = "headphones_" + os.path.splitext(file_name)[0] + "_@hp@_" prefix = "headphones_" + os.path.splitext(file_name)[0] + "_@hp@_"
new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir) new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir)
except Exception as e: except Exception as e:
logger.error("Cannot create temp directory: " + tempdir.decode( logger.error(f"Cannot create temp directory: {tempdir}. Error: {e}")
headphones.SYS_ENCODING, 'replace') + ". Error: " + str(e))
return None return None
# Attempt to stop multiple temp dirs being created for the same albumpath # Attempt to stop multiple temp dirs being created for the same albumpath
@@ -699,19 +712,23 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
workdir = os.path.join(tempdir, prefix) workdir = os.path.join(tempdir, prefix)
workdir = re.sub(r'\[', '[[]', workdir) workdir = re.sub(r'\[', '[[]', workdir)
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir) workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
if len(glob.glob(workdir + '*/')) >= 3: if len(glob(workdir + '*/')) >= 3:
logger.error( logger.error(
"Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode( "Looks like a temp directory has previously been created "
headphones.SYS_ENCODING, 'replace')) "for this albumpath, not continuing "
)
shutil.rmtree(new_folder) shutil.rmtree(new_folder)
return None return None
except Exception as e: except Exception as e:
logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e)) logger.warn(
"Cannot determine if already copied/processed, will copy anyway. "
f"Warning: {e}"
)
# Copy to temp dir # Copy to temp dir
try: try:
subdir = os.path.join(new_folder, "headphones") subdir = os.path.join(new_folder, "headphones")
logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Copying files to {subdir}")
if not single: if not single:
shutil.copytree(albumpath, subdir) shutil.copytree(albumpath, subdir)
else: else:
@@ -720,9 +737,10 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
# Update the album path with the new location # Update the album path with the new location
return subdir return subdir
except Exception as e: except Exception as e:
logger.warn("Cannot copy/move files to temp directory: " + new_folder.decode(headphones.SYS_ENCODING, logger.warn(
'replace') + ". Not continuing. Error: " + str( f"Cannot copy/move files to temp directory: {new_folder}. "
e)) f"Not continuing. Error: {e}"
)
shutil.rmtree(new_folder) shutil.rmtree(new_folder)
return None return None
@@ -767,7 +785,7 @@ def cue_split(albumpath, keep_original_folder=False):
cuesplit.split(cue_dir) cuesplit.split(cue_dir)
except Exception as e: except Exception as e:
os.chdir(cwd) os.chdir(cwd)
logger.warn("Cue not split: " + str(e)) logger.warn(f"Cue not split. Error: {e}")
return None return None
os.chdir(cwd) os.chdir(cwd)
@@ -805,7 +823,7 @@ def extract_song_data(s):
year = match.group("year") year = match.group("year")
return (name, album, year) return (name, album, year)
else: else:
logger.info("Couldn't parse %s into a valid default format", s) logger.info(f"Couldn't parse {s} into a valid default format")
# newzbin default format # newzbin default format
pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s\((?P<year>\d+?\))', re.VERBOSE) pattern = re.compile(r'(?P<name>.*?)\s\-\s(?P<album>.*?)\s\((?P<year>\d+?\))', re.VERBOSE)
@@ -816,7 +834,7 @@ def extract_song_data(s):
year = match.group("year") year = match.group("year")
return (name, album, year) return (name, album, year)
else: else:
logger.info("Couldn't parse %s into a valid Newbin format", s) logger.info(f"Couldn't parse {s} into a valid Newbin format")
return (name, album, year) return (name, album, year)
@@ -829,7 +847,7 @@ def smartMove(src, dest, delete=True):
dest_path = os.path.join(dest, filename) dest_path = os.path.join(dest, filename)
if os.path.isfile(dest_path): if os.path.isfile(dest_path):
logger.info('Destination file exists: %s', dest_path) logger.info(f"Destination file exists: {dest_path}")
title = os.path.splitext(filename)[0] title = os.path.splitext(filename)[0]
ext = os.path.splitext(filename)[1] ext = os.path.splitext(filename)[1]
i = 1 i = 1
@@ -838,13 +856,12 @@ def smartMove(src, dest, delete=True):
if os.path.isfile(os.path.join(dest, newfile)): if os.path.isfile(os.path.join(dest, newfile)):
i += 1 i += 1
else: else:
logger.info('Renaming to %s', newfile) logger.info(f"Renaming to {newfile}")
try: try:
os.rename(src, os.path.join(source_dir, newfile)) os.rename(src, os.path.join(source_dir, newfile))
filename = newfile filename = newfile
except Exception as e: except Exception as e:
logger.warn('Error renaming %s: %s', logger.warn(f"Error renaming {src}: {e}")
src.decode(headphones.SYS_ENCODING, 'replace'), e)
break break
if delete: if delete:
@@ -854,8 +871,9 @@ def smartMove(src, dest, delete=True):
except Exception as e: except Exception as e:
exists = os.path.exists(dest_path) exists = os.path.exists(dest_path)
if exists and os.path.getsize(source_path) == os.path.getsize(dest_path): if exists and os.path.getsize(source_path) == os.path.getsize(dest_path):
logger.warn('Successfully moved file "%s", but something went wrong: %s', logger.warn(
filename.decode(headphones.SYS_ENCODING, 'replace'), e) f"Successfully moved {filename}, but something went wrong: {e}"
)
os.unlink(source_path) os.unlink(source_path)
else: else:
# remove faultly copied file # remove faultly copied file
@@ -864,12 +882,11 @@ def smartMove(src, dest, delete=True):
raise raise
else: else:
try: try:
logger.info('Copying "%s" to "%s"', source_path, dest_path) logger.info(f"Copying {source_path} to {dest_path}")
shutil.copy(source_path, dest_path) shutil.copy(source_path, dest_path)
return True return True
except Exception as e: except Exception as e:
logger.warn('Error copying file %s: %s', filename.decode(headphones.SYS_ENCODING, 'replace'), logger.warn(f"Error copying {filename}: {e}")
e)
def walk_directory(basedir, followlinks=True): def walk_directory(basedir, followlinks=True):
@@ -878,7 +895,7 @@ def walk_directory(basedir, followlinks=True):
with care. In case a folder is already processed, don't traverse it again. with care. In case a folder is already processed, don't traverse it again.
""" """
import logger from . import logger
# Add the base path, because symlinks poiting to the basedir should not be # Add the base path, because symlinks poiting to the basedir should not be
# traversed again. # traversed again.
@@ -892,8 +909,10 @@ def walk_directory(basedir, followlinks=True):
real_path = os.path.abspath(os.readlink(path)) real_path = os.path.abspath(os.readlink(path))
if real_path in traversed: if real_path in traversed:
logger.debug("Skipping '%s' since it is a symlink to " logger.debug(
"'%s', which is already visited.", path, real_path) f"Skipping {path} since it is a symlink to "
f"{real_path}, which is already visited."
)
else: else:
traversed.append(real_path) traversed.append(real_path)
@@ -935,22 +954,15 @@ def sab_sanitize_foldername(name):
FL_ILLEGAL = CH_ILLEGAL + ':\x92"' FL_ILLEGAL = CH_ILLEGAL + ':\x92"'
FL_LEGAL = CH_LEGAL + "-''" FL_LEGAL = CH_LEGAL + "-''"
uFL_ILLEGAL = FL_ILLEGAL.decode('latin-1')
uFL_LEGAL = FL_LEGAL.decode('latin-1')
if not name: if not name:
return name return
if isinstance(name, unicode):
illegal = uFL_ILLEGAL name = unidecode(name)
legal = uFL_LEGAL
else:
illegal = FL_ILLEGAL
legal = FL_LEGAL
lst = [] lst = []
for ch in name.strip(): for ch in name.strip():
if ch in illegal: if ch in FL_ILLEGAL:
ch = legal[illegal.find(ch)] ch = FL_LEGAL[FL_ILLEGAL.find(ch)]
lst.append(ch) lst.append(ch)
else: else:
lst.append(ch) lst.append(ch)
@@ -1006,7 +1018,7 @@ def create_https_certificates(ssl_cert, ssl_key):
with open(ssl_cert, "w") as fp: with open(ssl_cert, "w") as fp:
fp.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) fp.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
except IOError as e: except IOError as e:
logger.error("Error creating SSL key and certificate: %s", e) logger.error(f"Error creating SSL key and certificate: e")
return False return False
return True return True
@@ -1019,7 +1031,7 @@ class BeetsLogCapture(beetslogging.Handler):
self.messages = [] self.messages = []
def emit(self, record): def emit(self, record):
self.messages.append(six.text_type(record.msg)) self.messages.append(text_type(record.msg))
@contextmanager @contextmanager
@@ -1031,3 +1043,10 @@ def capture_beets_log(logger='beets'):
yield capture.messages yield capture.messages
finally: finally:
log.removeHandler(capture) log.removeHandler(capture)
def have_pct_have_total(db_artist):
have_tracks = db_artist['HaveTracks'] or 0
total_tracks = db_artist['TotalTracks'] or 0
have_pct = have_tracks / total_tracks if total_tracks else 0
return (have_pct, total_tracks)
+27 -17
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from unittestcompat import TestCase from .unittestcompat import TestCase
from headphones.helpers import clean_name from headphones.helpers import clean_name, is_valid_date, age
class HelpersTest(TestCase): class HelpersTest(TestCase):
@@ -8,28 +8,28 @@ class HelpersTest(TestCase):
def test_clean_name(self): def test_clean_name(self):
"""helpers: check correctness of clean_name() function""" """helpers: check correctness of clean_name() function"""
cases = { cases = {
u' Weiße & rose ': 'Weisse and rose', ' Weiße & rose ': 'Weisse and rose',
u'Multiple / spaces': 'Multiple spaces', 'Multiple / spaces': 'Multiple spaces',
u'Kevin\'s m²': 'Kevins m2', 'Kevin\'s m²': 'Kevins m2',
u'Symphonęy Nº9': 'Symphoney No.9', 'Symphonęy Nº9': 'Symphoney No.9',
u'ÆæßðÞIJij': u'AeaessdThIJıj', 'ÆæßðÞIJij': 'AeaessdThIJıj',
u'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral ' 'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral '
'apoplexy remix', 'apoplexy remix',
u'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu ' 'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu '
'zbojow', 'zbojow',
u'Arbetets Söner och Döttrar': 'arbetets soner och dottrar', 'Arbetets Söner och Döttrar': 'arbetets soner och dottrar',
u'Björk Guðmundsdóttir': 'bjork gudmundsdottir', 'Björk Guðmundsdóttir': 'bjork gudmundsdottir',
u'L\'Arc~en~Ciel': 'larc en ciel', 'L\'Arc~en~Ciel': 'larc en ciel',
u'Orquesta de la Luz (オルケスタ・デ・ラ・ルス)': 'Orquesta de la Luz (オルケスタ・デ・ラ・ルス)':
u'Orquesta de la Luz オルケスタ デ ラ ルス' 'Orquesta de la Luz オルケスタ デ ラ ルス'
} }
for first, second in cases.iteritems(): for first, second in cases.items():
nf = clean_name(first).lower() nf = clean_name(first).lower()
ns = clean_name(second).lower() ns = clean_name(second).lower()
self.assertEqual( self.assertEqual(
nf, ns, u"check cleaning of case (%s," nf, ns, "check cleaning of case (%s,"
u"%s)" % (nf, ns) "%s)" % (nf, ns)
) )
def test_clean_name_nonunicode(self): def test_clean_name_nonunicode(self):
@@ -46,3 +46,13 @@ class HelpersTest(TestCase):
self.assertEqual( self.assertEqual(
test, expected, "check clean_name() with narrow non-ascii input" test, expected, "check clean_name() with narrow non-ascii input"
) )
def test_is_valid_date(date):
test_cases = [
('2021-11-12', True, "check is_valid_date returns True for valid date"),
(None, False, "check is_valid_date returns False for None"),
('2021-11', False, "check is_valid_date returns False for incomplete"),
('2021', False, "check is_valid_date returns False for incomplete")
]
for input, expected, desc in test_cases:
self.assertEqual(is_valid_date(input), expected, desc)
+24 -34
View File
@@ -16,7 +16,7 @@
import time import time
from headphones import logger, helpers, db, mb, lastfm, metacritic from headphones import logger, helpers, db, mb, lastfm, metacritic
from beets.mediafile import MediaFile from mediafile import MediaFile
import headphones import headphones
blacklisted_special_artist_names = ['[anonymous]', '[data]', '[no artist]', 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): if any(artistid in x for x in artistlist):
logger.info(artistlist[0][ logger.info(artistlist[0][
1] + u" is already in the database. Updating 'have tracks', but not artist information") 1] + " is already in the database. Updating 'have tracks', but not artist information")
return True return True
else: else:
return False 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?) # If adding artists through Manage New Artists, they're coming through as non-unicode (utf-8?)
# and screwing everything up # and screwing everything up
if not isinstance(artist, unicode): if not isinstance(artist, str):
try: try:
artist = artist.decode('utf-8', 'replace') artist = artist.decode('utf-8', 'replace')
except Exception: except Exception:
@@ -102,12 +102,7 @@ def artistlist_to_mbids(artistlist, forced=False):
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist]) myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
# Update the similar artist tag cloud: # Update the similar artist tag cloud:
logger.info('Updating artist information from Last.fm') lastfm.getSimilar()
try:
lastfm.getSimilar()
except Exception as e:
logger.warn('Failed to update artist information from Last.fm: %s' % e)
def addArtistIDListToDB(artistidlist): def addArtistIDListToDB(artistidlist):
@@ -184,7 +179,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
else: else:
sortname = artist['artist_name'] sortname = artist['artist_name']
logger.info(u"Now adding/updating: " + artist['artist_name']) logger.info("Now adding/updating: " + artist['artist_name'])
controlValueDict = {"ArtistID": artistid} controlValueDict = {"ArtistID": artistid}
newValueDict = {"ArtistName": artist['artist_name'], newValueDict = {"ArtistName": artist['artist_name'],
"ArtistSortName": sortname, "ArtistSortName": sortname,
@@ -245,7 +240,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
rgid = rg['id'] rgid = rg['id']
skip_log = 0 skip_log = 0
# Make a user configurable variable to skip update of albums with release dates older than this date (in days) # Make a user configurable variable to skip update of albums with release dates older than this date (in days)
pause_delta = headphones.CONFIG.MB_IGNORE_AGE ignore_age = headphones.CONFIG.MB_IGNORE_AGE
rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone() rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone()
@@ -263,8 +258,8 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
new_releases = mb.get_new_releases(rgid, includeExtras) new_releases = mb.get_new_releases(rgid, includeExtras)
else: else:
if check_release_date is None or check_release_date == u"None": if check_release_date is None or check_release_date == "None":
if headphones.CONFIG.MB_IGNORE_AGE_MISSING is not 1: if not headphones.CONFIG.MB_IGNORE_AGE_MISSING:
logger.info("[%s] Now updating: %s (No Release Date)" % (artist['artist_name'], rg['title'])) logger.info("[%s] Now updating: %s (No Release Date)" % (artist['artist_name'], rg['title']))
new_releases = mb.get_new_releases(rgid, includeExtras, True) new_releases = mb.get_new_releases(rgid, includeExtras, True)
else: else:
@@ -274,18 +269,18 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
if len(check_release_date) == 10: if len(check_release_date) == 10:
release_date = check_release_date release_date = check_release_date
elif len(check_release_date) == 7: elif len(check_release_date) == 7:
release_date = check_release_date + "-31" release_date = check_release_date + "-27"
elif len(check_release_date) == 4: elif len(check_release_date) == 4:
release_date = check_release_date + "-12-31" release_date = check_release_date + "-12-27"
else: else:
release_date = today release_date = today
if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta: if helpers.age(release_date) < ignore_age:
logger.info("[%s] Now updating: %s (Release Date <%s Days)", logger.info("[%s] Now updating: %s (Release Date <%s Days)",
artist['artist_name'], rg['title'], pause_delta) artist['artist_name'], rg['title'], ignore_age)
new_releases = mb.get_new_releases(rgid, includeExtras, True) new_releases = mb.get_new_releases(rgid, includeExtras, True)
else: else:
logger.info("[%s] Skipping: %s (Release Date >%s Days)", logger.info("[%s] Skipping: %s (Release Date >%s Days)",
artist['artist_name'], rg['title'], pause_delta) artist['artist_name'], rg['title'], ignore_age)
skip_log = 1 skip_log = 1
new_releases = 0 new_releases = 0
@@ -450,14 +445,9 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
if headphones.CONFIG.AUTOWANT_ALL: if headphones.CONFIG.AUTOWANT_ALL:
newValueDict['Status'] = "Wanted" newValueDict['Status'] = "Wanted"
elif album['ReleaseDate'] > today and headphones.CONFIG.AUTOWANT_UPCOMING: elif headphones.CONFIG.AUTOWANT_UPCOMING:
newValueDict['Status'] = "Wanted" if helpers.is_valid_date(album['ReleaseDate']) and helpers.age(album['ReleaseDate']) < 21:
# Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these newValueDict['Status'] = "Wanted"
# The first test just makes sure we have year-month-day
elif helpers.get_age(album['ReleaseDate']) and helpers.get_age(
today) - helpers.get_age(
album['ReleaseDate']) < 21 and headphones.CONFIG.AUTOWANT_UPCOMING:
newValueDict['Status'] = "Wanted"
else: else:
newValueDict['Status'] = "Skipped" newValueDict['Status'] = "Skipped"
@@ -517,7 +507,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
marked_as_downloaded = True marked_as_downloaded = True
logger.info( logger.info(
u"[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title'])) "[%s] Seeing if we need album art for %s" % (artist['artist_name'], rg['title']))
try: try:
cache.getThumb(AlbumID=rg['id']) cache.getThumb(AlbumID=rg['id'])
except Exception as e: except Exception as e:
@@ -530,19 +520,19 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
album_searches.append(rg['id']) album_searches.append(rg['id'])
else: else:
if skip_log == 0: if skip_log == 0:
logger.info(u"[%s] No new releases, so no changes made to %s" % ( logger.info("[%s] No new releases, so no changes made to %s" % (
artist['artist_name'], rg['title'])) artist['artist_name'], rg['title']))
time.sleep(3) time.sleep(3)
finalize_update(artistid, artist['artist_name'], errors) finalize_update(artistid, artist['artist_name'], errors)
logger.info(u"Seeing if we need album art for: %s" % artist['artist_name']) logger.info("Seeing if we need album art for: %s" % artist['artist_name'])
try: try:
cache.getThumb(ArtistID=artistid) cache.getThumb(ArtistID=artistid)
except Exception as e: except Exception as e:
logger.error("Error getting album art: %s", e) logger.error("Error getting album art: %s", e)
logger.info(u"Fetching Metacritic reviews for: %s" % artist['artist_name']) logger.info("Fetching Metacritic reviews for: %s" % artist['artist_name'])
try: try:
metacritic.update(artistid, artist['artist_name'], artist['releasegroups']) metacritic.update(artistid, artist['artist_name'], artist['releasegroups'])
except Exception as e: except Exception as e:
@@ -554,7 +544,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
artist['artist_name'], artist['artist_name'])) artist['artist_name'], artist['artist_name']))
else: else:
myDB.action('DELETE FROM newartists WHERE ArtistName = ?', [artist['artist_name']]) myDB.action('DELETE FROM newartists WHERE ArtistName = ?', [artist['artist_name']])
logger.info(u"Updating complete for: %s" % artist['artist_name']) logger.info("Updating complete for: %s" % artist['artist_name'])
# Start searching for newly added albums # Start searching for newly added albums
if album_searches: if album_searches:
@@ -663,7 +653,7 @@ def addReleaseById(rid, rgid=None):
sortname = release_dict['artist_name'] sortname = release_dict['artist_name']
logger.info( logger.info(
u"Now manually adding: " + release_dict['artist_name'] + " - with status Paused") "Now manually adding: " + release_dict['artist_name'] + " - with status Paused")
controlValueDict = {"ArtistID": release_dict['artist_id']} controlValueDict = {"ArtistID": release_dict['artist_id']}
newValueDict = {"ArtistName": release_dict['artist_name'], newValueDict = {"ArtistName": release_dict['artist_name'],
"ArtistSortName": sortname, "ArtistSortName": sortname,
@@ -696,7 +686,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 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 # but if it is this will fail
logger.info(u"Now adding-by-id album (" + release_dict['title'] + ") from id: " + rgid) logger.info("Now adding-by-id album (" + release_dict['title'] + ") from id: " + rgid)
controlValueDict = {"AlbumID": rgid} controlValueDict = {"AlbumID": rgid}
if status != 'Loading': if status != 'Loading':
status = 'Wanted' status = 'Wanted'
@@ -772,7 +762,7 @@ def addReleaseById(rid, rgid=None):
# Start a search for the album # Start a search for the album
if headphones.CONFIG.AUTOWANT_MANUALLY_ADDED: if headphones.CONFIG.AUTOWANT_MANUALLY_ADDED:
import searcher from . import searcher
searcher.searchforalbum(rgid, False) searcher.searchforalbum(rgid, False)
elif not rg_exists and not release_dict: elif not rg_exists and not release_dict:
+29 -22
View File
@@ -22,8 +22,8 @@ from headphones import db, logger, request
TIMEOUT = 60.0 # seconds TIMEOUT = 60.0 # seconds
REQUEST_LIMIT = 1.0 / 5 # seconds REQUEST_LIMIT = 1.0 / 5 # seconds
ENTRY_POINT = "http://ws.audioscrobbler.com/2.0/" ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
API_KEY = "395e6ec6bb557382fc41fde867bce66f" APP_API_KEY = "395e6ec6bb557382fc41fde867bce66f"
# Required for API request limit # Required for API request limit
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT) lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
@@ -31,7 +31,7 @@ lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
def request_lastfm(method, **kwargs): def request_lastfm(method, **kwargs):
""" """
Call a Last.FM API method. Automatically sets the method and API key. Method Call a Last.fm API method. Automatically sets the method and API key. Method
will return the result if no error occured. will return the result if no error occured.
By default, this method will request the JSON format, since it is more By default, this method will request the JSON format, since it is more
@@ -40,35 +40,42 @@ def request_lastfm(method, **kwargs):
# Prepare request # Prepare request
kwargs["method"] = method kwargs["method"] = method
kwargs.setdefault("api_key", API_KEY) kwargs.setdefault("api_key", headphones.CONFIG.LASTFM_APIKEY or APP_API_KEY)
kwargs.setdefault("format", "json") kwargs.setdefault("format", "json")
# Send request # Send request
logger.debug("Calling Last.FM method: %s", method) logger.debug("Calling Last.fm method: %s", method)
logger.debug("Last.FM call parameters: %s", kwargs) logger.debug("Last.fm call parameters: %s", kwargs)
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock) data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
# Parse response and check for errors. # Parse response and check for errors.
if not data: if not data:
logger.error("Error calling Last.FM method: %s", method) logger.error("Error calling Last.fm method: %s", method)
return return
if "error" in data: if "error" in data:
logger.debug("Last.FM returned an error: %s", data["message"]) logger.debug("Last.fm returned an error: %s", data["message"])
return return
return data return data
def getSimilar(): def getSimilar():
myDB = db.DBConnection() if not headphones.CONFIG.LASTFM_APIKEY:
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC") logger.info(
'To update the Similar Artists cloud tag, create a Last.fm application api key '
'and add it under the Advanced config tab'
)
return
logger.info("Fetching similar artists from Last.FM for tag cloud") myDB = db.DBConnection()
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC LIMIT 10")
logger.info("Fetching similar artists from Last.fm for tag cloud")
artistlist = [] artistlist = []
for result in results[:12]: for result in results:
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"]) data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
if data and "similarartists" in data: if data and "similarartists" in data:
@@ -85,13 +92,13 @@ def getSimilar():
artistlist.append((artist_name, artist_mbid)) artistlist.append((artist_name, artist_mbid))
# Add new artists to tag cloud # Add new artists to tag cloud
logger.debug("Fetched %d artists from Last.FM", len(artistlist)) logger.debug("Fetched %d artists from Last.fm", len(artistlist))
count = defaultdict(int) count = defaultdict(int)
for artist, mbid in artistlist: for artist, mbid in artistlist:
count[artist, mbid] += 1 count[artist, mbid] += 1
items = count.items() items = list(count.items())
top_list = sorted(items, key=lambda x: x[1], reverse=True)[:25] top_list = sorted(items, key=lambda x: x[1], reverse=True)[:25]
random.shuffle(top_list) random.shuffle(top_list)
@@ -103,7 +110,7 @@ def getSimilar():
myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count]) myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count])
logger.debug("Inserted %d artists into Last.FM tag cloud", len(top_list)) logger.debug("Inserted %d artists into Last.fm tag cloud", len(top_list))
def getArtists(): def getArtists():
@@ -111,16 +118,16 @@ def getArtists():
results = myDB.select("SELECT ArtistID from artists") results = myDB.select("SELECT ArtistID from artists")
if not headphones.CONFIG.LASTFM_USERNAME: if not headphones.CONFIG.LASTFM_USERNAME:
logger.warn("Last.FM username not set, not importing artists.") logger.warn("Last.fm username not set, not importing artists.")
return return
logger.info("Fetching artists from Last.FM for username: %s", headphones.CONFIG.LASTFM_USERNAME) logger.info("Fetching artists from Last.fm for username: %s", headphones.CONFIG.LASTFM_USERNAME)
data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME) data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME)
if data and "artists" in data: if data and "artists" in data:
artistlist = [] artistlist = []
artists = data["artists"]["artist"] artists = data["artists"]["artist"]
logger.debug("Fetched %d artists from Last.FM", len(artists)) logger.debug("Fetched %d artists from Last.fm", len(artists))
for artist in artists: for artist in artists:
artist_mbid = artist["mbid"] artist_mbid = artist["mbid"]
@@ -133,20 +140,20 @@ def getArtists():
for artistid in artistlist: for artistid in artistlist:
importer.addArtisttoDB(artistid) importer.addArtisttoDB(artistid)
logger.info("Imported %d new artists from Last.FM", len(artistlist)) logger.info("Imported %d new artists from Last.fm", len(artistlist))
def getTagTopArtists(tag, limit=50): def getTagTopArtists(tag, limit=50):
myDB = db.DBConnection() myDB = db.DBConnection()
results = myDB.select("SELECT ArtistID from artists") results = myDB.select("SELECT ArtistID from artists")
logger.info("Fetching top artists from Last.FM for tag: %s", tag) logger.info("Fetching top artists from Last.fm for tag: %s", tag)
data = request_lastfm("tag.gettopartists", limit=limit, tag=tag) data = request_lastfm("tag.gettopartists", limit=limit, tag=tag)
if data and "topartists" in data: if data and "topartists" in data:
artistlist = [] artistlist = []
artists = data["topartists"]["artist"] artists = data["topartists"]["artist"]
logger.debug("Fetched %d artists from Last.FM", len(artists)) logger.debug("Fetched %d artists from Last.fm", len(artists))
for artist in artists: for artist in artists:
try: try:
@@ -162,4 +169,4 @@ def getTagTopArtists(tag, limit=50):
for artistid in artistlist: for artistid in artistlist:
importer.addArtisttoDB(artistid) importer.addArtisttoDB(artistid)
logger.debug("Added %d new artists from Last.FM", len(artistlist)) logger.debug("Added %d new artists from Last.fm", len(artistlist))
+88 -105
View File
@@ -17,7 +17,7 @@ import os
import math import math
import headphones import headphones
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from mediafile import MediaFile, FileTypeError, UnreadableFileError
from headphones import db, logger, helpers, importer, lastfm from headphones import db, logger, helpers, importer, lastfm
@@ -30,72 +30,64 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
if not dir: if not dir:
if not headphones.CONFIG.MUSIC_DIR: if not headphones.CONFIG.MUSIC_DIR:
logger.info(
"No music directory configured. Add it under "
"Manage -> Scan Music Library"
)
return return
else: else:
dir = headphones.CONFIG.MUSIC_DIR 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): if not os.path.isdir(dir):
logger.warn('Cannot find directory: %s. Not scanning' % dir.decode(headphones.SYS_ENCODING, logger.warn(f"Cannot find music directory: {dir}")
'replace'))
return return
myDB = db.DBConnection() myDB = db.DBConnection()
new_artists = [] new_artists = []
logger.info('Scanning music directory: %s' % dir.decode(headphones.SYS_ENCODING, 'replace')) logger.info(f"Scanning music directory: {dir}")
if not append: if not append:
# Clean up bad filepaths. Queries can take some time, ensure all results are loaded before processing # Clean up bad filepaths. Queries can take some time, ensure all results are loaded before processing
if ArtistID: if ArtistID:
tracks = myDB.action( dbtracks = myDB.action(
'SELECT Location FROM alltracks WHERE ArtistID = ? AND Location IS NOT NULL UNION SELECT Location FROM tracks WHERE ArtistID = ? AND Location ' 'SELECT Location FROM alltracks WHERE ArtistID = ? AND Location IS NOT NULL UNION SELECT Location FROM tracks WHERE ArtistID = ? AND Location '
'IS NOT NULL', 'IS NOT NULL',
[ArtistID, ArtistID]) [ArtistID, ArtistID])
else: else:
tracks = myDB.action( dbtracks = myDB.action(
'SELECT Location FROM alltracks WHERE Location IS NOT NULL UNION SELECT Location FROM tracks WHERE Location IS NOT NULL') 'SELECT Location FROM alltracks WHERE Location IS NOT NULL UNION SELECT Location FROM tracks WHERE Location IS NOT NULL')
locations = [] for track in dbtracks:
for track in tracks: track_location = track['Location']
locations.append(track['Location']) if not os.path.isfile(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=?', myDB.action('UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, location]) [None, None, None, track_location])
myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?', myDB.action('UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, location]) [None, None, None, track_location])
if ArtistName: if ArtistName:
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have WHERE ArtistName = ? COLLATE NOCASE', [ArtistName]) del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have WHERE ArtistName = ? COLLATE NOCASE', [ArtistName])
else: else:
del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have') del_have_tracks = myDB.select('SELECT Location, Matched, ArtistName FROM have')
locations = []
for track in del_have_tracks: for track in del_have_tracks:
locations.append([track['Location'], track['ArtistName']]) if not os.path.isfile(track['Location']):
for location in locations: if track['ArtistName']:
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 # Make sure deleted files get accounted for when updating artist track counts
new_artists.append(location[1]) new_artists.append(track['ArtistName'])
myDB.action('DELETE FROM have WHERE Location=?', [location[0]]) myDB.action('DELETE FROM have WHERE Location=?', [track['Location']])
logger.info( logger.info(
'File %s removed from Headphones, as it is no longer on disk' % encoded_track_string.decode( f"{track['Location']} removed from Headphones, as it "
headphones.SYS_ENCODING, 'replace')) f"is no longer on disk"
)
bitrates = [] bitrates = []
song_list = [] track_list = []
latest_subdirectory = [] latest_subdirectory = []
new_song_count = 0 new_track_count = 0
file_count = 0 file_count = 0
for r, d, f in helpers.walk_directory(dir): for r, d, f in helpers.walk_directory(dir):
@@ -110,32 +102,16 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
subdirectory = r.replace(dir, '') subdirectory = r.replace(dir, '')
latest_subdirectory.append(subdirectory) latest_subdirectory.append(subdirectory)
if file_count == 0 and r.replace(dir, '') != '': track_path = os.path.join(r, files)
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 to read the metadata
try: try:
f = MediaFile(song) f = MediaFile(track_path)
except (FileTypeError, UnreadableFileError): except (FileTypeError, UnreadableFileError):
logger.warning( logger.warning(f"Cannot read `{track_path}`. It may be corrupted or not a media file.")
"Cannot read media file '%s', skipping. It may be corrupted or not a media file.",
unicode_song_path)
continue continue
except IOError: except IOError:
logger.warning("Cannnot read media file '%s', skipping. Does the file exists?", logger.warning(f"Cannnot read `{track_path}`. Does the file exists?")
unicode_song_path)
continue continue
# Grab the bitrates for the auto detect bit rate option # Grab the bitrates for the auto detect bit rate option
@@ -150,15 +126,15 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
else: else:
f_artist = None f_artist = None
# Add the song to our song list - # Add the track to our track list -
# TODO: skip adding songs without the minimum requisite information (just a matter of putting together the right if statements) # TODO: skip adding tracks without the minimum requisite information (just a matter of putting together the right if statements)
if f_artist and f.album and f.title: if f_artist and f.album and f.title:
CleanName = helpers.clean_name(f_artist + ' ' + f.album + ' ' + f.title) CleanName = helpers.clean_name(f_artist + ' ' + f.album + ' ' + f.title)
else: else:
CleanName = None CleanName = None
controlValueDict = {'Location': unicode_song_path} controlValueDict = {'Location': track_path}
newValueDict = {'TrackID': f.mb_trackid, newValueDict = {'TrackID': f.mb_trackid,
# 'ReleaseID' : f.mb_albumid, # 'ReleaseID' : f.mb_albumid,
@@ -174,24 +150,24 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
'CleanName': CleanName 'CleanName': CleanName
} }
# song_list.append(song_dict) # track_list.append(track_dict)
check_exist_song = myDB.action("SELECT * FROM have WHERE Location=?", check_exist_track = myDB.action("SELECT * FROM have WHERE Location=?",
[unicode_song_path]).fetchone() [track_path]).fetchone()
# Only attempt to match songs that are new, haven't yet been matched, or metadata has changed. # Only attempt to match tracks that are new, haven't yet been matched, or metadata has changed.
if not check_exist_song: if not check_exist_track:
# This is a new track # This is a new track
if f_artist: if f_artist:
new_artists.append(f_artist) new_artists.append(f_artist)
myDB.upsert("have", newValueDict, controlValueDict) myDB.upsert("have", newValueDict, controlValueDict)
new_song_count += 1 new_track_count += 1
else: else:
if check_exist_song['ArtistName'] != f_artist or check_exist_song[ if check_exist_track['ArtistName'] != f_artist or check_exist_track[
'AlbumTitle'] != f.album or check_exist_song['TrackTitle'] != f.title: 'AlbumTitle'] != f.album or check_exist_track['TrackTitle'] != f.title:
# Important track metadata has been modified, need to run matcher again # Important track metadata has been modified, need to run matcher again
if f_artist and f_artist != check_exist_song['ArtistName']: if f_artist and f_artist != check_exist_track['ArtistName']:
new_artists.append(f_artist) new_artists.append(f_artist)
elif f_artist and f_artist == check_exist_song['ArtistName'] and \ elif f_artist and f_artist == check_exist_track['ArtistName'] and \
check_exist_song['Matched'] != "Ignored": check_exist_track['Matched'] != "Ignored":
new_artists.append(f_artist) new_artists.append(f_artist)
else: else:
continue continue
@@ -200,51 +176,59 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
myDB.upsert("have", newValueDict, controlValueDict) myDB.upsert("have", newValueDict, controlValueDict)
myDB.action( myDB.action(
'UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?', 'UPDATE tracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, unicode_song_path]) [None, None, None, track_path])
myDB.action( myDB.action(
'UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?', 'UPDATE alltracks SET Location=?, BitRate=?, Format=? WHERE Location=?',
[None, None, None, unicode_song_path]) [None, None, None, track_path])
new_song_count += 1 new_track_count += 1
else: else:
# This track information hasn't changed # This track information hasn't changed
if f_artist and check_exist_song['Matched'] != "Ignored": if f_artist and check_exist_track['Matched'] != "Ignored":
new_artists.append(f_artist) new_artists.append(f_artist)
file_count += 1 file_count += 1
# Now we start track matching # Now we start track matching
logger.info("%s new/modified songs found and added to the database" % new_song_count) logger.info(f"{new_track_count} new/modified tracks found and added to the database")
song_list = myDB.action("SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", dbtracks = myDB.action(
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]) "SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
total_number_of_songs = \ [f"{dir}%"]
myDB.action("SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", )
[dir.decode(headphones.SYS_ENCODING, 'replace') + "%"]).fetchone()[0] dbtracks_count = myDB.action(
logger.info("Found " + str(total_number_of_songs) + " new/modified tracks in: '" + dir.decode( "SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
headphones.SYS_ENCODING, 'replace') + "'. Matching tracks to the appropriate releases....") [f"{dir}%"]
).fetchone()[0]
logger.info(f"Found {dbtracks_count} new/modified tracks in `{dir}`")
logger.info("Matching tracks to the appropriate releases....")
# 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
# 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 # Sort the track_list by most vague (e.g. no trackid or releaseid)
song_count = 0 # to most specific (both trackid & releaseid)
latest_artist = [] # 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'])
# We'll use this to give a % completion, just because the
# track matching might take a while
tracks_completed = 0
latest_artist = None
last_completion_percentage = 0 last_completion_percentage = 0
prev_artist_name = None prev_artist_name = None
artistid = None artistid = None
for song in song_list: for track in sorted_dbtracks:
latest_artist.append(song['ArtistName']) if latest_artist != track['ArtistName']:
if song_count == 0: logger.info(f"Now matching tracks by {track['ArtistName']}")
logger.info("Now matching songs by %s" % song['ArtistName']) latest_artist = track['ArtistName']
elif latest_artist[song_count] != latest_artist[song_count - 1] and song_count != 0:
logger.info("Now matching songs by %s" % song['ArtistName'])
song_count += 1 tracks_completed += 1
completion_percentage = math.floor(float(song_count) / total_number_of_songs * 1000) / 10 completion_percentage = math.floor(
float(tracks_completed) / dbtracks_count * 1000
) / 10
if completion_percentage >= (last_completion_percentage + 10): if completion_percentage >= (last_completion_percentage + 10):
logger.info("Track matching is " + str(completion_percentage) + "% complete") logger.info("Track matching is " + str(completion_percentage) + "% complete")
@@ -257,9 +241,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
albumid = None albumid = None
if song['ArtistName'] and song['CleanName']: if track['ArtistName'] and track['CleanName']:
artist_name = song['ArtistName'] artist_name = track['ArtistName']
clean_name = song['CleanName'] clean_name = track['CleanName']
# Only update if artist is in the db # Only update if artist is in the db
if artist_name != prev_artist_name: if artist_name != prev_artist_name:
@@ -297,12 +281,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 # matching on CleanName should be enough, ensure it's the same artist just in case
# Update tracks # Update tracks
track = myDB.action('SELECT AlbumID, ArtistName FROM tracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone() dbtrack = myDB.action('SELECT AlbumID, ArtistName FROM tracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
if track: if dbtrack:
albumid = track['AlbumID'] albumid = dbtrack['AlbumID']
myDB.action( myDB.action(
'UPDATE tracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?', 'UPDATE tracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?',
[song['Location'], song['BitRate'], song['Format'], clean_name, artistid]) [track['Location'], track['BitRate'], track['Format'], clean_name, artistid])
# Update alltracks # Update alltracks
alltrack = myDB.action('SELECT AlbumID, ArtistName FROM alltracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone() alltrack = myDB.action('SELECT AlbumID, ArtistName FROM alltracks WHERE CleanName = ? AND ArtistID = ?', [clean_name, artistid]).fetchone()
@@ -310,26 +294,25 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
albumid = alltrack['AlbumID'] albumid = alltrack['AlbumID']
myDB.action( myDB.action(
'UPDATE alltracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?', 'UPDATE alltracks SET Location = ?, BitRate = ?, Format = ? WHERE CleanName = ? AND ArtistID = ?',
[song['Location'], song['BitRate'], song['Format'], clean_name, artistid]) [track['Location'], track['BitRate'], track['Format'], clean_name, artistid])
# Update have # Update have
controlValueDict2 = {'Location': song['Location']} controlValueDict2 = {'Location': track['Location']}
if albumid: if albumid:
newValueDict2 = {'Matched': albumid} newValueDict2 = {'Matched': albumid}
else: else:
newValueDict2 = {'Matched': "Failed"} newValueDict2 = {'Matched': "Failed"}
myDB.upsert("have", newValueDict2, controlValueDict2) myDB.upsert("have", newValueDict2, controlValueDict2)
# 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']]) # 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']])
logger.info('Completed matching tracks from directory: %s' % dir.decode(headphones.SYS_ENCODING, logger.info(f"Completed matching tracks from `{dir}`")
'replace'))
if not append or artistScan: if not append or artistScan:
logger.info('Updating scanned artist track counts') logger.info('Updating scanned artist track counts')
# Clean up the new artist list # Clean up the new artist list
unique_artists = {}.fromkeys(new_artists).keys() unique_artists = list({}.fromkeys(new_artists).keys())
# # Don't think we need to do this, check the db instead below # # 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 time
import threading import threading
import Queue import queue
import headphones.logger import headphones.logger
@@ -29,7 +29,7 @@ class TimedLock(object):
self.lock = threading.Lock() self.lock = threading.Lock()
self.last_used = 0 self.last_used = 0
self.minimum_delta = minimum_delta self.minimum_delta = minimum_delta
self.queue = Queue.Queue() self.queue = queue.Queue()
def __enter__(self): def __enter__(self):
""" """
@@ -47,7 +47,7 @@ class TimedLock(object):
seconds = self.queue.get(False) seconds = self.queue.get(False)
headphones.logger.debug('Sleeping %s (queued)', seconds) headphones.logger.debug('Sleeping %s (queued)', seconds)
time.sleep(seconds) time.sleep(seconds)
except Queue.Empty: except queue.Empty:
continue continue
self.queue.task_done() self.queue.task_done()
+2 -1
View File
@@ -153,7 +153,8 @@ def initLogger(console=False, log_dir=False, verbose=False):
file_formatter = logging.Formatter( file_formatter = logging.Formatter(
'%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S') '%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%d-%b-%Y %H:%M:%S')
file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE,
backupCount=MAX_FILES) backupCount=MAX_FILES,
encoding='utf8')
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter) 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 # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import htmlentitydefs import html.entities
import re import re
from headphones import logger, request from headphones import logger, request
@@ -25,7 +25,7 @@ def getLyrics(artist, song):
"fmt": 'xml' "fmt": 'xml'
} }
url = 'http://lyrics.wikia.com/api.php' url = 'https://lyrics.wikia.com/api.php'
data = request.request_minidom(url, params=params) data = request.request_minidom(url, params=params)
if not data: 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( '''<div class='lyricbox'><span style="padding:1em"><a href="/Category:Instrumental" title="Instrumental">''').search(
lyricspage) lyricspage)
if m: if m:
return u'(Instrumental)' return '(Instrumental)'
else: else:
logger.warn('Cannot find lyrics on: %s' % lyricsurl) logger.warn('Cannot find lyrics on: %s' % lyricsurl)
return return
@@ -72,7 +72,7 @@ def convert_html_entities(s):
name = hit[2:-1] name = hit[2:-1]
try: try:
entnum = int(name) entnum = int(name)
s = s.replace(hit, unichr(entnum)) s = s.replace(hit, chr(entnum))
except ValueError: except ValueError:
pass pass
@@ -83,7 +83,7 @@ def convert_html_entities(s):
hits.remove(amp) hits.remove(amp)
for hit in hits: for hit in hits:
name = hit[1:-1] name = hit[1:-1]
if name in htmlentitydefs.name2codepoint: if name in html.entities.name2codepoint:
s = s.replace(hit, unichr(htmlentitydefs.name2codepoint[name])) s = s.replace(hit, chr(html.entities.name2codepoint[name]))
s = s.replace(amp, "&") s = s.replace(amp, "&")
return s return s
+79 -97
View File
@@ -14,20 +14,14 @@
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
from headphones import logger, db, helpers from collections import OrderedDict
import musicbrainzngs
import headphones import headphones
import musicbrainzngs
import headphones.lock import headphones.lock
from headphones import logger, db, helpers
try:
# pylint:disable=E0611
# ignore this error because we are catching the ImportError
from collections import OrderedDict
# pylint:enable=E0611
except ImportError:
# Python 2.6.x fallback, from libs
from ordereddict import OrderedDict
mb_lock = headphones.lock.TimedLock(0) mb_lock = headphones.lock.TimedLock(0)
@@ -97,7 +91,7 @@ def findArtist(name, limit=1):
try: try:
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list'] artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
except ValueError as e: except ValueError as e:
if "at least one query term is required" in e.message: if "at least one query term is required" in str(e):
logger.error( logger.error(
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s", "Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
name) name)
@@ -112,9 +106,9 @@ def findArtist(name, limit=1):
return False return False
for result in artistResults: for result in artistResults:
if 'disambiguation' in result: if 'disambiguation' in result:
uniquename = unicode(result['sort-name'] + " (" + result['disambiguation'] + ")") uniquename = str(result['sort-name'] + " (" + result['disambiguation'] + ")")
else: else:
uniquename = unicode(result['sort-name']) uniquename = str(result['sort-name'])
if result['name'] != uniquename and limit == 1: if result['name'] != uniquename and limit == 1:
logger.info( logger.info(
'Found an artist with a disambiguation: %s - doing an album based search' % name) 'Found an artist with a disambiguation: %s - doing an album based search' % name)
@@ -124,20 +118,16 @@ def findArtist(name, limit=1):
'Cannot determine the best match from an artist/album search. Using top match instead') 'Cannot determine the best match from an artist/album search. Using top match instead')
artistlist.append({ artistlist.append({
# Just need the artist id if the limit is 1 # Just need the artist id if the limit is 1
# 'name': unicode(result['sort-name']), 'id': str(result['id']),
# '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: else:
artistlist.append(artistdict) artistlist.append(artistdict)
else: else:
artistlist.append({ artistlist.append({
'name': unicode(result['sort-name']), 'name': str(result['sort-name']),
'uniquename': uniquename, 'uniquename': uniquename,
'id': unicode(result['id']), 'id': str(result['id']),
'url': unicode("http://musicbrainz.org/artist/" + result['id']), 'url': str("https://musicbrainz.org/artist/" + result['id']),
# probably needs to be changed # probably needs to be changed
'score': int(result['ext:score']) 'score': int(result['ext:score'])
}) })
@@ -187,7 +177,7 @@ def findRelease(name, limit=1, artist=None):
if tracks: if tracks:
tracks += ' + ' tracks += ' + '
tracks += str(medium['track-count']) tracks += str(medium['track-count'])
for format, count in format_dict.items(): for format, count in list(format_dict.items()):
if formats: if formats:
formats += ' + ' formats += ' + '
if count > 1: if count > 1:
@@ -203,22 +193,22 @@ def findRelease(name, limit=1, artist=None):
rg_type = secondary_type rg_type = secondary_type
releaselist.append({ releaselist.append({
'uniquename': unicode(result['artist-credit'][0]['artist']['name']), 'uniquename': str(result['artist-credit'][0]['artist']['name']),
'title': unicode(title), 'title': str(title),
'id': unicode(result['artist-credit'][0]['artist']['id']), 'id': str(result['artist-credit'][0]['artist']['id']),
'albumid': unicode(result['id']), 'albumid': str(result['id']),
'url': unicode( 'url': str(
"http://musicbrainz.org/artist/" + result['artist-credit'][0]['artist']['id']), "https://musicbrainz.org/artist/" + result['artist-credit'][0]['artist']['id']),
# probably needs to be changed # probably needs to be changed
'albumurl': unicode("http://musicbrainz.org/release/" + result['id']), 'albumurl': str("https://musicbrainz.org/release/" + result['id']),
# probably needs to be changed # probably needs to be changed
'score': int(result['ext:score']), 'score': int(result['ext:score']),
'date': unicode(result['date']) if 'date' in result else '', 'date': str(result['date']) if 'date' in result else '',
'country': unicode(result['country']) if 'country' in result else '', 'country': str(result['country']) if 'country' in result else '',
'formats': unicode(formats), 'formats': str(formats),
'tracks': unicode(tracks), 'tracks': str(tracks),
'rgid': unicode(result['release-group']['id']), 'rgid': str(result['release-group']['id']),
'rgtype': unicode(rg_type) 'rgtype': str(rg_type)
}) })
return releaselist return releaselist
@@ -240,15 +230,15 @@ def findSeries(name, limit=1):
return False return False
for result in seriesResults: for result in seriesResults:
if 'disambiguation' in result: if 'disambiguation' in result:
uniquename = unicode(result['name'] + " (" + result['disambiguation'] + ")") uniquename = str(result['name'] + " (" + result['disambiguation'] + ")")
else: else:
uniquename = unicode(result['name']) uniquename = str(result['name'])
serieslist.append({ serieslist.append({
'uniquename': uniquename, 'uniquename': uniquename,
'name': unicode(result['name']), 'name': str(result['name']),
'type': unicode(result['type']), 'type': str(result['type']),
'id': unicode(result['id']), 'id': str(result['id']),
'url': unicode("http://musicbrainz.org/series/" + result['id']), 'url': str("https://musicbrainz.org/series/" + result['id']),
# probably needs to be changed # probably needs to be changed
'score': int(result['ext:score']) 'score': int(result['ext:score'])
}) })
@@ -284,19 +274,19 @@ def getArtist(artistid, extrasonly=False):
if not artist: if not artist:
return False return False
artist_dict['artist_name'] = unicode(artist['name']) artist_dict['artist_name'] = str(artist['name'])
releasegroups = [] releasegroups = []
if not extrasonly: if not extrasonly:
for rg in artist['release-group-list']: for rg in artist['release-group-list']:
if "secondary-type-list" in rg.keys(): # only add releases without a secondary type if "secondary-type-list" in list(rg.keys()): # only add releases without a secondary type
continue continue
releasegroups.append({ releasegroups.append({
'title': unicode(rg['title']), 'title': str(rg['title']),
'id': unicode(rg['id']), 'id': str(rg['id']),
'url': u"http://musicbrainz.org/release-group/" + rg['id'], 'url': "https://musicbrainz.org/release-group/" + rg['id'],
'type': unicode(rg['type']) 'type': str(rg['type'])
}) })
# See if we need to grab extras. Artist specific extras take precedence over global option # See if we need to grab extras. Artist specific extras take precedence over global option
@@ -314,7 +304,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) # Need to convert extras string from something like '2,5.6' to ['ep','live','remix'] (append new extras to end)
if db_artist['Extras']: if db_artist['Extras']:
extras = map(int, db_artist['Extras'].split(',')) extras = list(map(int, db_artist['Extras'].split(',')))
else: else:
extras = [] extras = []
extras_list = headphones.POSSIBLE_EXTRAS extras_list = headphones.POSSIBLE_EXTRAS
@@ -354,10 +344,10 @@ def getArtist(artistid, extrasonly=False):
rg_type = secondary_type rg_type = secondary_type
releasegroups.append({ releasegroups.append({
'title': unicode(rg['title']), 'title': str(rg['title']),
'id': unicode(rg['id']), 'id': str(rg['id']),
'url': u"http://musicbrainz.org/release-group/" + rg['id'], 'url': "https://musicbrainz.org/release-group/" + rg['id'],
'type': unicode(rg_type) 'type': str(rg_type)
}) })
artist_dict['releasegroups'] = releasegroups artist_dict['releasegroups'] = releasegroups
return artist_dict return artist_dict
@@ -382,10 +372,10 @@ def getSeries(seriesid):
return False return False
if 'disambiguation' in series: if 'disambiguation' in series:
series_dict['artist_name'] = unicode( series_dict['artist_name'] = str(
series['name'] + " (" + unicode(series['disambiguation']) + ")") series['name'] + " (" + str(series['disambiguation']) + ")")
else: else:
series_dict['artist_name'] = unicode(series['name']) series_dict['artist_name'] = str(series['name'])
releasegroups = [] releasegroups = []
@@ -448,42 +438,42 @@ def getRelease(releaseid, include_artist_info=True):
if not results: if not results:
return False return False
release['title'] = unicode(results['title']) release['title'] = str(results['title'])
release['id'] = unicode(results['id']) release['id'] = str(results['id'])
release['asin'] = unicode(results['asin']) if 'asin' in results else None release['asin'] = str(results['asin']) if 'asin' in results else None
release['date'] = unicode(results['date']) if 'date' in results else None release['date'] = str(results['date']) if 'date' in results else None
try: try:
release['format'] = unicode(results['medium-list'][0]['format']) release['format'] = str(results['medium-list'][0]['format'])
except: except:
release['format'] = u'Unknown' release['format'] = 'Unknown'
try: try:
release['country'] = unicode(results['country']) release['country'] = str(results['country'])
except: except:
release['country'] = u'Unknown' release['country'] = 'Unknown'
if include_artist_info: if include_artist_info:
if 'release-group' in results: if 'release-group' in results:
release['rgid'] = unicode(results['release-group']['id']) release['rgid'] = str(results['release-group']['id'])
release['rg_title'] = unicode(results['release-group']['title']) release['rg_title'] = str(results['release-group']['title'])
try: try:
release['rg_type'] = unicode(results['release-group']['type']) release['rg_type'] = str(results['release-group']['type'])
if release['rg_type'] == 'Album' and 'secondary-type-list' in results[ if release['rg_type'] == 'Album' and 'secondary-type-list' in results[
'release-group']: 'release-group']:
secondary_type = unicode(results['release-group']['secondary-type-list'][0]) secondary_type = str(results['release-group']['secondary-type-list'][0])
if secondary_type != release['rg_type']: if secondary_type != release['rg_type']:
release['rg_type'] = secondary_type release['rg_type'] = secondary_type
except KeyError: except KeyError:
release['rg_type'] = u'Unknown' release['rg_type'] = 'Unknown'
else: else:
logger.warn("Release " + releaseid + "had no ReleaseGroup associated") logger.warn("Release " + releaseid + "had no ReleaseGroup associated")
release['artist_name'] = unicode(results['artist-credit'][0]['artist']['name']) release['artist_name'] = str(results['artist-credit'][0]['artist']['name'])
release['artist_id'] = unicode(results['artist-credit'][0]['artist']['id']) release['artist_id'] = str(results['artist-credit'][0]['artist']['id'])
release['tracks'] = getTracksFromRelease(results) release['tracks'] = getTracksFromRelease(results)
@@ -529,7 +519,7 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
force_repackage1 = 0 force_repackage1 = 0
if len(results) != 0: if len(results) != 0:
for release_mark in results: for release_mark in results:
release_list.append(unicode(release_mark['id'])) release_list.append(str(release_mark['id']))
release_title = release_mark['title'] release_title = release_mark['title']
remove_missing_releases = myDB.action("SELECT ReleaseID FROM allalbums WHERE AlbumID=?", remove_missing_releases = myDB.action("SELECT ReleaseID FROM allalbums WHERE AlbumID=?",
[rgid]) [rgid])
@@ -561,31 +551,31 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
# DELETE all references to this release since we're updating it anyway. # 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 allalbums WHERE ReleaseID=?', [rel_id_check])
myDB.action('DELETE from alltracks WHERE ReleaseID=?', [rel_id_check]) myDB.action('DELETE from alltracks WHERE ReleaseID=?', [rel_id_check])
release['AlbumTitle'] = unicode(releasedata['title']) release['AlbumTitle'] = str(releasedata['title'])
release['AlbumID'] = unicode(rgid) release['AlbumID'] = str(rgid)
release['AlbumASIN'] = unicode(releasedata['asin']) if 'asin' in releasedata else None release['AlbumASIN'] = str(releasedata['asin']) if 'asin' in releasedata else None
release['ReleaseDate'] = unicode(releasedata['date']) if 'date' in releasedata else None release['ReleaseDate'] = str(releasedata['date']) if 'date' in releasedata else None
release['ReleaseID'] = releasedata['id'] release['ReleaseID'] = releasedata['id']
if 'release-group' not in releasedata: if 'release-group' not in releasedata:
raise Exception('No release group associated with release id ' + releasedata[ raise Exception('No release group associated with release id ' + releasedata[
'id'] + ' album id' + rgid) 'id'] + ' album id' + rgid)
release['Type'] = unicode(releasedata['release-group']['type']) release['Type'] = str(releasedata['release-group']['type'])
if release['Type'] == 'Album' and 'secondary-type-list' in releasedata['release-group']: if release['Type'] == 'Album' and 'secondary-type-list' in releasedata['release-group']:
secondary_type = unicode(releasedata['release-group']['secondary-type-list'][0]) secondary_type = str(releasedata['release-group']['secondary-type-list'][0])
if secondary_type != release['Type']: if secondary_type != release['Type']:
release['Type'] = secondary_type release['Type'] = secondary_type
# making the assumption that the most important artist will be first in the list # making the assumption that the most important artist will be first in the list
if 'artist-credit' in releasedata: if 'artist-credit' in releasedata:
release['ArtistID'] = unicode(releasedata['artist-credit'][0]['artist']['id']) release['ArtistID'] = str(releasedata['artist-credit'][0]['artist']['id'])
release['ArtistName'] = unicode(releasedata['artist-credit-phrase']) release['ArtistName'] = str(releasedata['artist-credit-phrase'])
else: else:
logger.warn('Release ' + releasedata['id'] + ' has no Artists associated.') logger.warn('Release ' + releasedata['id'] + ' has no Artists associated.')
return False return False
release['ReleaseCountry'] = unicode( release['ReleaseCountry'] = str(
releasedata['country']) if 'country' in releasedata else u'Unknown' releasedata['country']) if 'country' in releasedata else 'Unknown'
# assuming that the list will contain media and that the format will be consistent # assuming that the list will contain media and that the format will be consistent
try: try:
additional_medium = '' additional_medium = ''
@@ -600,9 +590,9 @@ def get_new_releases(rgid, includeExtras=False, forcefull=False):
disc_number = str(medium_count) + 'x' disc_number = str(medium_count) + 'x'
packaged_medium = disc_number + releasedata['medium-list'][0][ packaged_medium = disc_number + releasedata['medium-list'][0][
'format'] + additional_medium 'format'] + additional_medium
release['ReleaseFormat'] = unicode(packaged_medium) release['ReleaseFormat'] = str(packaged_medium)
except: except:
release['ReleaseFormat'] = u'Unknown' release['ReleaseFormat'] = 'Unknown'
release['Tracks'] = getTracksFromRelease(releasedata) release['Tracks'] = getTracksFromRelease(releasedata)
@@ -684,14 +674,14 @@ def getTracksFromRelease(release):
for medium in release['medium-list']: for medium in release['medium-list']:
for track in medium['track-list']: for track in medium['track-list']:
try: try:
track_title = unicode(track['title']) track_title = str(track['title'])
except: except:
track_title = unicode(track['recording']['title']) track_title = str(track['recording']['title'])
tracks.append({ tracks.append({
'number': totalTracks, 'number': totalTracks,
'title': track_title, 'title': track_title,
'id': unicode(track['recording']['id']), 'id': str(track['recording']['id']),
'url': u"http://musicbrainz.org/track/" + track['recording']['id'], 'url': "https://musicbrainz.org/track/" + track['recording']['id'],
'duration': int(track['length']) if 'length' in track else 0 'duration': int(track['length']) if 'length' in track else 0
}) })
totalTracks += 1 totalTracks += 1
@@ -733,15 +723,7 @@ def findArtistbyAlbum(name):
for releaseGroup in results: for releaseGroup in results:
newArtist = releaseGroup['artist-credit'][0]['artist'] newArtist = releaseGroup['artist-credit'][0]['artist']
# Only need the artist ID if we're doing an artist+album lookup # Only need the artist ID if we're doing an artist+album lookup
# if 'disambiguation' in newArtist: artist_dict['id'] = str(newArtist['id'])
# 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 return artist_dict
@@ -768,7 +750,7 @@ def findAlbumID(artist=None, album=None):
if len(results) < 1: if len(results) < 1:
return False return False
rgid = unicode(results[0]['id']) rgid = str(results[0]['id'])
return rgid return rgid
+1 -1
View File
@@ -35,7 +35,7 @@ def update(artistid, artist_name, release_groups):
headers = { 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'} '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 = "http://www.metacritic.com/person/" + mc_artist_name + "?filter-options=music&sort_options=date&num_items=100" url = "https://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) res = request.request_soup(url, headers=headers, whitelist_status_code=404)
+25 -22
View File
@@ -17,8 +17,8 @@
Track/album metadata handling routines. Track/album metadata handling routines.
""" """
from __future__ import print_function
from beets.mediafile import MediaFile, UnreadableFileError from mediafile import MediaFile, UnreadableFileError
import headphones import headphones
from headphones import logger from headphones import logger
import os.path import os.path
@@ -60,7 +60,7 @@ class MetadataDict(dict):
self._lower = {} self._lower = {}
if seq is not None: if seq is not None:
try: try:
self.add_items(seq.iteritems()) self.add_items(iter(seq.items()))
except KeyError: except KeyError:
self.add_items(seq) self.add_items(seq)
@@ -79,6 +79,7 @@ class Vars:
Metadata $variable names (only ones set explicitly by headphones). Metadata $variable names (only ones set explicitly by headphones).
""" """
DISC = '$Disc' DISC = '$Disc'
DISC_TOTAL = '$DiscTotal'
TRACK = '$Track' TRACK = '$Track'
TITLE = '$Title' TITLE = '$Title'
ARTIST = '$Artist' ARTIST = '$Artist'
@@ -103,11 +104,11 @@ def _verify_var_type(val):
""" """
Check if type of value is allowed as a variable in pathname substitution. Check if type of value is allowed as a variable in pathname substitution.
""" """
return isinstance(val, (basestring, int, float, datetime.date)) return isinstance(val, (str, int, float, datetime.date))
def _as_str(val): def _as_str(val):
if isinstance(val, basestring): if isinstance(val, str):
return val return val
else: else:
return str(val) return str(val)
@@ -134,7 +135,7 @@ def _row_to_dict(row, d):
""" """
Populate dict with database row fields. Populate dict with database row fields.
""" """
for fld in row.keys(): for fld in list(row.keys()):
val = row[fld] val = row[fld]
if val is None: if val is None:
val = '' val = ''
@@ -171,7 +172,7 @@ def _lower(s):
return None return None
def file_metadata(path, release): def file_metadata(path, release, single_disc_ignore=False):
# type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool] # type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool]
""" """
Prepare metadata dictionary for path substitution, based on file name, Prepare metadata dictionary for path substitution, based on file name,
@@ -184,9 +185,7 @@ def file_metadata(path, release):
try: try:
f = MediaFile(path) f = MediaFile(path)
except UnreadableFileError as ex: except UnreadableFileError as ex:
logger.info("MediaFile couldn't parse: %s (%s)", logger.info(f"MediaFile couldn't parse {path}: {e}")
path.decode(headphones.SYS_ENCODING, 'replace'),
str(ex))
return None, None return None, None
res = MetadataDict() res = MetadataDict()
@@ -196,7 +195,13 @@ def file_metadata(path, release):
_row_to_dict(release, res) _row_to_dict(release, res)
date, year = _date_year(release) date, year = _date_year(release)
if not f.disc:
if not f.disctotal or (f.disctotal == 1 and single_disc_ignore):
disc_total = ''
else:
disc_total = '%d' % f.disctotal
if not f.disc or (f.disctotal == 1 and single_disc_ignore):
disc_number = '' disc_number = ''
else: else:
disc_number = '%d' % f.disc disc_number = '%d' % f.disc
@@ -207,8 +212,7 @@ def file_metadata(path, release):
track_number = '%02d' % f.track track_number = '%02d' % f.track
if not f.title: if not f.title:
basename = os.path.basename( basename = os.path.basename(path)
path.decode(headphones.SYS_ENCODING, 'replace'))
title = os.path.splitext(basename)[0] title = os.path.splitext(basename)[0]
from_metadata = False from_metadata = False
else: else:
@@ -229,6 +233,7 @@ def file_metadata(path, release):
album_title = release['AlbumTitle'] album_title = release['AlbumTitle']
override_values = { override_values = {
Vars.DISC: disc_number, Vars.DISC: disc_number,
Vars.DISC_TOTAL: disc_total,
Vars.TRACK: track_number, Vars.TRACK: track_number,
Vars.TITLE: title, Vars.TITLE: title,
Vars.ARTIST: artist_name, Vars.ARTIST: artist_name,
@@ -242,7 +247,7 @@ def file_metadata(path, release):
Vars.SORT_ARTIST_LOWER: _lower(sort_name), Vars.SORT_ARTIST_LOWER: _lower(sort_name),
Vars.ALBUM_LOWER: _lower(album_title), Vars.ALBUM_LOWER: _lower(album_title),
} }
res.add_items(override_values.iteritems()) res.add_items(iter(override_values.items()))
return res, from_metadata return res, from_metadata
@@ -252,7 +257,7 @@ def _intersect(d1, d2):
Create intersection (common part) of two dictionaries. Create intersection (common part) of two dictionaries.
""" """
res = {} res = {}
for key, val in d1.iteritems(): for key, val in d1.items():
if key in d2 and d2[key] == val: if key in d2 and d2[key] == val:
res[key] = val res[key] = val
return res return res
@@ -284,21 +289,19 @@ def album_metadata(path, release, common_tags):
sort_name = artist sort_name = artist
if not sort_name or sort_name[0].isdigit(): if not sort_name or sort_name[0].isdigit():
first_char = u'0-9' first_char = '0-9'
else: else:
first_char = sort_name[0] first_char = sort_name[0]
orig_folder = u'' orig_folder = ''
# Get from temp path # Get from temp path
if "_@hp@_" in path: if "_@hp@_" in path:
orig_folder = path.rsplit("headphones_", 1)[1].split("_@hp@_")[0] orig_folder = path.rsplit("headphones_", 1)[1].split("_@hp@_")[0]
orig_folder = orig_folder.decode(headphones.SYS_ENCODING, 'replace')
else: else:
for r, d, f in os.walk(path): for r, d, f in os.walk(path):
try: try:
orig_folder = os.path.basename( orig_folder = os.path.basename(os.path.normpath(r))
os.path.normpath(r).decode(headphones.SYS_ENCODING, 'replace'))
break break
except: except:
pass pass
@@ -320,7 +323,7 @@ def album_metadata(path, release, common_tags):
Vars.ORIGINAL_FOLDER_LOWER: _lower(orig_folder) Vars.ORIGINAL_FOLDER_LOWER: _lower(orig_folder)
} }
res = MetadataDict(common_tags) res = MetadataDict(common_tags)
res.add_items(override_values.iteritems()) res.add_items(iter(override_values.items()))
return res return res
@@ -345,7 +348,7 @@ def albumart_metadata(release, common_tags):
Vars.ALBUM_LOWER: _lower(album) Vars.ALBUM_LOWER: _lower(album)
} }
res = MetadataDict(common_tags) res = MetadataDict(common_tags)
res.add_items(override_values.iteritems()) res.add_items(iter(override_values.items()))
return res return res
+6 -6
View File
@@ -22,7 +22,7 @@ import headphones.helpers as _hp
from headphones.metadata import MetadataDict from headphones.metadata import MetadataDict
import datetime import datetime
from unittestcompat import TestCase from .unittestcompat import TestCase
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>" __author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
@@ -50,7 +50,7 @@ class _MockDatabaseRow(object):
self._dict = dict(d) self._dict = dict(d)
def keys(self): def keys(self):
return self._dict.iterkeys() return iter(self._dict.keys())
def __getitem__(self, item): def __getitem__(self, item):
return self._dict[item] return self._dict[item]
@@ -63,9 +63,9 @@ class MetadataTest(TestCase):
def test_metadata_dict_ci(self): def test_metadata_dict_ci(self):
"""MetadataDict: case-insensitive lookup""" """MetadataDict: case-insensitive lookup"""
expected = u'naïve' expected = 'naïve'
key_var = '$TitlE' key_var = '$TitlE'
m = MetadataDict({key_var.lower(): u'naïve'}) m = MetadataDict({key_var.lower(): 'naïve'})
self.assertFalse('$track' in m) self.assertFalse('$track' in m)
self.assertTrue('$tITLe' in m, "cross-case lookup with 'in'") self.assertTrue('$tITLe' in m, "cross-case lookup with 'in'")
self.assertEqual(m[key_var], expected, "cross-case lookup success") self.assertEqual(m[key_var], expected, "cross-case lookup success")
@@ -74,7 +74,7 @@ class MetadataTest(TestCase):
def test_metadata_dict_cs(self): def test_metadata_dict_cs(self):
"""MetadataDice: case-preserving lookup""" """MetadataDice: case-preserving lookup"""
expected_var = u'NaïVe' expected_var = 'NaïVe'
key_var = '$TitlE' key_var = '$TitlE'
m = MetadataDict({ m = MetadataDict({
key_var.lower(): expected_var.lower(), key_var.lower(): expected_var.lower(),
@@ -171,5 +171,5 @@ class MetadataTest(TestCase):
res = _hp.pattern_substitute( res = _hp.pattern_substitute(
"/music/$First/$Artist/$Artist - $Album{ [$Year]}", md, True) "/music/$First/$Artist/$Artist - $Album{ [$Year]}", md, True)
self.assertEqual(res, u"/music/A/artist/artist - Album", self.assertEqual(res, "/music/A/artist/artist - Album",
"check correct rendering of None via pattern_substitute()") "check correct rendering of None via pattern_substitute()")
+23 -30
View File
@@ -14,6 +14,7 @@
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import time import time
import datetime
import shutil import shutil
import subprocess import subprocess
import multiprocessing import multiprocessing
@@ -21,11 +22,11 @@ import multiprocessing
import os import os
import headphones import headphones
from headphones import logger from headphones import logger
from beets.mediafile import MediaFile from mediafile import MediaFile
# xld # xld
import getXldProfile from . import getXldProfile
def encode(albumPath): def encode(albumPath):
@@ -63,8 +64,7 @@ def encode(albumPath):
for music in f: for music in f:
if any(music.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS): if any(music.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
if not use_xld: if not use_xld:
encoderFormat = headphones.CONFIG.ENCODEROUTPUTFORMAT.encode( encoderFormat = headphones.CONFIG.ENCODEROUTPUTFORMAT
headphones.SYS_ENCODING)
else: else:
xldMusicFile = os.path.join(r, music) xldMusicFile = os.path.join(r, music)
xldInfoMusic = MediaFile(xldMusicFile) xldInfoMusic = MediaFile(xldMusicFile)
@@ -86,7 +86,7 @@ def encode(albumPath):
musicTempFiles.append(os.path.join(tempDirEncode, musicTemp)) musicTempFiles.append(os.path.join(tempDirEncode, musicTemp))
if headphones.CONFIG.ENCODER_PATH: if headphones.CONFIG.ENCODER_PATH:
encoder = headphones.CONFIG.ENCODER_PATH.encode(headphones.SYS_ENCODING) encoder = headphones.CONFIG.ENCODER_PATH
else: else:
if use_xld: if use_xld:
encoder = os.path.join('/Applications', 'xld') encoder = os.path.join('/Applications', 'xld')
@@ -117,18 +117,17 @@ def encode(albumPath):
if use_xld: if use_xld:
if xldBitrate and (infoMusic.bitrate / 1000 <= xldBitrate): if xldBitrate and (infoMusic.bitrate / 1000 <= xldBitrate):
logger.info('%s has bitrate <= %skb, will not be re-encoded', logger.info(f"{music} has bitrate <= {xldBitrate}kb, will not be re-encoded")
music.decode(headphones.SYS_ENCODING, 'replace'), xldBitrate)
else: else:
encode = True encode = True
elif headphones.CONFIG.ENCODER == 'lame': elif headphones.CONFIG.ENCODER == 'lame':
if not any( if not any(
music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + x) for x music.lower().endswith('.' + x) for x
in ["mp3", "wav"]): in ["mp3", "wav"]):
logger.warn('Lame cannot encode %s format for %s, use ffmpeg', logger.warn('Lame cannot encode %s format for %s, use ffmpeg',
os.path.splitext(music)[1], music) os.path.splitext(music)[1], music)
else: else:
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.mp3') and ( if music.lower().endswith('.mp3') and (
int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE): int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE):
logger.info('%s has bitrate <= %skb, will not be re-encoded', music, logger.info('%s has bitrate <= %skb, will not be re-encoded', music,
headphones.CONFIG.BITRATE) headphones.CONFIG.BITRATE)
@@ -136,13 +135,12 @@ def encode(albumPath):
encode = True encode = True
else: else:
if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'ogg': if headphones.CONFIG.ENCODEROUTPUTFORMAT == 'ogg':
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.ogg'): if music.lower().endswith('.ogg'):
logger.warn('Cannot re-encode .ogg %s', logger.warn(f"Cannot re-encode .ogg {music}")
music.decode(headphones.SYS_ENCODING, 'replace'))
else: else:
encode = True encode = True
else: else:
if music.decode(headphones.SYS_ENCODING, 'replace').lower().endswith('.' + headphones.CONFIG.ENCODEROUTPUTFORMAT) and (int(infoMusic.bitrate / 1000) <= headphones.CONFIG.BITRATE): if music.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) logger.info('%s has bitrate <= %skb, will not be re-encoded', music, headphones.CONFIG.BITRATE)
else: else:
encode = True encode = True
@@ -185,13 +183,13 @@ def encode(albumPath):
# Retrieve the results # Retrieve the results
results = results.get() results = results.get()
else: else:
results = map(command_map, jobs) results = list(map(command_map, jobs))
# The results are either True or False, so determine if one is False # The results are either True or False, so determine if one is False
encoder_failed = not all(results) encoder_failed = not all(results)
musicFiles = filter(None, musicFiles) musicFiles = [_f for _f in musicFiles if _f]
musicTempFiles = filter(None, musicTempFiles) musicTempFiles = [_f for _f in musicTempFiles if _f]
# check all files to be encoded now exist in temp directory # check all files to be encoded now exist in temp directory
if not encoder_failed and musicTempFiles: if not encoder_failed and musicTempFiles:
@@ -352,36 +350,31 @@ def command(encoder, musicSource, musicDest, albumPath, xldProfile):
startupinfo.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW startupinfo.dwFlags |= subprocess._subprocess.STARTF_USESHOWWINDOW
# Encode # Encode
logger.info('Encoding %s...' % (musicSource.decode(headphones.SYS_ENCODING, 'replace'))) logger.info(f"Encoding {musicSource}")
logger.debug(subprocess.list2cmdline(cmd)) logger.debug(subprocess.list2cmdline(cmd))
process = subprocess.Popen(cmd, startupinfo=startupinfo, process = subprocess.Popen(cmd, startupinfo=startupinfo,
stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE, stdin=open(os.devnull, 'rb'), stdout=subprocess.PIPE,
stderr=subprocess.PIPE) stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate(headphones.CONFIG.ENCODER) stdout, stderr = process.communicate(headphones.CONFIG.ENCODER)
# Error if return code not zero # Error if return code not zero
if process.returncode: if process.returncode:
logger.error( logger.error(f"Encoding failed for {musicSource}")
'Encoding failed for %s' % (musicSource.decode(headphones.SYS_ENCODING, 'replace'))) out = stdout or stderr
out = stdout if stdout else stderr
out = out.decode(headphones.SYS_ENCODING, 'replace')
outlast2lines = '\n'.join(out.splitlines()[-2:]) outlast2lines = '\n'.join(out.splitlines()[-2:])
logger.error('%s error details: %s' % (headphones.CONFIG.ENCODER, outlast2lines)) logger.error(f"{headphones.CONFIG.ENCODER} error details: {outlast2lines}")
out = out.rstrip("\n") out = out.rstrip("\n")
logger.debug(out) logger.debug(out)
encoded = False encoded = False
else: else:
logger.info('%s encoded in %s', musicSource, getTimeEncode(startMusicTime)) logger.info(f"{musicSource} encoded in {getTimeEncode(startMusicTime)}")
encoded = True encoded = True
return encoded return encoded
def getTimeEncode(start): def getTimeEncode(start):
seconds = int(time.time() - start) finish = time.time()
hours = seconds / 3600 seconds = int(finish - start)
seconds -= 3600 * hours return datetime.timedelta(seconds=seconds)
minutes = seconds / 60
seconds -= 60 * minutes
return "%02d:%02d:%02d" % (hours, minutes, seconds)
+60 -75
View File
@@ -1,28 +1,13 @@
# This file is part of Headphones. from urllib.parse import urlencode, quote_plus
# import urllib.request, urllib.parse, urllib.error
# 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 subprocess
import json import json
from email.mime.text import MIMEText from email.mime.text import MIMEText
import smtplib import smtplib
import email.utils import email.utils
from httplib import HTTPSConnection from http.client import HTTPSConnection
from urlparse import parse_qsl from urllib.parse import parse_qsl
import urllib2 import urllib.request, urllib.error, urllib.parse
import requests as requests import requests as requests
import os.path import os.path
@@ -31,8 +16,8 @@ from pynma import pynma
import cherrypy import cherrypy
import headphones import headphones
import gntp.notifier import gntp.notifier
import oauth2 as oauth #import oauth2 as oauth
import pythontwitter as twitter import twitter
class GROWL(object): class GROWL(object):
@@ -81,10 +66,10 @@ class GROWL(object):
try: try:
growl.register() growl.register()
except gntp.notifier.errors.NetworkError: except gntp.notifier.errors.NetworkError:
logger.warning(u'Growl notification failed: network error') logger.warning('Growl notification failed: network error')
return return
except gntp.notifier.errors.AuthError: except gntp.notifier.errors.AuthError:
logger.warning(u'Growl notification failed: authentication error') logger.warning('Growl notification failed: authentication error')
return return
# Fix message # Fix message
@@ -105,10 +90,10 @@ class GROWL(object):
icon=image icon=image
) )
except gntp.notifier.errors.NetworkError: except gntp.notifier.errors.NetworkError:
logger.warning(u'Growl notification failed: network error') logger.warning('Growl notification failed: network error')
return return
logger.info(u"Growl notifications sent.") logger.info("Growl notifications sent.")
def updateLibrary(self): def updateLibrary(self):
# For uniformity reasons not removed # For uniformity reasons not removed
@@ -157,13 +142,13 @@ class PROWL(object):
request_status = response.status request_status = response.status
if request_status == 200: if request_status == 200:
logger.info(u"Prowl notifications sent.") logger.info("Prowl notifications sent.")
return True return True
elif request_status == 401: elif request_status == 401:
logger.info(u"Prowl auth failed: %s" % response.reason) logger.info("Prowl auth failed: %s" % response.reason)
return False return False
else: else:
logger.info(u"Prowl notification failed.") logger.info("Prowl notification failed.")
return False return False
def updateLibrary(self): def updateLibrary(self):
@@ -202,7 +187,7 @@ class XBMC(object):
self.password = headphones.CONFIG.XBMC_PASSWORD self.password = headphones.CONFIG.XBMC_PASSWORD
def _sendhttp(self, host, command): def _sendhttp(self, host, command):
url_command = urllib.urlencode(command) url_command = urllib.parse.urlencode(command)
url = host + '/xbmcCmds/xbmcHttp/?' + url_command url = host + '/xbmcCmds/xbmcHttp/?' + url_command
if self.password: if self.password:
@@ -295,10 +280,10 @@ class LMS(object):
content = {'Content-Type': 'application/json'} content = {'Content-Type': 'application/json'}
req = urllib2.Request(host + '/jsonrpc.js', data, content) req = urllib.request.Request(host + '/jsonrpc.js', data, content)
try: try:
handle = urllib2.urlopen(req) handle = urllib.request.urlopen(req)
except Exception as e: except Exception as e:
logger.warn('Error opening LMS url: %s' % e) logger.warn('Error opening LMS url: %s' % e)
return return
@@ -424,7 +409,7 @@ class Plex(object):
sections = r.getElementsByTagName('Directory') sections = r.getElementsByTagName('Directory')
if not sections: if not sections:
logger.info(u"Plex Media Server not running on: " + host) logger.info("Plex Media Server not running on: " + host)
return False return False
for s in sections: for s in sections:
@@ -483,9 +468,9 @@ class NMA(object):
api = headphones.CONFIG.NMA_APIKEY api = headphones.CONFIG.NMA_APIKEY
nma_priority = headphones.CONFIG.NMA_PRIORITY nma_priority = headphones.CONFIG.NMA_PRIORITY
logger.debug(u"NMA title: " + title) logger.debug("NMA title: " + title)
logger.debug(u"NMA API: " + api) logger.debug("NMA API: " + api)
logger.debug(u"NMA Priority: " + str(nma_priority)) logger.debug("NMA Priority: " + str(nma_priority))
if snatched: if snatched:
event = snatched + " snatched!" event = snatched + " snatched!"
@@ -495,8 +480,8 @@ class NMA(object):
message = "Headphones has downloaded and postprocessed: " + \ message = "Headphones has downloaded and postprocessed: " + \
artist + ' [' + album + ']' artist + ' [' + album + ']'
logger.debug(u"NMA event: " + event) logger.debug("NMA event: " + event)
logger.debug(u"NMA message: " + message) logger.debug("NMA message: " + message)
batch = False batch = False
@@ -510,8 +495,8 @@ class NMA(object):
response = p.push(title, event, message, priority=nma_priority, response = p.push(title, event, message, priority=nma_priority,
batch_mode=batch) batch_mode=batch)
if not response[api][u'code'] == u'200': if not response[api]['code'] == '200':
logger.error(u'Could not send notification to NotifyMyAndroid') logger.error('Could not send notification to NotifyMyAndroid')
return False return False
else: else:
return True return True
@@ -543,10 +528,10 @@ class PUSHBULLET(object):
data=json.dumps(data)) data=json.dumps(data))
if response: if response:
logger.info(u"PushBullet notifications sent.") logger.info("PushBullet notifications sent.")
return True return True
else: else:
logger.info(u"PushBullet notification failed.") logger.info("PushBullet notification failed.")
return False return False
@@ -557,9 +542,9 @@ class PUSHALOT(object):
pushalot_authorizationtoken = headphones.CONFIG.PUSHALOT_APIKEY pushalot_authorizationtoken = headphones.CONFIG.PUSHALOT_APIKEY
logger.debug(u"Pushalot event: " + event) logger.debug("Pushalot event: " + event)
logger.debug(u"Pushalot message: " + message) logger.debug("Pushalot message: " + message)
logger.debug(u"Pushalot api: " + pushalot_authorizationtoken) logger.debug("Pushalot api: " + pushalot_authorizationtoken)
http_handler = HTTPSConnection("pushalot.com") http_handler = HTTPSConnection("pushalot.com")
@@ -576,18 +561,18 @@ class PUSHALOT(object):
response = http_handler.getresponse() response = http_handler.getresponse()
request_status = response.status request_status = response.status
logger.debug(u"Pushalot response status: %r" % request_status) logger.debug("Pushalot response status: %r" % request_status)
logger.debug(u"Pushalot response headers: %r" % response.getheaders()) logger.debug("Pushalot response headers: %r" % response.getheaders())
logger.debug(u"Pushalot response body: %r" % response.read()) logger.debug("Pushalot response body: %r" % response.read())
if request_status == 200: if request_status == 200:
logger.info(u"Pushalot notifications sent.") logger.info("Pushalot notifications sent.")
return True return True
elif request_status == 410: elif request_status == 410:
logger.info(u"Pushalot auth failed: %s" % response.reason) logger.info("Pushalot auth failed: %s" % response.reason)
return False return False
else: else:
logger.info(u"Pushalot notification failed.") logger.info("Pushalot notification failed.")
return False return False
@@ -618,7 +603,7 @@ class JOIN(object):
else: else:
self.url += '&deviceId={deviceid}' self.url += '&deviceId={deviceid}'
response = urllib2.urlopen(self.url.format(apikey=self.apikey, response = urllib.request.urlopen(self.url.format(apikey=self.apikey,
title=quote_plus(event), title=quote_plus(event),
text=quote_plus( text=quote_plus(
message.encode( message.encode(
@@ -627,10 +612,10 @@ class JOIN(object):
deviceid=self.deviceid)) deviceid=self.deviceid))
if response: if response:
logger.info(u"Join notifications sent.") logger.info("Join notifications sent.")
return True return True
else: else:
logger.error(u"Join notification failed.") logger.error("Join notification failed.")
return False return False
@@ -669,7 +654,7 @@ class Synoindex(object):
out, error = p.communicate() out, error = p.communicate()
# synoindex never returns any codes other than '0', # synoindex never returns any codes other than '0',
# highly irritating # highly irritating
except OSError, e: except OSError as e:
logger.warn("Error sending notification: %s" % str(e)) logger.warn("Error sending notification: %s" % str(e))
def notify_multiple(self, path_list): def notify_multiple(self, path_list):
@@ -710,10 +695,10 @@ class PUSHOVER(object):
headers=headers, data=data) headers=headers, data=data)
if response: if response:
logger.info(u"Pushover notifications sent.") logger.info("Pushover notifications sent.")
return True return True
else: else:
logger.error(u"Pushover notification failed.") logger.error("Pushover notification failed.")
return False return False
def updateLibrary(self): def updateLibrary(self):
@@ -832,7 +817,7 @@ class TwitterNotifier(object):
access_token_key = headphones.CONFIG.TWITTER_USERNAME access_token_key = headphones.CONFIG.TWITTER_USERNAME
access_token_secret = headphones.CONFIG.TWITTER_PASSWORD access_token_secret = headphones.CONFIG.TWITTER_PASSWORD
logger.info(u"Sending tweet: " + message) logger.info("Sending tweet: " + message)
api = twitter.Api(username, password, access_token_key, api = twitter.Api(username, password, access_token_key,
access_token_secret) access_token_secret)
@@ -840,7 +825,7 @@ class TwitterNotifier(object):
try: try:
api.PostUpdate(message) api.PostUpdate(message)
except Exception as e: except Exception as e:
logger.info(u"Error Sending Tweet: %s" % e) logger.info("Error Sending Tweet: %s" % e)
return False return False
return True return True
@@ -935,10 +920,10 @@ class BOXCAR(object):
def notify(self, title, message, rgid=None): def notify(self, title, message, rgid=None):
try: try:
if rgid: if rgid:
message += '<br></br><a href="http://musicbrainz.org/' \ message += '<br></br><a href="https://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid 'release-group/%s">MusicBrainz</a>' % rgid
data = urllib.urlencode({ data = urllib.parse.urlencode({
'user_credentials': headphones.CONFIG.BOXCAR_TOKEN, 'user_credentials': headphones.CONFIG.BOXCAR_TOKEN,
'notification[title]': title.encode('utf-8'), 'notification[title]': title.encode('utf-8'),
'notification[long_message]': message.encode('utf-8'), 'notification[long_message]': message.encode('utf-8'),
@@ -947,12 +932,12 @@ class BOXCAR(object):
"/headphoneslogo.png" "/headphoneslogo.png"
}) })
req = urllib2.Request(self.url) req = urllib.request.Request(self.url)
handle = urllib2.urlopen(req, data) handle = urllib.request.urlopen(req, data)
handle.close() handle.close()
return True return True
except urllib2.URLError as e: except urllib.error.URLError as e:
logger.warn('Error sending Boxcar2 Notification: %s' % e) logger.warn('Error sending Boxcar2 Notification: %s' % e)
return False return False
@@ -1011,7 +996,7 @@ class Email(object):
mailserver.quit() mailserver.quit()
return True return True
except Exception, e: except Exception as e:
logger.warn('Error sending Email: %s' % e) logger.warn('Error sending Email: %s' % e)
return False return False
@@ -1034,7 +1019,7 @@ class TELEGRAM(object):
# MusicBrainz link # MusicBrainz link
if rgid: if rgid:
message += '\n\n <a href="http://musicbrainz.org/' \ message += '\n\n <a href="https://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid 'release-group/%s">MusicBrainz</a>' % rgid
# Send image # Send image
@@ -1044,15 +1029,15 @@ class TELEGRAM(object):
payload = {'chat_id': userid, 'parse_mode': "HTML", 'caption': status + message} payload = {'chat_id': userid, 'parse_mode': "HTML", 'caption': status + message}
try: try:
response = requests.post(TELEGRAM_API % (token, "sendPhoto"), data=payload, files=image_file) response = requests.post(TELEGRAM_API % (token, "sendPhoto"), data=payload, files=image_file)
except Exception, e: except Exception as e:
logger.info(u'Telegram notify failed: ' + str(e)) logger.info('Telegram notify failed: ' + str(e))
# Sent text # Sent text
else: else:
payload = {'chat_id': userid, 'parse_mode': "HTML", 'text': status + message} payload = {'chat_id': userid, 'parse_mode': "HTML", 'text': status + message}
try: try:
response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload) response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload)
except Exception, e: except Exception as e:
logger.info(u'Telegram notify failed: ' + str(e)) logger.info('Telegram notify failed: ' + str(e))
# Error logging # Error logging
sent_successfuly = True sent_successfuly = True
@@ -1060,7 +1045,7 @@ class TELEGRAM(object):
logger.info("Could not send notification to TelegramBot (token=%s). Response: [%s]", token, response.text) logger.info("Could not send notification to TelegramBot (token=%s). Response: [%s]", token, response.text)
sent_successfuly = False sent_successfuly = False
logger.info(u"Telegram notifications sent.") logger.info("Telegram notifications sent.")
return sent_successfuly return sent_successfuly
@@ -1080,15 +1065,15 @@ class SLACK(object):
try: try:
response = requests.post(SLACK_URL, json=payload) response = requests.post(SLACK_URL, json=payload)
except Exception, e: except Exception as e:
logger.info(u'Slack notify failed: ' + str(e)) logger.info('Slack notify failed: ' + str(e))
sent_successfuly = True sent_successfuly = True
if not response.status_code == 200: if not response.status_code == 200:
logger.info( logger.info(
u'Could not send notification to Slack. Response: [%s]', 'Could not send notification to Slack. Response: [%s]',
(response.text)) (response.text))
sent_successfuly = False sent_successfuly = False
logger.info(u"Slack notifications sent.") logger.info("Slack notifications sent.")
return sent_successfuly return sent_successfuly
+18 -17
View File
@@ -20,8 +20,8 @@
from base64 import standard_b64encode from base64 import standard_b64encode
import httplib import http.client
import xmlrpclib import xmlrpc.client
import headphones import headphones
from headphones import logger from headphones import logger
@@ -32,7 +32,7 @@ def sendNZB(nzb):
nzbgetXMLrpc = "%(protocol)s://%(username)s:%(password)s@%(host)s/xmlrpc" nzbgetXMLrpc = "%(protocol)s://%(username)s:%(password)s@%(host)s/xmlrpc"
if not headphones.CONFIG.NZBGET_HOST: if not headphones.CONFIG.NZBGET_HOST:
logger.error(u"No NZBget host found in configuration. Please configure it.") logger.error("No NZBget host found in configuration. Please configure it.")
return False return False
if headphones.CONFIG.NZBGET_HOST.startswith('https://'): if headphones.CONFIG.NZBGET_HOST.startswith('https://'):
@@ -46,34 +46,35 @@ def sendNZB(nzb):
"username": headphones.CONFIG.NZBGET_USERNAME, "username": headphones.CONFIG.NZBGET_USERNAME,
"password": headphones.CONFIG.NZBGET_PASSWORD} "password": headphones.CONFIG.NZBGET_PASSWORD}
nzbGetRPC = xmlrpclib.ServerProxy(url) nzbGetRPC = xmlrpc.client.ServerProxy(url)
try: try:
if nzbGetRPC.writelog("INFO", "headphones connected to drop of %s any moment now." % ( if nzbGetRPC.writelog("INFO", "headphones connected to drop of %s any moment now." % (
nzb.name + ".nzb")): nzb.name + ".nzb")):
logger.debug(u"Successfully connected to NZBget") logger.debug("Successfully connected to NZBget")
else: else:
logger.info(u"Successfully connected to NZBget, but unable to send a message" % ( logger.info("Successfully connected to NZBget, but unable to send a message" % (
nzb.name + ".nzb")) nzb.name + ".nzb"))
except httplib.socket.error: except http.client.socket.error:
logger.error( logger.error(
u"Please check your NZBget host and port (if it is running). NZBget is not responding to this combination") "Please check your NZBget host and port (if it is running). NZBget is not responding to this combination")
return False return False
except xmlrpclib.ProtocolError, e: except xmlrpc.client.ProtocolError as e:
if e.errmsg == "Unauthorized": if e.errmsg == "Unauthorized":
logger.error(u"NZBget password is incorrect.") logger.error("NZBget password is incorrect.")
else: else:
logger.error(u"Protocol Error: " + e.errmsg) logger.error("Protocol Error: " + e.errmsg)
return False return False
nzbcontent64 = None nzbcontent64 = None
if nzb.resultType == "nzbdata": if nzb.resultType == "nzbdata":
data = nzb.extraInfo[0] data = nzb.extraInfo[0]
nzbcontent64 = standard_b64encode(data) # NZBGet needs a string, not bytes
nzbcontent64 = standard_b64encode(data).decode("utf-8")
logger.info(u"Sending NZB to NZBget") logger.info("Sending NZB to NZBget")
logger.debug(u"URL: " + url) logger.debug("URL: " + url)
dupekey = "" dupekey = ""
dupescore = 0 dupescore = 0
@@ -131,12 +132,12 @@ def sendNZB(nzb):
nzb.url) nzb.url)
if nzbget_result: if nzbget_result:
logger.debug(u"NZB sent to NZBget successfully") logger.debug("NZB sent to NZBget successfully")
return True return True
else: else:
logger.error(u"NZBget could not add %s to the queue" % (nzb.name + ".nzb")) logger.error("NZBget could not add %s to the queue" % (nzb.name + ".nzb"))
return False return False
except: except:
logger.error( logger.error(
u"Connect Error to NZBget: could not add %s to the queue" % (nzb.name + ".nzb")) "Connect Error to NZBget: could not add %s to the queue" % (nzb.name + ".nzb"))
return False return False
+12 -12
View File
@@ -30,7 +30,7 @@ syntax elements are supported:
nonempty value only if any variable or optional inside returned nonempty value only if any variable or optional inside returned
nonempty value, ignoring literals (like {'{'$That'}'}). nonempty value, ignoring literals (like {'{'$That'}'}).
""" """
from __future__ import print_function
from enum import Enum from enum import Enum
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>" __author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
@@ -111,9 +111,9 @@ class _OptionalBlock(_Generator):
# type: (Mapping[str,str]) -> str # type: (Mapping[str,str]) -> str
res = [(isinstance(x, _Generator), x.render(replacement)) for x in self._scope] 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): if any((t[0] and t[1] is not None and len(t[1]) != 0) for t in res):
return u"".join(t[1] for t in res) return "".join(t[1] for t in res)
else: else:
return u"" return ""
def __eq__(self, other): def __eq__(self, other):
""" """
@@ -122,15 +122,15 @@ class _OptionalBlock(_Generator):
return isinstance(other, _OptionalBlock) and self._scope == other._scope return isinstance(other, _OptionalBlock) and self._scope == other._scope
_OPTIONAL_START = u'{' _OPTIONAL_START = '{'
_OPTIONAL_END = u'}' _OPTIONAL_END = '}'
_ESCAPE_CHAR = u'\'' _ESCAPE_CHAR = '\''
_REPLACEMENT_START = u'$' _REPLACEMENT_START = '$'
def _is_replacement_valid(c): def _is_replacement_valid(c):
# type: (str) -> bool # type: (str) -> bool
return c.isalnum() or c == u'_' return c.isalnum() or c == '_'
class _State(Enum): class _State(Enum):
@@ -243,7 +243,7 @@ class Pattern(object):
def __call__(self, replacement): def __call__(self, replacement):
# type: (Mapping[str,str]) -> str # type: (Mapping[str,str]) -> str
'''Execute path rendering/substitution based on replacement dictionary.''' '''Execute path rendering/substitution based on replacement dictionary.'''
return u"".join(p.render(replacement) for p in self._pattern) return "".join(p.render(replacement) for p in self._pattern)
def _get_warnings(self): def _get_warnings(self):
# type: () -> str # type: () -> str
@@ -262,6 +262,6 @@ def render(pattern, replacement):
if __name__ == "__main__": if __name__ == "__main__":
# primitive test ;) # primitive test ;)
p = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]}") p = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]}")
d = {'$Disc': '', '$Track': '05', '$Artist': u'Grzegżółka', '$Title': u'Błona kapłona', '$Year': '2019'} d = {'$Disc': '', '$Track': '05', '$Artist': 'Grzegżółka', '$Title': 'Błona kapłona', '$Year': '2019'}
assert p(d) == u"05 - Grzegżółka - Błona kapłona [2019]" assert p(d) == "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 import headphones.pathrender as _pr
from headphones.pathrender import Pattern, Warnings from headphones.pathrender import Pattern, Warnings
from unittestcompat import TestCase from .unittestcompat import TestCase
__author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>" __author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
@@ -32,21 +32,21 @@ class PathRenderTest(TestCase):
def test_parsing(self): def test_parsing(self):
"""pathrender: pattern parsing""" """pathrender: pattern parsing"""
pattern = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]}") pattern = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]}")
expected = [ expected = [
_pr._OptionalBlock([ _pr._OptionalBlock([
_pr._Replacement(u"$Disc"), _pr._Replacement("$Disc"),
_pr._LiteralText(u".") _pr._LiteralText(".")
]), ]),
_pr._Replacement(u"$Track"), _pr._Replacement("$Track"),
_pr._LiteralText(u" - "), _pr._LiteralText(" - "),
_pr._Replacement(u"$Artist"), _pr._Replacement("$Artist"),
_pr._LiteralText(u" - "), _pr._LiteralText(" - "),
_pr._Replacement(u"$Title"), _pr._Replacement("$Title"),
_pr._OptionalBlock([ _pr._OptionalBlock([
_pr._LiteralText(u" ["), _pr._LiteralText(" ["),
_pr._Replacement(u"$Year"), _pr._Replacement("$Year"),
_pr._LiteralText(u"]") _pr._LiteralText("]")
]) ])
] ]
self.assertEqual(expected, pattern._pattern) self.assertEqual(expected, pattern._pattern)
@@ -54,27 +54,27 @@ class PathRenderTest(TestCase):
def test_parsing_warnings(self): def test_parsing_warnings(self):
"""pathrender: pattern parsing with warnings""" """pathrender: pattern parsing with warnings"""
pattern = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]") pattern = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]")
self.assertEqual(set([Warnings.UNCLOSED_OPTIONAL]), pattern.warnings) self.assertEqual(set([Warnings.UNCLOSED_OPTIONAL]), pattern.warnings)
pattern = Pattern(u"{$Disc.}$Track - $Artist - $Title{ [$Year]'}") pattern = Pattern("{$Disc.}$Track - $Artist - $Title{ [$Year]'}")
self.assertEqual(set([Warnings.UNCLOSED_ESCAPE, Warnings.UNCLOSED_OPTIONAL]), pattern.warnings) self.assertEqual(set([Warnings.UNCLOSED_ESCAPE, Warnings.UNCLOSED_OPTIONAL]), pattern.warnings)
def test_replacement(self): def test_replacement(self):
"""pathrender: _Replacement variable substitution""" """pathrender: _Replacement variable substitution"""
r = _pr._Replacement(u"$Title") r = _pr._Replacement("$Title")
subst = {'$Title': 'foo', '$Track': 'bar'} subst = {'$Title': 'foo', '$Track': 'bar'}
res = r.render(subst) res = r.render(subst)
self.assertEqual(res, u'foo', 'check valid replacement') self.assertEqual(res, 'foo', 'check valid replacement')
subst = {} subst = {}
res = r.render(subst) res = r.render(subst)
self.assertEqual(res, u'$Title', 'check missing replacement') self.assertEqual(res, '$Title', 'check missing replacement')
subst = {'$Title': None} subst = {'$Title': None}
res = r.render(subst) res = r.render(subst)
self.assertEqual(res, '', 'check render() works with None') self.assertEqual(res, '', 'check render() works with None')
def test_literal(self): def test_literal(self):
"""pathrender: _Literal text rendering""" """pathrender: _Literal text rendering"""
l = _pr._LiteralText(u"foo") l = _pr._LiteralText("foo")
subst = {'$foo': 'bar'} subst = {'$foo': 'bar'}
res = l.render(subst) res = l.render(subst)
self.assertEqual(res, 'foo') self.assertEqual(res, 'foo')
@@ -82,12 +82,12 @@ class PathRenderTest(TestCase):
def test_optional(self): def test_optional(self):
"""pathrender: _OptionalBlock element processing""" """pathrender: _OptionalBlock element processing"""
o = _pr._OptionalBlock([ o = _pr._OptionalBlock([
_pr._Replacement(u"$Title"), _pr._Replacement("$Title"),
_pr._LiteralText(u".foobar") _pr._LiteralText(".foobar")
]) ])
subst = {'$Title': 'foo', '$Track': 'bar'} subst = {'$Title': 'foo', '$Track': 'bar'}
res = o.render(subst) res = o.render(subst)
self.assertEqual(res, u'foo.foobar', 'check non-empty replacement') self.assertEqual(res, 'foo.foobar', 'check non-empty replacement')
subst = {'$Title': ''} subst = {'$Title': ''}
res = o.render(subst) res = o.render(subst)
self.assertEqual(res, '', 'check empty replacement') self.assertEqual(res, '', 'check empty replacement')
+134 -142
View File
@@ -25,7 +25,7 @@ import headphones
from beets import autotag from beets import autotag
from beets import config as beetsconfig from beets import config as beetsconfig
from beets import logging as beetslogging from beets import logging as beetslogging
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from mediafile import MediaFile, FileTypeError, UnreadableFileError
from beetsplug import lyrics as beetslyrics from beetsplug import lyrics as beetslyrics
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
from headphones import db, albumart, librarysync from headphones import db, albumart, librarysync
@@ -65,8 +65,7 @@ def checkFolder():
folder_name = torrent_folder_name folder_name = torrent_folder_name
if folder_name: if folder_name:
album_path = os.path.join(download_dir, folder_name).encode( album_path = os.path.join(download_dir, folder_name)
headphones.SYS_ENCODING, 'replace')
logger.debug("Checking if %s exists" % album_path) logger.debug("Checking if %s exists" % album_path)
if os.path.exists(album_path): if os.path.exists(album_path):
@@ -135,11 +134,11 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if headphones.CONFIG.RENAME_FROZEN: if headphones.CONFIG.RENAME_FROZEN:
renameUnprocessedFolder(albumpath, tag="Frozen") renameUnprocessedFolder(albumpath, tag="Frozen")
else: else:
logger.warn(u"Won't rename %s to mark as 'Frozen', because it is disabled.", logger.warn("Won't rename %s to mark as 'Frozen', because it is disabled.",
albumpath.decode(headphones.SYS_ENCODING, 'replace')) albumpath)
return return
logger.info(u"Now adding/updating artist: " + release_dict['artist_name']) logger.info("Now adding/updating artist: " + release_dict['artist_name'])
if release_dict['artist_name'].startswith('The '): if release_dict['artist_name'].startswith('The '):
sortname = release_dict['artist_name'][4:] sortname = release_dict['artist_name'][4:]
@@ -161,7 +160,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
myDB.upsert("artists", newValueDict, controlValueDict) myDB.upsert("artists", newValueDict, controlValueDict)
logger.info(u"Now adding album: " + release_dict['title']) logger.info("Now adding album: " + release_dict['title'])
controlValueDict = {"AlbumID": albumid} controlValueDict = {"AlbumID": albumid}
newValueDict = {"ArtistID": release_dict['artist_id'], newValueDict = {"ArtistID": release_dict['artist_id'],
@@ -202,7 +201,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
newValueDict = {"Status": "Paused"} newValueDict = {"Status": "Paused"}
myDB.upsert("artists", newValueDict, controlValueDict) myDB.upsert("artists", newValueDict, controlValueDict)
logger.info(u"Addition complete for: " + release_dict['title'] + " - " + release_dict[ logger.info("Addition complete for: " + release_dict['title'] + " - " + release_dict[
'artist_name']) 'artist_name'])
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone() release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
@@ -211,17 +210,18 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
downloaded_track_list = [] downloaded_track_list = []
downloaded_cuecount = 0 downloaded_cuecount = 0
for r, d, f in os.walk(albumpath): media_extensions = tuple(map(lambda x: '.' + x, headphones.MEDIA_FORMATS))
for files in f:
if any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS): for root, dirs, files in os.walk(albumpath):
downloaded_track_list.append(os.path.join(r, files)) for file in files:
elif files.lower().endswith('.cue'): if file.endswith(media_extensions):
downloaded_track_list.append(os.path.join(root, file))
elif file.endswith('.cue'):
downloaded_cuecount += 1 downloaded_cuecount += 1
# if any of the files end in *.part, we know the torrent isn't done yet. Process if forced, though # if any of the files end in *.part, we know the torrent isn't done yet. Process if forced, though
elif files.lower().endswith(('.part', '.utpart')) and not forced: elif file.endswith(('.part', '.utpart')) and not forced:
logger.info( logger.info(
"Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, "Looks like " + os.path.basename(albumpath) + " isn't complete yet. Will try again on the next run")
'replace') + " isn't complete yet. Will try again on the next run")
return return
# Force single file through # Force single file through
@@ -264,10 +264,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
try: try:
f = MediaFile(downloaded_track) f = MediaFile(downloaded_track)
except Exception as e: except Exception as e:
logger.info( logger.info(f"Exception from MediaFile for {downloaded_track}: {e}")
u"Exception from MediaFile for: " + downloaded_track.decode(headphones.SYS_ENCODING,
'replace') + u" : " + unicode(
e))
continue continue
if not f.artist: if not f.artist:
@@ -275,10 +272,10 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if not f.album: if not f.album:
continue continue
metaartist = helpers.latinToAscii(f.artist.lower()).encode('UTF-8') metaartist = helpers.latinToAscii(f.artist.lower())
dbartist = helpers.latinToAscii(release['ArtistName'].lower()).encode('UTF-8') dbartist = helpers.latinToAscii(release['ArtistName'].lower())
metaalbum = helpers.latinToAscii(f.album.lower()).encode('UTF-8') metaalbum = helpers.latinToAscii(f.album.lower())
dbalbum = helpers.latinToAscii(release['AlbumTitle'].lower()).encode('UTF-8') dbalbum = helpers.latinToAscii(release['AlbumTitle'].lower())
logger.debug('Matching metadata artist: %s with artist name: %s' % (metaartist, dbartist)) logger.debug('Matching metadata artist: %s with artist name: %s' % (metaartist, dbartist))
logger.debug('Matching metadata album: %s with album name: %s' % (metaalbum, dbalbum)) logger.debug('Matching metadata album: %s with album name: %s' % (metaalbum, dbalbum))
@@ -298,8 +295,8 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
if not track['TrackTitle']: if not track['TrackTitle']:
continue continue
dbtrack = helpers.latinToAscii(track['TrackTitle'].lower()).encode('UTF-8') dbtrack = helpers.latinToAscii(track['TrackTitle'].lower())
filetrack = helpers.latinToAscii(split_track_name).encode('UTF-8') filetrack = helpers.latinToAscii(split_track_name)
logger.debug('Checking if track title: %s is in file name: %s' % (dbtrack, filetrack)) logger.debug('Checking if track title: %s is in file name: %s' % (dbtrack, filetrack))
if dbtrack in filetrack: if dbtrack in filetrack:
@@ -340,11 +337,9 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
keep_original_folder, forced, single) keep_original_folder, forced, single)
return return
logger.warn(u'Could not identify album: %s. It may not be the intended album.', logger.warn(f"Could not identify {albumpath}. It may not be the intended album")
albumpath.decode(headphones.SYS_ENCODING, 'replace'))
markAsUnprocessed(albumid, albumpath, keep_original_folder) markAsUnprocessed(albumid, albumpath, keep_original_folder)
def markAsUnprocessed(albumid, albumpath, keep_original_folder=False): def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
myDB = db.DBConnection() myDB = db.DBConnection()
myDB.action( myDB.action(
@@ -354,13 +349,19 @@ def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
if headphones.CONFIG.RENAME_UNPROCESSED and not keep_original_folder: if headphones.CONFIG.RENAME_UNPROCESSED and not keep_original_folder:
renameUnprocessedFolder(albumpath, tag="Unprocessed") renameUnprocessedFolder(albumpath, tag="Unprocessed")
else: else:
logger.warn(u"Won't rename %s to mark as 'Unprocessed', because it is disabled or folder is being kept.", logger.warn(
albumpath.decode(headphones.SYS_ENCODING, 'replace')) f"Won't rename {albumpath} to mark as 'Unprocessed', "
f"because it is disabled or folder is being kept."
)
return
def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind=None, def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind=None,
keep_original_folder=False, forced=False, single=False): keep_original_folder=False, forced=False, single=False):
logger.info('Starting post-processing for: %s - %s' % (release['ArtistName'], release['AlbumTitle'])) logger.info(
f"Starting post-processing for: {release['ArtistName']} - "
f"{release['AlbumTitle']}"
)
new_folder = None new_folder = None
# Preserve the torrent dir # Preserve the torrent dir
@@ -393,12 +394,10 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
f = MediaFile(downloaded_track) f = MediaFile(downloaded_track)
builder.add_media_file(f) builder.add_media_file(f)
except (FileTypeError, UnreadableFileError): except (FileTypeError, UnreadableFileError):
logger.error("Track file is not a valid media file: %s. Not continuing.", logger.error(f"`{downloaded_track}` is not a valid media file. Not continuing.")
downloaded_track.decode(headphones.SYS_ENCODING, "replace"))
return return
except IOError: except IOError:
logger.error("Unable to find media file: %s. Not continuing.", downloaded_track.decode( logger.error(f"Unable to find `{downloaded_track}`. Not continuing.")
headphones.SYS_ENCODING, "replace"))
if new_folder: if new_folder:
shutil.rmtree(new_folder) shutil.rmtree(new_folder)
return return
@@ -416,9 +415,10 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
fp.seek(0) fp.seek(0)
except IOError as e: except IOError as e:
logger.debug("Write check exact error: %s", e) logger.debug("Write check exact error: %s", e)
logger.error("Track file is not writable. This is required " logger.error(
"for some post processing steps: %s. Not continuing.", f"`{downloaded_track}` is not writable. This is required "
downloaded_track.decode(headphones.SYS_ENCODING, "replace")) "for some post processing steps. Not continuing."
)
if new_folder: if new_folder:
shutil.rmtree(new_folder) shutil.rmtree(new_folder)
return return
@@ -475,7 +475,8 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
else: else:
albumpaths = [albumpath] albumpaths = [albumpath]
updateFilePermissions(albumpaths) if headphones.CONFIG.FILE_PERMISSIONS_ENABLED:
updateFilePermissions(albumpaths)
myDB = db.DBConnection() myDB = db.DBConnection()
myDB.action('UPDATE albums SET status = "Downloaded" WHERE AlbumID=?', [albumid]) myDB.action('UPDATE albums SET status = "Downloaded" WHERE AlbumID=?', [albumid])
@@ -491,7 +492,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
if seed_snatched: if seed_snatched:
hash = seed_snatched['TorrentHash'] hash = seed_snatched['TorrentHash']
torrent_removed = False torrent_removed = False
logger.info(u'%s - %s. Checking if torrent has finished seeding and can be removed' % ( logger.info('%s - %s. Checking if torrent has finished seeding and can be removed' % (
release['ArtistName'], release['AlbumTitle'])) release['ArtistName'], release['AlbumTitle']))
if headphones.CONFIG.TORRENT_DOWNLOADER == 1: if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
torrent_removed = transmission.removeTorrent(hash, True) torrent_removed = transmission.removeTorrent(hash, True)
@@ -517,18 +518,18 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
ArtistName=release['ArtistName']) ArtistName=release['ArtistName'])
logger.info( logger.info(
u'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle'])) 'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle']))
pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle'] pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
statusmessage = "Download and Postprocessing completed" statusmessage = "Download and Postprocessing completed"
if headphones.CONFIG.GROWL_ENABLED: if headphones.CONFIG.GROWL_ENABLED:
logger.info(u"Growl request") logger.info("Growl request")
growl = notifiers.GROWL() growl = notifiers.GROWL()
growl.notify(pushmessage, statusmessage) growl.notify(pushmessage, statusmessage)
if headphones.CONFIG.PROWL_ENABLED: if headphones.CONFIG.PROWL_ENABLED:
logger.info(u"Prowl request") logger.info("Prowl request")
prowl = notifiers.PROWL() prowl = notifiers.PROWL()
prowl.notify(pushmessage, statusmessage) prowl.notify(pushmessage, statusmessage)
@@ -559,7 +560,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
nma.notify(release['ArtistName'], release['AlbumTitle']) nma.notify(release['ArtistName'], release['AlbumTitle'])
if headphones.CONFIG.PUSHALOT_ENABLED: if headphones.CONFIG.PUSHALOT_ENABLED:
logger.info(u"Pushalot request") logger.info("Pushalot request")
pushalot = notifiers.PUSHALOT() pushalot = notifiers.PUSHALOT()
pushalot.notify(pushmessage, statusmessage) pushalot.notify(pushmessage, statusmessage)
@@ -569,35 +570,36 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
syno.notify(albumpath) syno.notify(albumpath)
if headphones.CONFIG.PUSHOVER_ENABLED: if headphones.CONFIG.PUSHOVER_ENABLED:
logger.info(u"Pushover request") logger.info("Pushover request")
pushover = notifiers.PUSHOVER() pushover = notifiers.PUSHOVER()
pushover.notify(pushmessage, "Headphones") pushover.notify(pushmessage, "Headphones")
if headphones.CONFIG.PUSHBULLET_ENABLED: if headphones.CONFIG.PUSHBULLET_ENABLED:
logger.info(u"PushBullet request") logger.info("PushBullet request")
pushbullet = notifiers.PUSHBULLET() pushbullet = notifiers.PUSHBULLET()
pushbullet.notify(pushmessage, statusmessage) pushbullet.notify(pushmessage, statusmessage)
if headphones.CONFIG.JOIN_ENABLED: if headphones.CONFIG.JOIN_ENABLED:
logger.info(u"Join request") logger.info("Join request")
join = notifiers.JOIN() join = notifiers.JOIN()
join.notify(pushmessage, statusmessage) join.notify(pushmessage, statusmessage)
if headphones.CONFIG.TELEGRAM_ENABLED: if headphones.CONFIG.TELEGRAM_ENABLED:
logger.info(u"Telegram request") logger.info("Telegram request")
telegram = notifiers.TELEGRAM() telegram = notifiers.TELEGRAM()
telegram.notify(statusmessage, pushmessage) telegram.notify(statusmessage, pushmessage)
if headphones.CONFIG.TWITTER_ENABLED: if headphones.CONFIG.TWITTER_ENABLED:
logger.info(u"Sending Twitter notification") logger.info("Twitter notifications temporarily disabled")
twitter = notifiers.TwitterNotifier() #logger.info("Sending Twitter notification")
twitter.notify_download(pushmessage) #twitter = notifiers.TwitterNotifier()
#twitter.notify_download(pushmessage)
if headphones.CONFIG.OSX_NOTIFY_ENABLED: if headphones.CONFIG.OSX_NOTIFY_ENABLED:
from headphones import cache from headphones import cache
c = cache.Cache() c = cache.Cache()
album_art = c.get_artwork_from_cache(None, release['AlbumID']) album_art = c.get_artwork_from_cache(None, release['AlbumID'])
logger.info(u"Sending OS X notification") logger.info("Sending OS X notification")
osx_notify = notifiers.OSX_NOTIFY() osx_notify = notifiers.OSX_NOTIFY()
osx_notify.notify(release['ArtistName'], osx_notify.notify(release['ArtistName'],
release['AlbumTitle'], release['AlbumTitle'],
@@ -605,13 +607,13 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
image=album_art) image=album_art)
if headphones.CONFIG.BOXCAR_ENABLED: if headphones.CONFIG.BOXCAR_ENABLED:
logger.info(u"Sending Boxcar2 notification") logger.info("Sending Boxcar2 notification")
boxcar = notifiers.BOXCAR() boxcar = notifiers.BOXCAR()
boxcar.notify('Headphones processed: ' + pushmessage, boxcar.notify('Headphones processed: ' + pushmessage,
statusmessage, release['AlbumID']) statusmessage, release['AlbumID'])
if headphones.CONFIG.SUBSONIC_ENABLED: if headphones.CONFIG.SUBSONIC_ENABLED:
logger.info(u"Sending Subsonic update") logger.info("Sending Subsonic update")
subsonic = notifiers.SubSonicNotifier() subsonic = notifiers.SubSonicNotifier()
subsonic.notify(albumpaths) subsonic.notify(albumpaths)
@@ -620,7 +622,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
mpc.notify() mpc.notify()
if headphones.CONFIG.EMAIL_ENABLED: if headphones.CONFIG.EMAIL_ENABLED:
logger.info(u"Sending Email notification") logger.info("Sending Email notification")
email = notifiers.Email() email = notifiers.Email()
subject = release['ArtistName'] + ' - ' + release['AlbumTitle'] subject = release['ArtistName'] + ' - ' + release['AlbumTitle']
email.notify(subject, "Download and Postprocessing completed") email.notify(subject, "Download and Postprocessing completed")
@@ -636,23 +638,21 @@ def embedAlbumArt(artwork, downloaded_track_list):
try: try:
f = MediaFile(downloaded_track) f = MediaFile(downloaded_track)
except: except:
logger.error(u'Could not read %s. Not adding album art' % downloaded_track.decode( logger.error(f"Could not read {downloaded_track}. Not adding album art")
headphones.SYS_ENCODING, 'replace'))
continue continue
logger.debug('Adding album art to: %s' % downloaded_track) logger.debug(f"Adding album art to `{downloaded_track}`")
try: try:
f.art = artwork f.art = artwork
f.save() f.save()
except Exception as e: except Exception as e:
logger.error(u'Error embedding album art to: %s. Error: %s' % ( logger.error(f"Error embedding album art to `{downloaded_track}`: {e}")
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), str(e)))
continue continue
def addAlbumArt(artwork, albumpath, release, metadata_dict): def addAlbumArt(artwork, albumpath, release, metadata_dict):
logger.info('Adding album art to folder') logger.info(f"Adding album art to `{albumpath}`")
md = metadata.album_metadata(albumpath, release, metadata_dict) md = metadata.album_metadata(albumpath, release, metadata_dict)
ext = ".jpg" ext = ".jpg"
@@ -663,8 +663,7 @@ def addAlbumArt(artwork, albumpath, release, metadata_dict):
album_art_name = helpers.pattern_substitute( album_art_name = helpers.pattern_substitute(
headphones.CONFIG.ALBUM_ART_FORMAT.strip(), md) + ext headphones.CONFIG.ALBUM_ART_FORMAT.strip(), md) + ext
album_art_name = helpers.replace_illegal_chars(album_art_name).encode( album_art_name = helpers.replace_illegal_chars(album_art_name)
headphones.SYS_ENCODING, 'replace')
if headphones.CONFIG.FILE_UNDERSCORES: if headphones.CONFIG.FILE_UNDERSCORES:
album_art_name = album_art_name.replace(' ', '_') album_art_name = album_art_name.replace(' ', '_')
@@ -684,14 +683,13 @@ def cleanupFiles(albumpath):
logger.info('Cleaning up files') logger.info('Cleaning up files')
for r, d, f in os.walk(albumpath): for r, d, f in os.walk(albumpath):
for files in f: for file in f:
if not any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS): if not any(file.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
logger.debug('Removing: %s' % files) logger.debug('Removing: %s' % file)
try: try:
os.remove(os.path.join(r, files)) os.remove(os.path.join(r, file))
except Exception as e: except Exception as e:
logger.error(u'Could not remove file: %s. Error: %s' % ( logger.error('Could not remove file: %s. Error: %s' % (file, e))
files.decode(headphones.SYS_ENCODING, 'replace'), e))
def renameNFO(albumpath): def renameNFO(albumpath):
@@ -701,19 +699,16 @@ def renameNFO(albumpath):
for file in f: for file in f:
if file.lower().endswith('.nfo'): if file.lower().endswith('.nfo'):
if not file.lower().endswith('.orig.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: try:
new_file_name = os.path.join(r, file)[:-3] + 'orig.nfo' 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) os.rename(os.path.join(r, file), new_file_name)
except Exception as e: except Exception as e:
logger.error(u'Could not rename file: %s. Error: %s' % ( logger.error(f"Could not rename {file}: {e}")
os.path.join(r, file).decode(headphones.SYS_ENCODING, 'replace'), e))
def moveFiles(albumpath, release, metadata_dict): def moveFiles(albumpath, release, metadata_dict):
logger.info("Moving files: %s" % albumpath) logger.info(f"Moving files: `{albumpath}`")
md = metadata.album_metadata(albumpath, release, metadata_dict) md = metadata.album_metadata(albumpath, release, metadata_dict)
folder = helpers.pattern_substitute( folder = helpers.pattern_substitute(
@@ -750,12 +745,8 @@ def moveFiles(albumpath, release, metadata_dict):
make_lossy_folder = False make_lossy_folder = False
make_lossless_folder = False make_lossless_folder = False
lossy_destination_path = os.path.normpath( lossy_destination_path = os.path.join(headphones.CONFIG.DESTINATION_DIR, folder)
os.path.join(headphones.CONFIG.DESTINATION_DIR, folder)).encode(headphones.SYS_ENCODING, lossless_destination_path = os.path.join(headphones.CONFIG.LOSSLESS_DESTINATION_DIR, folder)
'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 they set a destination dir for lossless media, only create the lossy folder if there is lossy media
if headphones.CONFIG.LOSSLESS_DESTINATION_DIR: if headphones.CONFIG.LOSSLESS_DESTINATION_DIR:
@@ -780,8 +771,9 @@ def moveFiles(albumpath, release, metadata_dict):
shutil.rmtree(lossless_destination_path) shutil.rmtree(lossless_destination_path)
except Exception as e: except Exception as e:
logger.error( logger.error(
"Error deleting existing folder: %s. Creating duplicate folder. Error: %s" % ( f"Error deleting `{lossless_destination_path}`. "
lossless_destination_path.decode(headphones.SYS_ENCODING, 'replace'), e)) f"Creating duplicate folder. Error: {e}"
)
create_duplicate_folder = True create_duplicate_folder = True
if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder: if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder:
@@ -791,8 +783,11 @@ def moveFiles(albumpath, release, metadata_dict):
while True: while True:
newfolder = temp_folder + '[%i]' % i newfolder = temp_folder + '[%i]' % i
lossless_destination_path = os.path.normpath( lossless_destination_path = os.path.normpath(
os.path.join(headphones.CONFIG.LOSSLESS_DESTINATION_DIR, newfolder)).encode( os.path.join(
headphones.SYS_ENCODING, 'replace') headphones.CONFIG.LOSSLESS_DESTINATION_DIR,
newfolder
)
)
if os.path.exists(lossless_destination_path): if os.path.exists(lossless_destination_path):
i += 1 i += 1
else: else:
@@ -818,8 +813,9 @@ def moveFiles(albumpath, release, metadata_dict):
shutil.rmtree(lossy_destination_path) shutil.rmtree(lossy_destination_path)
except Exception as e: except Exception as e:
logger.error( logger.error(
"Error deleting existing folder: %s. Creating duplicate folder. Error: %s" % ( f"Error deleting `{lossy_destination_path}`. "
lossy_destination_path.decode(headphones.SYS_ENCODING, 'replace'), e)) f"Creating duplicate folder. Error: {e}"
)
create_duplicate_folder = True create_duplicate_folder = True
if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder: if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder:
@@ -829,8 +825,11 @@ def moveFiles(albumpath, release, metadata_dict):
while True: while True:
newfolder = temp_folder + '[%i]' % i newfolder = temp_folder + '[%i]' % i
lossy_destination_path = os.path.normpath( lossy_destination_path = os.path.normpath(
os.path.join(headphones.CONFIG.DESTINATION_DIR, newfolder)).encode( os.path.join(
headphones.SYS_ENCODING, 'replace') headphones.CONFIG.DESTINATION_DIR,
newfolder
)
)
if os.path.exists(lossy_destination_path): if os.path.exists(lossy_destination_path):
i += 1 i += 1
else: else:
@@ -876,12 +875,11 @@ def moveFiles(albumpath, release, metadata_dict):
os.remove(file_to_move) os.remove(file_to_move)
except Exception as e: except Exception as e:
logger.error( logger.error(
"Error deleting file '" + file_to_move.decode(headphones.SYS_ENCODING, f"Error deleting `{file_to_move}` from source directory")
'replace') + "' from source directory")
else: else:
logger.error("Error copying '" + file_to_move.decode(headphones.SYS_ENCODING, logger.error(
'replace') + "'. Not deleting from download directory") f"Error copying `{file_to_move}`. "
f"Not deleting from download directory")
elif make_lossless_folder and not make_lossy_folder: elif make_lossless_folder and not make_lossy_folder:
for file_to_move in files_to_move: for file_to_move in files_to_move:
@@ -910,20 +908,20 @@ def moveFiles(albumpath, release, metadata_dict):
if headphones.CONFIG.FOLDER_PERMISSIONS_ENABLED: if headphones.CONFIG.FOLDER_PERMISSIONS_ENABLED:
try: try:
os.chmod(os.path.normpath(temp_f).encode(headphones.SYS_ENCODING, 'replace'), os.chmod(os.path.normpath(temp_f),
int(headphones.CONFIG.FOLDER_PERMISSIONS, 8)) int(headphones.CONFIG.FOLDER_PERMISSIONS, 8))
except Exception as e: except Exception as e:
logger.error("Error trying to change permissions on folder: %s. %s", logger.error(f"Error trying to change permissions on `{temp_f}`: {e}")
temp_f.decode(headphones.SYS_ENCODING, 'replace'), e)
else: else:
logger.debug("Not changing folder permissions, since it is disabled: %s", logger.debug(
temp_f.decode(headphones.SYS_ENCODING, 'replace')) f"Not changing permissions on `{temp_f}`, "
"since it is disabled")
# If we failed to move all the files out of the directory, this will fail too # If we failed to move all the files out of the directory, this will fail too
try: try:
shutil.rmtree(albumpath) shutil.rmtree(albumpath)
except Exception as e: except Exception as e:
logger.error('Could not remove directory: %s. %s', albumpath, e) logger.error(f"Could not remove `{albumpath}`: {e}")
destination_paths = [] destination_paths = []
@@ -952,11 +950,15 @@ def correctMetadata(albumid, release, downloaded_track_list):
headphones.LOSSY_MEDIA_FORMATS): headphones.LOSSY_MEDIA_FORMATS):
lossy_items.append(beets.library.Item.from_path(downloaded_track)) lossy_items.append(beets.library.Item.from_path(downloaded_track))
else: else:
logger.warn("Skipping: %s because it is not a mutagen friendly file format", logger.warn(
downloaded_track.decode(headphones.SYS_ENCODING, 'replace')) f"Skipping `{downloaded_track}` because it is "
f"not a mutagen friendly file format"
)
continue
except Exception as e: except Exception as e:
logger.error("Beets couldn't create an Item from: %s - not a media file? %s", logger.error(
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), str(e)) f"Beets couldn't create an Item from `{downloaded_track}`: {e}")
continue
for items in [lossy_items, lossless_items]: for items in [lossy_items, lossless_items]:
@@ -1018,11 +1020,9 @@ def correctMetadata(albumid, release, downloaded_track_list):
for item in items: for item in items:
try: try:
item.write() item.write()
logger.info("Successfully applied metadata to: %s", logger.info(f"Successfully applied metadata to `{item.path}`")
item.path.decode(headphones.SYS_ENCODING, 'replace'))
except Exception as e: except Exception as e:
logger.warn("Error writing metadata to '%s': %s", logger.warn(f"Error writing metadata to `{item.path}: {e}")
item.path.decode(headphones.SYS_ENCODING, 'replace'), str(e))
return False return False
return True return True
@@ -1048,11 +1048,11 @@ def embedLyrics(downloaded_track_list):
headphones.LOSSY_MEDIA_FORMATS): headphones.LOSSY_MEDIA_FORMATS):
lossy_items.append(beets.library.Item.from_path(downloaded_track)) lossy_items.append(beets.library.Item.from_path(downloaded_track))
else: else:
logger.warn("Skipping: %s because it is not a mutagen friendly file format", logger.warn(
downloaded_track.decode(headphones.SYS_ENCODING, 'replace')) f"Skipping `{downloaded_track}` because it is "
f"not a mutagen friendly file format")
except Exception as e: except Exception as e:
logger.error("Beets couldn't create an Item from: %s - not a media file? %s", logger.error(f"Beets couldn't create an Item from `{downloaded_track}`: {e}")
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), str(e))
for items in [lossy_items, lossless_items]: for items in [lossy_items, lossless_items]:
@@ -1067,7 +1067,7 @@ def embedLyrics(downloaded_track_list):
if any(lyrics): if any(lyrics):
break break
lyrics = u"\n\n---\n\n".join([l for l in lyrics if l]) lyrics = "\n\n---\n\n".join([l for l in lyrics if l])
if lyrics: if lyrics:
logger.debug('Adding lyrics to: %s', item.title) logger.debug('Adding lyrics to: %s', item.title)
@@ -1085,7 +1085,11 @@ def renameFiles(albumpath, downloaded_track_list, release):
# Until tagging works better I'm going to rely on the already provided metadata # Until tagging works better I'm going to rely on the already provided metadata
for downloaded_track in downloaded_track_list: for downloaded_track in downloaded_track_list:
md, from_metadata = metadata.file_metadata(downloaded_track, release) md, from_metadata = metadata.file_metadata(
downloaded_track,
release,
headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE
)
if md is None: if md is None:
# unable to parse media file, skip file # unable to parse media file, skip file
continue continue
@@ -1099,8 +1103,7 @@ def renameFiles(albumpath, downloaded_track_list, release):
headphones.CONFIG.FILE_FORMAT.strip(), md headphones.CONFIG.FILE_FORMAT.strip(), md
).replace('/', '_') + ext ).replace('/', '_') + ext
new_file_name = helpers.replace_illegal_chars(new_file_name).encode( new_file_name = helpers.replace_illegal_chars(new_file_name)
headphones.SYS_ENCODING, 'replace')
if headphones.CONFIG.FILE_UNDERSCORES: if headphones.CONFIG.FILE_UNDERSCORES:
new_file_name = new_file_name.replace(' ', '_') new_file_name = new_file_name.replace(' ', '_')
@@ -1111,37 +1114,28 @@ def renameFiles(albumpath, downloaded_track_list, release):
new_file = os.path.join(albumpath, new_file_name) new_file = os.path.join(albumpath, new_file_name)
if downloaded_track == new_file_name: if downloaded_track == new_file_name:
logger.debug("Renaming for: " + downloaded_track.decode( logger.debug(f"Renaming for {downloaded_track} is not neccessary")
headphones.SYS_ENCODING, 'replace') + " is not neccessary")
continue continue
logger.debug('Renaming %s ---> %s', logger.debug(f"Renaming {downloaded_track} ---> {new_file_name}")
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'),
new_file_name.decode(headphones.SYS_ENCODING, 'replace'))
try: try:
os.rename(downloaded_track, new_file) os.rename(downloaded_track, new_file)
except Exception as e: except Exception as e:
logger.error('Error renaming file: %s. Error: %s', logger.error(f"Error renaming {downloaded_track}: {e}")
downloaded_track.decode(headphones.SYS_ENCODING, 'replace'), e)
continue continue
def updateFilePermissions(albumpaths): def updateFilePermissions(albumpaths):
for folder in albumpaths: for folder in albumpaths:
logger.info("Updating file permissions in %s", folder) logger.info(f"Updating file permissions in `{folder}`")
for r, d, f in os.walk(folder): for r, d, f in os.walk(folder):
for files in f: for files in f:
full_path = os.path.join(r, files) full_path = os.path.join(r, files)
if headphones.CONFIG.FILE_PERMISSIONS_ENABLED: try:
try: os.chmod(full_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
os.chmod(full_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8)) except:
except: logger.error(f"Could not change permissions for `{full_path}`")
logger.error("Could not change permissions for file: %s", full_path) continue
continue
else:
logger.debug("Not changing file permissions, since it is disabled: %s",
full_path.decode(headphones.SYS_ENCODING, 'replace'))
def renameUnprocessedFolder(path, tag): def renameUnprocessedFolder(path, tag):
""" """
@@ -1168,18 +1162,16 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
ignored = 0 ignored = 0
if album_dir: if album_dir:
folders = [album_dir.encode(headphones.SYS_ENCODING, 'replace')] folders = [album_dir]
else: else:
download_dirs = [] download_dirs = []
if dir: if dir:
download_dirs.append(dir.encode(headphones.SYS_ENCODING, 'replace')) download_dirs.append(dir)
if headphones.CONFIG.DOWNLOAD_DIR and not dir: if headphones.CONFIG.DOWNLOAD_DIR and not dir:
download_dirs.append( download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
headphones.CONFIG.DOWNLOAD_DIR.encode(headphones.SYS_ENCODING, 'replace'))
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir: if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
download_dirs.append( download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_DIR)
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. # 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)) download_dirs = list(set(download_dirs))
@@ -1223,7 +1215,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
myDB = db.DBConnection() myDB = db.DBConnection()
for folder in folders: for folder in folders:
folder_basename = os.path.basename(folder).decode(headphones.SYS_ENCODING, 'replace') folder_basename = os.path.basename(folder)
logger.info('Processing: %s', folder_basename) logger.info('Processing: %s', folder_basename)
# Attempt 1: First try to see if there's a match in the snatched table, # Attempt 1: First try to see if there's a match in the snatched table,
+13 -13
View File
@@ -13,9 +13,9 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import urllib import urllib.request, urllib.parse, urllib.error
import urllib2 import urllib.request, urllib.error, urllib.parse
import cookielib import http.cookiejar
import json import json
import time import time
import mimetypes import mimetypes
@@ -61,23 +61,23 @@ class qbittorrentclient(object):
self.version = 2 self.version = 2
except Exception as e: except Exception as e:
logger.warning("Error with qBittorrent v2 api, check settings or update, will try v1: %s" % e) logger.warning("Error with qBittorrent v2 api, check settings or update, will try v1: %s" % e)
self.cookiejar = cookielib.CookieJar() self.cookiejar = http.cookiejar.CookieJar()
self.opener = self._make_opener() self.opener = self._make_opener()
self._get_sid(self.base_url, self.username, self.password) self._get_sid(self.base_url, self.username, self.password)
self.version = 1 self.version = 1
def _make_opener(self): def _make_opener(self):
# create opener with cookie handler to carry QBitTorrent SID cookie # create opener with cookie handler to carry QBitTorrent SID cookie
cookie_handler = urllib2.HTTPCookieProcessor(self.cookiejar) cookie_handler = urllib.request.HTTPCookieProcessor(self.cookiejar)
handlers = [cookie_handler] handlers = [cookie_handler]
return urllib2.build_opener(*handlers) return urllib.request.build_opener(*handlers)
def _get_sid(self, base_url, username, password): def _get_sid(self, base_url, username, password):
# login so we can capture SID cookie # login so we can capture SID cookie
login_data = urllib.urlencode({'username': username, 'password': password}) login_data = urllib.parse.urlencode({'username': username, 'password': password})
try: try:
self.opener.open(base_url + '/login', login_data) self.opener.open(base_url + '/login', login_data)
except urllib2.URLError as err: except urllib.error.URLError as err:
logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason)) logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason))
return return
for cookie in self.cookiejar: for cookie in self.cookiejar:
@@ -95,14 +95,14 @@ class qbittorrentclient(object):
data, headers = encode_multipart(args, files) data, headers = encode_multipart(args, files)
else: else:
if args: if args:
data = urllib.urlencode(args) data = urllib.parse.urlencode(args)
if content_type: if content_type:
headers['Content-Type'] = content_type headers['Content-Type'] = content_type
logger.debug('%s' % json.dumps(headers, indent=4)) logger.debug('%s' % json.dumps(headers, indent=4))
logger.debug('%s' % data) logger.debug('%s' % data)
request = urllib2.Request(url, data, headers) request = urllib.request.Request(url, data, headers)
try: try:
response = self.opener.open(request) response = self.opener.open(request)
info = response.info() info = response.info()
@@ -117,7 +117,7 @@ class qbittorrentclient(object):
return response.code, json.loads(resp) return response.code, json.loads(resp)
logger.debug('response code: %s' % str(response.code)) logger.debug('response code: %s' % str(response.code))
return response.code, None return response.code, None
except urllib2.URLError as err: except urllib.error.URLError as err:
logger.debug('Failed URL: %s' % url) logger.debug('Failed URL: %s' % url)
logger.debug('QBitTorrent webUI raised the following error: %s' % str(err)) logger.debug('QBitTorrent webUI raised the following error: %s' % str(err))
return None, None return None, None
@@ -319,7 +319,7 @@ def encode_multipart(args, files, boundary=None):
lines = [] lines = []
if args: if args:
for name, value in args.items(): for name, value in list(args.items()):
lines.extend(( lines.extend((
'--{0}'.format(boundary), '--{0}'.format(boundary),
'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)), 'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)),
@@ -329,7 +329,7 @@ def encode_multipart(args, files, boundary=None):
logger.debug(''.join(lines)) logger.debug(''.join(lines))
if files: if files:
for name, value in files.items(): for name, value in list(files.items()):
filename = value['filename'] filename = value['filename']
if 'mimetype' in value: if 'mimetype' in value:
mimetype = value['mimetype'] mimetype = value['mimetype']
+2 -2
View File
@@ -138,7 +138,7 @@ def request_soup(url, **kwargs):
no exceptions are raised. no exceptions are raised.
""" """
parser = kwargs.pop("parser", "html5lib") parser = kwargs.pop("parser", "html.parser")
response = request_response(url, **kwargs) response = request_response(url, **kwargs)
if response is not None: if response is not None:
@@ -222,7 +222,7 @@ def server_message(response):
if response.headers.get("content-type") and \ if response.headers.get("content-type") and \
"text/html" in response.headers.get("content-type"): "text/html" in response.headers.get("content-type"):
try: try:
soup = BeautifulSoup(response.content, "html5lib") soup = BeautifulSoup(response.content, "html.parser")
except Exception: except Exception:
pass pass
+15 -14
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
import urllib import urllib.request, urllib.parse, urllib.error
import time import time
from urlparse import urlparse from urllib.parse import urlparse
import re import re
import requests as requests import requests as requests
@@ -11,6 +11,7 @@ from bs4 import BeautifulSoup
import headphones import headphones
from headphones import logger from headphones import logger
from headphones.types import Result
class Rutracker(object): class Rutracker(object):
@@ -19,13 +20,13 @@ class Rutracker(object):
self.timeout = 60 self.timeout = 60
self.loggedin = False self.loggedin = False
self.maxsize = 0 self.maxsize = 0
self.search_referer = 'http://rutracker.org/forum/tracker.php' self.search_referer = 'https://rutracker.org/forum/tracker.php'
def logged_in(self): def logged_in(self):
return self.loggedin return self.loggedin
def still_logged_in(self, html): def still_logged_in(self, html):
if not html or "action=\"http://rutracker.org/forum/login.php\">" in html: if not html or "action=\"https://rutracker.org/forum/login.php\">" in html:
return False return False
else: else:
return True return True
@@ -35,7 +36,7 @@ class Rutracker(object):
Logs in user Logs in user
""" """
loginpage = 'http://rutracker.org/forum/login.php' loginpage = 'https://rutracker.org/forum/login.php'
post_params = { post_params = {
'login_username': headphones.CONFIG.RUTRACKER_USER, 'login_username': headphones.CONFIG.RUTRACKER_USER,
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD, 'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
@@ -68,10 +69,10 @@ class Rutracker(object):
return self.loggedin return self.loggedin
def has_bb_session_cookie(self, response): def has_bb_session_cookie(self, response):
if 'bb_session' in response.cookies.keys(): if 'bb_session' in list(response.cookies.keys()):
return True return True
# Rutracker randomly send a 302 redirect code, cookie may be present in response history # Rutracker randomly send a 302 redirect code, cookie may be present in response history
return next(('bb_session' in r.cookies.keys() for r in response.history), False) return next(('bb_session' in list(r.cookies.keys()) for r in response.history), False)
def searchurl(self, artist, album, year, format): def searchurl(self, artist, album, year, format):
""" """
@@ -99,10 +100,10 @@ class Rutracker(object):
# sort by size, descending. # sort by size, descending.
sort = '&o=7&s=2' sort = '&o=7&s=2'
try: try:
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort) searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.parse.quote(searchterm), format, sort)
except: except:
searchterm = searchterm.encode('utf-8') searchterm = searchterm.encode('utf-8')
searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort) searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.parse.quote(searchterm), format, sort)
logger.info("Searching rutracker using term: %s", searchterm) logger.info("Searching rutracker using term: %s", searchterm)
return searchurl return searchurl
@@ -114,7 +115,7 @@ class Rutracker(object):
try: try:
headers = {'Referer': self.search_referer} headers = {'Referer': self.search_referer}
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout) r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
soup = BeautifulSoup(r.content, 'html5lib') soup = BeautifulSoup(r.content, 'html.parser')
# Debug # Debug
# logger.debug (soup.prettify()) # logger.debug (soup.prettify())
@@ -123,7 +124,7 @@ class Rutracker(object):
if not self.still_logged_in(soup): if not self.still_logged_in(soup):
self.login() self.login()
r = self.session.get(url=searchurl, timeout=self.timeout) r = self.session.get(url=searchurl, timeout=self.timeout)
soup = BeautifulSoup(r.content, 'html5lib') soup = BeautifulSoup(r.content, 'html.parser')
if not self.still_logged_in(soup): if not self.still_logged_in(soup):
logger.error("Error getting rutracker data") logger.error("Error getting rutracker data")
return None return None
@@ -159,8 +160,8 @@ class Rutracker(object):
# Torrent topic page # Torrent topic page
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[ torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
't'] 't']
topicurl = 'http://rutracker.org/forum/viewtopic.php?t=' + torrent_id topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id
rulist.append((title, size, topicurl, 'rutracker.org', 'torrent', True)) rulist.append(Result(title, size, url, 'rutracker.org', 'torrent', True))
else: else:
logger.info("%s is larger than the maxsize or has too little seeders for this category, " logger.info("%s is larger than the maxsize or has too little seeders for this category, "
"skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds))) "skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds)))
@@ -179,7 +180,7 @@ class Rutracker(object):
return the .torrent data return the .torrent data
""" """
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t'] torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t']
downloadurl = 'http://rutracker.org/forum/dl.php?t=' + torrent_id downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
cookie = {'bb_dl': torrent_id} cookie = {'bb_dl': torrent_id}
try: try:
headers = {'Referer': url} headers = {'Referer': url}
+7 -8
View File
@@ -17,7 +17,7 @@
# Stolen from Sick-Beard's sab.py # # Stolen from Sick-Beard's sab.py #
################################### ###################################
import cookielib import http.cookiejar
import headphones import headphones
from headphones.common import USER_AGENT from headphones.common import USER_AGENT
@@ -74,29 +74,28 @@ def sendNZB(nzb):
# if we get a raw data result we want to upload it to SAB # if we get a raw data result we want to upload it to SAB
elif nzb.resultType == "nzbdata": elif nzb.resultType == "nzbdata":
# Sanitize the file a bit, since we can only use ascii chars with MultiPartPostHandler nzbdata = nzb.extraInfo[0]
nzbdata = helpers.latinToAscii(nzb.extraInfo[0])
params['mode'] = 'addfile' params['mode'] = 'addfile'
files = {"nzbfile": (helpers.latinToAscii(nzb.name) + ".nzb", nzbdata)} files = {"nzbfile": (nzb.name + ".nzb", nzbdata)}
headers = {'User-Agent': USER_AGENT} headers = {'User-Agent': USER_AGENT}
logger.info("Attempting to connect to SABnzbd on url: %s" % headphones.CONFIG.SAB_HOST) logger.info("Attempting to connect to SABnzbd on url: %s" % headphones.CONFIG.SAB_HOST)
if nzb.resultType == "nzb": if nzb.resultType == "nzb":
response = sab_api_call('send_nzb', params=params) response = sab_api_call('send_nzb', params=params)
elif nzb.resultType == "nzbdata": elif nzb.resultType == "nzbdata":
cookies = cookielib.CookieJar() cookies = http.cookiejar.CookieJar()
response = sab_api_call('send_nzb', params=params, method="post", files=files, response = sab_api_call('send_nzb', params=params, method="post", files=files,
cookies=cookies, headers=headers) cookies=cookies, headers=headers)
if not response: if not response:
logger.info(u"No data returned from SABnzbd, NZB not sent") logger.info("No data returned from SABnzbd, NZB not sent")
return False return False
if response['status']: if response['status']:
logger.info(u"NZB sent to SABnzbd successfully") logger.info("NZB sent to SABnzbd successfully")
return True return True
else: else:
logger.error(u"Error sending NZB to SABnzbd: %s" % response['error']) logger.error("Error sending NZB to SABnzbd: %s" % response['error'])
return False return False
+332 -268
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -38,8 +38,8 @@ class SoftChrootTest(TestCase):
cf = SoftChroot(path) cf = SoftChroot(path)
self.assertIsNone(cf) self.assertIsNone(cf)
self.assertRegexpMatches(str(exc.exception), r'No such directory') self.assertRegex(str(exc.exception), r'No such directory')
self.assertRegexpMatches(str(exc.exception), path) self.assertRegex(str(exc.exception), path)
@mock.patch('headphones.softchroot.os', wrap=os, name='OsMock') @mock.patch('headphones.softchroot.os', wrap=os, name='OsMock')
def test_create_on_file(self, os_mock): def test_create_on_file(self, os_mock):
@@ -57,8 +57,8 @@ class SoftChrootTest(TestCase):
self.assertTrue(os_mock.path.isdir.called) self.assertTrue(os_mock.path.isdir.called)
self.assertRegexpMatches(str(exc.exception), r'No such directory') self.assertRegex(str(exc.exception), r'No such directory')
self.assertRegexpMatches(str(exc.exception), path) self.assertRegex(str(exc.exception), path)
@TestArgs( @TestArgs(
(None, None), (None, None),
+7 -8
View File
@@ -15,8 +15,8 @@
import time import time
import json import json
import base64 from base64 import b64encode
import urlparse import urllib.parse
import os import os
from headphones import logger, request from headphones import logger, request
@@ -36,10 +36,10 @@ def addTorrent(link, data=None):
if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data: if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data:
if data: if data:
metainfo = str(base64.b64encode(data)) metainfo = b64encode(data).decode("utf-8")
else: else:
with open(link, 'rb') as f: with open(link, 'rb') as f:
metainfo = str(base64.b64encode(f.read())) metainfo = b64encode(f.read()).decode("utf-8")
arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
else: else:
arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
@@ -57,7 +57,7 @@ def addTorrent(link, data=None):
else: else:
retid = False retid = False
logger.info(u"Torrent sent to Transmission successfully") logger.info("Torrent sent to Transmission successfully")
return retid return retid
else: else:
@@ -167,7 +167,7 @@ def torrentAction(method, arguments):
# Fix the URL. We assume that the user does not point to the RPC endpoint, # Fix the URL. We assume that the user does not point to the RPC endpoint,
# so add it if it is missing. # so add it if it is missing.
parts = list(urlparse.urlparse(host)) parts = list(urllib.parse.urlparse(host))
if not parts[0] in ("http", "https"): if not parts[0] in ("http", "https"):
parts[0] = "http" parts[0] = "http"
@@ -175,7 +175,7 @@ def torrentAction(method, arguments):
if not parts[2].endswith("/rpc"): if not parts[2].endswith("/rpc"):
parts[2] += "/transmission/rpc" parts[2] += "/transmission/rpc"
host = urlparse.urlunparse(parts) host = urllib.parse.urlunparse(parts)
data = {'method': method, 'arguments': arguments} data = {'method': method, 'arguments': arguments}
data_json = json.dumps(data) data_json = json.dumps(data)
auth = (username, password) if username and password else None auth = (username, password) if username and password else None
@@ -205,5 +205,4 @@ def torrentAction(method, arguments):
continue continue
resp_json = response.json() resp_json = response.json()
print resp_json
return resp_json return resp_json
+10
View File
@@ -0,0 +1,10 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Result:
title: str
size: int
url: str
provider: str
kind: str
matches: bool
+1 -1
View File
@@ -44,7 +44,7 @@ class TestCase(TC):
@_d @_d
def assertRegexpMatches(self, *args, **kw): def assertRegexpMatches(self, *args, **kw):
return super(TestCase, self).assertRegexpMatches(*args, **kw) return super(TestCase, self).assertRegex(*args, **kw)
# ----------------------------------------------------------- # -----------------------------------------------------------
# NOT DUMMY ASSERTIONS # NOT DUMMY ASSERTIONS
+16 -16
View File
@@ -13,13 +13,13 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import urllib import urllib.request, urllib.parse, urllib.error
import json import json
import time import time
from collections import namedtuple from collections import namedtuple
import urllib2 import urllib.request, urllib.error, urllib.parse
import urlparse import urllib.parse
import cookielib import http.cookiejar
import re import re
import os import os
@@ -52,23 +52,23 @@ class utorrentclient(object):
def _make_opener(self, realm, base_url, username, password): def _make_opener(self, realm, base_url, username, password):
"""uTorrent API need HTTP Basic Auth and cookie support for token verify.""" """uTorrent API need HTTP Basic Auth and cookie support for token verify."""
auth = urllib2.HTTPBasicAuthHandler() auth = urllib.request.HTTPBasicAuthHandler()
auth.add_password(realm=realm, uri=base_url, user=username, passwd=password) auth.add_password(realm=realm, uri=base_url, user=username, passwd=password)
opener = urllib2.build_opener(auth) opener = urllib.request.build_opener(auth)
urllib2.install_opener(opener) urllib.request.install_opener(opener)
cookie_jar = cookielib.CookieJar() cookie_jar = http.cookiejar.CookieJar()
cookie_handler = urllib2.HTTPCookieProcessor(cookie_jar) cookie_handler = urllib.request.HTTPCookieProcessor(cookie_jar)
handlers = [auth, cookie_handler] handlers = [auth, cookie_handler]
opener = urllib2.build_opener(*handlers) opener = urllib.request.build_opener(*handlers)
return opener return opener
def _get_token(self): def _get_token(self):
url = urlparse.urljoin(self.base_url, 'gui/token.html') url = urllib.parse.urljoin(self.base_url, 'gui/token.html')
try: try:
response = self.opener.open(url) response = self.opener.open(url)
except urllib2.HTTPError as err: except urllib.error.HTTPError as err:
logger.debug('URL: ' + str(url)) logger.debug('URL: ' + str(url))
logger.debug('Error getting Token. uTorrent responded with error: ' + str(err)) logger.debug('Error getting Token. uTorrent responded with error: ' + str(err))
return return
@@ -77,7 +77,7 @@ class utorrentclient(object):
def list(self, **kwargs): def list(self, **kwargs):
params = [('list', '1')] params = [('list', '1')]
params += kwargs.items() params += list(kwargs.items())
return self._action(params) return self._action(params)
def add_url(self, url): def add_url(self, url):
@@ -150,8 +150,8 @@ class utorrentclient(object):
if not self.token: if not self.token:
return return
url = self.base_url + '/gui/' + '?token=' + self.token + '&' + urllib.urlencode(params) url = self.base_url + '/gui/' + '?token=' + self.token + '&' + urllib.parse.urlencode(params)
request = urllib2.Request(url) request = urllib.request.Request(url)
if body: if body:
request.add_data(body) request.add_data(body)
@@ -162,7 +162,7 @@ class utorrentclient(object):
try: try:
response = self.opener.open(request) response = self.opener.open(request)
return response.code, json.loads(response.read()) return response.code, json.loads(response.read())
except urllib2.HTTPError as err: except urllib.error.HTTPError as err:
logger.debug('URL: ' + str(url)) logger.debug('URL: ' + str(url))
logger.debug('uTorrent webUI raised the following error: ' + str(err)) logger.debug('uTorrent webUI raised the following error: ' + str(err))
+1 -1
View File
@@ -43,7 +43,7 @@ def runGit(args):
shell=True, shell=True,
cwd=headphones.PROG_DIR) cwd=headphones.PROG_DIR)
output, err = p.communicate() output, err = p.communicate()
output = output.strip() output = output.decode('utf-8').strip()
logger.debug('Git output: ' + output) logger.debug('Git output: ' + output)
except OSError as e: except OSError as e:
+114 -114
View File
@@ -15,34 +15,46 @@
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi # NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
from operator import itemgetter
import threading
import hashlib
import random
import urllib
import json import json
import time
import cgi
import sys
import urllib2
import os import os
import random
import re import re
from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers, crier import secrets
from headphones.helpers import checked, radio, today, clean_name import sys
from mako.lookup import TemplateLookup import threading
from mako import exceptions import time
import headphones from collections import OrderedDict
import cherrypy from dataclasses import asdict
from html import escape as html_escape
from operator import itemgetter
from urllib import parse
try: import cherrypy
# pylint:disable=E0611 from mako import exceptions
# ignore this error because we are catching the ImportError from mako.lookup import TemplateLookup
from collections import OrderedDict
# pylint:enable=E0611 import headphones
except ImportError: from headphones import (
# Python 2.6.x fallback, from libs crier,
from ordereddict import OrderedDict db,
importer,
lastfm,
librarysync,
logger,
mb,
notifiers,
searcher,
)
from headphones.helpers import (
checked,
clean_name,
have_pct_have_total,
pattern_substitute,
radio,
replace_illegal_chars,
today,
)
from headphones.types import Result
def serve_template(templatename, **kwargs): def serve_template(templatename, **kwargs):
@@ -97,7 +109,7 @@ class WebInterface(object):
# Serve the extras up as a dict to make things easier for new templates (append new extras to the end) # 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 extras_list = headphones.POSSIBLE_EXTRAS
if artist['Extras']: if artist['Extras']:
artist_extras = map(int, artist['Extras'].split(',')) artist_extras = list(map(int, artist['Extras'].split(',')))
else: else:
artist_extras = [] artist_extras = []
@@ -158,8 +170,8 @@ class WebInterface(object):
else: else:
searchresults = mb.findSeries(name, limit=100) searchresults = mb.findSeries(name, limit=100)
return serve_template(templatename="searchresults.html", return serve_template(templatename="searchresults.html",
title='Search Results for: "' + cgi.escape(name) + '"', title='Search Results for: "' + html_escape(name) + '"',
searchresults=searchresults, name=cgi.escape(name), type=type) searchresults=searchresults, name=html_escape(name), type=type)
@cherrypy.expose @cherrypy.expose
def addArtist(self, artistid): def addArtist(self, artistid):
@@ -230,7 +242,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def pauseArtist(self, ArtistID): def pauseArtist(self, ArtistID):
logger.info(u"Pausing artist: " + ArtistID) logger.info("Pausing artist: " + ArtistID)
myDB = db.DBConnection() myDB = db.DBConnection()
controlValueDict = {'ArtistID': ArtistID} controlValueDict = {'ArtistID': ArtistID}
newValueDict = {'Status': 'Paused'} newValueDict = {'Status': 'Paused'}
@@ -239,7 +251,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def resumeArtist(self, ArtistID): def resumeArtist(self, ArtistID):
logger.info(u"Resuming artist: " + ArtistID) logger.info("Resuming artist: " + ArtistID)
myDB = db.DBConnection() myDB = db.DBConnection()
controlValueDict = {'ArtistID': ArtistID} controlValueDict = {'ArtistID': ArtistID}
newValueDict = {'Status': 'Active'} newValueDict = {'Status': 'Active'}
@@ -252,9 +264,9 @@ class WebInterface(object):
for name in namecheck: for name in namecheck:
artistname = name['ArtistName'] artistname = name['ArtistName']
try: try:
logger.info(u"Deleting all traces of artist: " + artistname) logger.info("Deleting all traces of artist: " + artistname)
except TypeError: except TypeError:
logger.info(u"Deleting all traces of artist: null") logger.info("Deleting all traces of artist: null")
myDB.action('DELETE from artists WHERE ArtistID=?', [ArtistID]) myDB.action('DELETE from artists WHERE ArtistID=?', [ArtistID])
from headphones import cache from headphones import cache
@@ -291,7 +303,7 @@ class WebInterface(object):
myDB = db.DBConnection() myDB = db.DBConnection()
artist_name = myDB.select('SELECT DISTINCT ArtistName FROM artists WHERE ArtistID=?', [ArtistID])[0][0] artist_name = myDB.select('SELECT DISTINCT ArtistName FROM artists WHERE ArtistID=?', [ArtistID])[0][0]
logger.info(u"Scanning artist: %s", artist_name) logger.info("Scanning artist: %s", artist_name)
full_folder_format = headphones.CONFIG.FOLDER_FORMAT full_folder_format = headphones.CONFIG.FOLDER_FORMAT
folder_format = re.findall(r'(.*?[Aa]rtist?)\.*', full_folder_format)[0] folder_format = re.findall(r'(.*?[Aa]rtist?)\.*', full_folder_format)[0]
@@ -314,7 +326,7 @@ class WebInterface(object):
sortname = artist sortname = artist
if sortname[0].isdigit(): if sortname[0].isdigit():
firstchar = u'0-9' firstchar = '0-9'
else: else:
firstchar = sortname[0] firstchar = sortname[0]
@@ -326,9 +338,9 @@ class WebInterface(object):
'$first': firstchar.lower(), '$first': firstchar.lower(),
} }
folder = helpers.pattern_substitute(folder_format.strip(), values, normalize=True) folder = pattern_substitute(folder_format.strip(), values, normalize=True)
folder = helpers.replace_illegal_chars(folder, type="folder") folder = replace_illegal_chars(folder, type="folder")
folder = folder.replace('./', '_/').replace('/.', '/_') folder = folder.replace('./', '_/').replace('/.', '/_')
if folder.endswith('.'): if folder.endswith('.'):
@@ -363,7 +375,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def deleteEmptyArtists(self): def deleteEmptyArtists(self):
logger.info(u"Deleting all empty artists") logger.info("Deleting all empty artists")
myDB = db.DBConnection() myDB = db.DBConnection()
emptyArtistIDs = [row['ArtistID'] for row in emptyArtistIDs = [row['ArtistID'] for row in
myDB.select("SELECT ArtistID FROM artists WHERE LatestAlbum IS NULL")] myDB.select("SELECT ArtistID FROM artists WHERE LatestAlbum IS NULL")]
@@ -423,7 +435,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def queueAlbum(self, AlbumID, ArtistID=None, new=False, redirect=None, lossless=False): def queueAlbum(self, AlbumID, ArtistID=None, new=False, redirect=None, lossless=False):
logger.info(u"Marking album: " + AlbumID + " as wanted...") logger.info("Marking album: " + AlbumID + " as wanted...")
myDB = db.DBConnection() myDB = db.DBConnection()
controlValueDict = {'AlbumID': AlbumID} controlValueDict = {'AlbumID': AlbumID}
if lossless: if lossless:
@@ -438,49 +450,36 @@ class WebInterface(object):
raise cherrypy.HTTPRedirect(redirect) raise cherrypy.HTTPRedirect(redirect)
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def choose_specific_download(self, AlbumID): def choose_specific_download(self, AlbumID):
results = searcher.searchforalbum(AlbumID, choose_specific_download=True) results = searcher.searchforalbum(AlbumID, choose_specific_download=True) or []
return list(map(asdict, results))
results_as_dicts = []
for result in results:
result_dict = {
'title': result[0],
'size': result[1],
'url': result[2],
'provider': result[3],
'kind': result[4],
'matches': result[5]
}
results_as_dicts.append(result_dict)
s = json.dumps(results_as_dicts)
cherrypy.response.headers['Content-type'] = 'application/json'
return s
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs): def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs):
# Handle situations where the torrent url contains arguments that are parsed # Handle situations where the torrent url contains arguments that are parsed
if kwargs: if kwargs:
url = urllib2.quote(url, safe=":?/=&") + '&' + urllib.urlencode(kwargs) url = parse.quote(url, safe=":?/=&") + '&' + parse.urlencode(kwargs)
try: try:
result = [(title, int(size), url, provider, kind)] result = [Result(title, int(size), url, provider, kind, True)]
except ValueError: except ValueError:
result = [(title, float(size), url, provider, kind)] result = [Result(title, float(size), url, provider, kind, True)]
logger.info(u"Making sure we can download the chosen result") logger.info("Making sure we can download the chosen result")
(data, bestqual) = searcher.preprocess(result) data, result = searcher.preprocess(result)
if data and bestqual: if data and result:
myDB = db.DBConnection() myDB = db.DBConnection()
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone() album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
searcher.send_to_downloader(data, bestqual, album) searcher.send_to_downloader(data, result, album)
return json.dumps({'result': 'success'}) return {'result': 'success'}
else: else:
return json.dumps({'result': 'failure'}) return {'result': 'failure'}
@cherrypy.expose @cherrypy.expose
def unqueueAlbum(self, AlbumID, ArtistID): def unqueueAlbum(self, AlbumID, ArtistID):
logger.info(u"Marking album: " + AlbumID + "as skipped...") logger.info("Marking album: " + AlbumID + "as skipped...")
myDB = db.DBConnection() myDB = db.DBConnection()
controlValueDict = {'AlbumID': AlbumID} controlValueDict = {'AlbumID': AlbumID}
newValueDict = {'Status': 'Skipped'} newValueDict = {'Status': 'Skipped'}
@@ -489,7 +488,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def deleteAlbum(self, AlbumID, ArtistID=None): def deleteAlbum(self, AlbumID, ArtistID=None):
logger.info(u"Deleting all traces of album: " + AlbumID) logger.info("Deleting all traces of album: " + AlbumID)
myDB = db.DBConnection() myDB = db.DBConnection()
myDB.action('DELETE from have WHERE Matched=?', [AlbumID]) myDB.action('DELETE from have WHERE Matched=?', [AlbumID])
@@ -528,7 +527,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def editSearchTerm(self, AlbumID, SearchTerm): def editSearchTerm(self, AlbumID, SearchTerm):
logger.info(u"Updating search term for albumid: " + AlbumID) logger.info("Updating search term for albumid: " + AlbumID)
myDB = db.DBConnection() myDB = db.DBConnection()
controlValueDict = {'AlbumID': AlbumID} controlValueDict = {'AlbumID': AlbumID}
newValueDict = {'SearchTerm': SearchTerm} newValueDict = {'SearchTerm': SearchTerm}
@@ -586,7 +585,7 @@ class WebInterface(object):
for albums in have_albums: for albums in have_albums:
# Have to skip over manually matched tracks # Have to skip over manually matched tracks
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']: if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
original_clean = helpers.clean_name( original_clean = clean_name(
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle']) albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
# else: # else:
# original_clean = None # original_clean = None
@@ -633,8 +632,8 @@ class WebInterface(object):
(artist, album)) (artist, album))
elif action == "matchArtist": elif action == "matchArtist":
existing_artist_clean = helpers.clean_name(existing_artist).lower() existing_artist_clean = clean_name(existing_artist).lower()
new_artist_clean = helpers.clean_name(new_artist).lower() new_artist_clean = clean_name(new_artist).lower()
if new_artist_clean != existing_artist_clean: if new_artist_clean != existing_artist_clean:
have_tracks = myDB.action( have_tracks = myDB.action(
'SELECT Matched, CleanName, Location, BitRate, Format FROM have WHERE ArtistName=?', 'SELECT Matched, CleanName, Location, BitRate, Format FROM have WHERE ArtistName=?',
@@ -678,10 +677,10 @@ class WebInterface(object):
"Artist %s already named appropriately; nothing to modify" % existing_artist) "Artist %s already named appropriately; nothing to modify" % existing_artist)
elif action == "matchAlbum": elif action == "matchAlbum":
existing_artist_clean = helpers.clean_name(existing_artist).lower() existing_artist_clean = clean_name(existing_artist).lower()
new_artist_clean = helpers.clean_name(new_artist).lower() new_artist_clean = clean_name(new_artist).lower()
existing_album_clean = helpers.clean_name(existing_album).lower() existing_album_clean = clean_name(existing_album).lower()
new_album_clean = helpers.clean_name(new_album).lower() new_album_clean = clean_name(new_album).lower()
existing_clean_string = existing_artist_clean + " " + existing_album_clean existing_clean_string = existing_artist_clean + " " + existing_album_clean
new_clean_string = new_artist_clean + " " + new_album_clean new_clean_string = new_artist_clean + " " + new_album_clean
if existing_clean_string != new_clean_string: if existing_clean_string != new_clean_string:
@@ -737,7 +736,7 @@ class WebInterface(object):
'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have') 'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have')
for albums in manualalbums: for albums in manualalbums:
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']: if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
original_clean = helpers.clean_name( original_clean = clean_name(
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle']) albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[ if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[
'CleanName'] != original_clean: 'CleanName'] != original_clean:
@@ -778,7 +777,7 @@ class WebInterface(object):
[artist]) [artist])
update_count = 0 update_count = 0
for tracks in update_clean: for tracks in update_clean:
original_clean = helpers.clean_name( original_clean = clean_name(
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[ tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
'TrackTitle']).lower() 'TrackTitle']).lower()
album = tracks['AlbumTitle'] album = tracks['AlbumTitle']
@@ -810,7 +809,7 @@ class WebInterface(object):
(artist, album)) (artist, album))
update_count = 0 update_count = 0
for tracks in update_clean: for tracks in update_clean:
original_clean = helpers.clean_name( original_clean = clean_name(
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[ tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
'TrackTitle']).lower() 'TrackTitle']).lower()
track_title = tracks['TrackTitle'] track_title = tracks['TrackTitle']
@@ -959,6 +958,7 @@ class WebInterface(object):
raise cherrypy.HTTPRedirect("logs") raise cherrypy.HTTPRedirect("logs")
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getLog(self, iDisplayStart=0, iDisplayLength=100, iSortCol_0=0, sSortDir_0="desc", def getLog(self, iDisplayStart=0, iDisplayLength=100, iSortCol_0=0, sSortDir_0="desc",
sSearch="", **kwargs): sSearch="", **kwargs):
iDisplayStart = int(iDisplayStart) iDisplayStart = int(iDisplayStart)
@@ -981,13 +981,14 @@ class WebInterface(object):
rows = filtered[iDisplayStart:(iDisplayStart + iDisplayLength)] rows = filtered[iDisplayStart:(iDisplayStart + iDisplayLength)]
rows = [[row[0], row[2], row[1]] for row in rows] rows = [[row[0], row[2], row[1]] for row in rows]
return json.dumps({ return {
'iTotalDisplayRecords': len(filtered), 'iTotalDisplayRecords': len(filtered),
'iTotalRecords': len(headphones.LOG_LIST), 'iTotalRecords': len(headphones.LOG_LIST),
'aaData': rows, 'aaData': rows,
}) }
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getArtists_json(self, iDisplayStart=0, iDisplayLength=100, sSearch="", iSortCol_0='0', def getArtists_json(self, iDisplayStart=0, iDisplayLength=100, sSearch="", iSortCol_0='0',
sSortDir_0='asc', **kwargs): sSortDir_0='asc', **kwargs):
iDisplayStart = int(iDisplayStart) iDisplayStart = int(iDisplayStart)
@@ -1016,9 +1017,7 @@ class WebInterface(object):
totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0] totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0]
if sortbyhavepercent: if sortbyhavepercent:
filtered.sort(key=lambda x: ( filtered.sort(key=have_pct_have_total, reverse=sSortDir_0 == "asc")
float(x['HaveTracks']) / x['TotalTracks'] if x['TotalTracks'] > 0 else 0.0,
x['HaveTracks'] if x['HaveTracks'] else 0.0), reverse=sSortDir_0 == "asc")
# can't figure out how to change the datatables default sorting order when its using an ajax datasource so ill # can't figure out how to change the datatables default sorting order when its using an ajax datasource so ill
# just reverse it here and the first click on the "Latest Album" header will sort by descending release date # just reverse it here and the first click on the "Latest Album" header will sort by descending release date
@@ -1055,61 +1054,58 @@ class WebInterface(object):
rows.append(row) rows.append(row)
dict = {'iTotalDisplayRecords': len(filtered), data = {'iTotalDisplayRecords': len(filtered),
'iTotalRecords': totalcount, 'iTotalRecords': totalcount,
'aaData': rows, 'aaData': rows,
} }
s = json.dumps(dict) return data
cherrypy.response.headers['Content-type'] = 'application/json'
return s
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getAlbumsByArtist_json(self, artist=None): def getAlbumsByArtist_json(self, artist=None):
myDB = db.DBConnection() myDB = db.DBConnection()
album_json = {} data = {}
counter = 0 counter = 0
album_list = myDB.select("SELECT AlbumTitle from albums WHERE ArtistName=?", [artist]) album_list = myDB.select("SELECT AlbumTitle from albums WHERE ArtistName=?", [artist])
for album in album_list: for album in album_list:
album_json[counter] = album['AlbumTitle'] data[counter] = album['AlbumTitle']
counter += 1 counter += 1
json_albums = json.dumps(album_json)
cherrypy.response.headers['Content-type'] = 'application/json' return data
return json_albums
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getArtistjson(self, ArtistID, **kwargs): def getArtistjson(self, ArtistID, **kwargs):
myDB = db.DBConnection() myDB = db.DBConnection()
artist = myDB.action('SELECT * FROM artists WHERE ArtistID=?', [ArtistID]).fetchone() artist = myDB.action('SELECT * FROM artists WHERE ArtistID=?', [ArtistID]).fetchone()
artist_json = json.dumps({ return {
'ArtistName': artist['ArtistName'], 'ArtistName': artist['ArtistName'],
'Status': artist['Status'] 'Status': artist['Status']
}) }
return artist_json
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getAlbumjson(self, AlbumID, **kwargs): def getAlbumjson(self, AlbumID, **kwargs):
myDB = db.DBConnection() myDB = db.DBConnection()
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone() album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
album_json = json.dumps({ return {
'AlbumTitle': album['AlbumTitle'], 'AlbumTitle': album['AlbumTitle'],
'ArtistName': album['ArtistName'], 'ArtistName': album['ArtistName'],
'Status': album['Status'] 'Status': album['Status']
}) }
return album_json
@cherrypy.expose @cherrypy.expose
def clearhistory(self, type=None, date_added=None, title=None): def clearhistory(self, type=None, date_added=None, title=None):
myDB = db.DBConnection() myDB = db.DBConnection()
if type: if type:
if type == 'all': if type == 'all':
logger.info(u"Clearing all history") logger.info("Clearing all history")
myDB.action('DELETE from snatched WHERE Status NOT LIKE "Seed%"') myDB.action('DELETE from snatched WHERE Status NOT LIKE "Seed%"')
else: else:
logger.info(u"Clearing history where status is %s" % type) logger.info("Clearing history where status is %s" % type)
myDB.action('DELETE from snatched WHERE Status=?', [type]) myDB.action('DELETE from snatched WHERE Status=?', [type])
else: else:
logger.info(u"Deleting '%s' from history" % title) logger.info("Deleting '%s' from history" % title)
myDB.action( myDB.action(
'DELETE from snatched WHERE Status NOT LIKE "Seed%" AND Title=? AND DateAdded=?', 'DELETE from snatched WHERE Status NOT LIKE "Seed%" AND Title=? AND DateAdded=?',
[title, date_added]) [title, date_added])
@@ -1117,7 +1113,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def generateAPI(self): def generateAPI(self):
apikey = hashlib.sha224(str(random.getrandbits(256))).hexdigest()[0:32] apikey = secrets.token_hex(nbytes=16)
logger.info("New API generated") logger.info("New API generated")
return apikey return apikey
@@ -1272,6 +1268,7 @@ class WebInterface(object):
"cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH, "cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH,
"move_files": checked(headphones.CONFIG.MOVE_FILES), "move_files": checked(headphones.CONFIG.MOVE_FILES),
"rename_files": checked(headphones.CONFIG.RENAME_FILES), "rename_files": checked(headphones.CONFIG.RENAME_FILES),
"rename_single_disc_ignore": checked(headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE),
"correct_metadata": checked(headphones.CONFIG.CORRECT_METADATA), "correct_metadata": checked(headphones.CONFIG.CORRECT_METADATA),
"cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES), "cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES),
"keep_nfo": checked(headphones.CONFIG.KEEP_NFO), "keep_nfo": checked(headphones.CONFIG.KEEP_NFO),
@@ -1388,6 +1385,7 @@ class WebInterface(object):
"custompass": headphones.CONFIG.CUSTOMPASS, "custompass": headphones.CONFIG.CUSTOMPASS,
"hpuser": headphones.CONFIG.HPUSER, "hpuser": headphones.CONFIG.HPUSER,
"hppass": headphones.CONFIG.HPPASS, "hppass": headphones.CONFIG.HPPASS,
"lastfm_apikey": headphones.CONFIG.LASTFM_APIKEY,
"songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED), "songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED),
"songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY, "songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY,
"songkick_location": headphones.CONFIG.SONGKICK_LOCATION, "songkick_location": headphones.CONFIG.SONGKICK_LOCATION,
@@ -1418,7 +1416,7 @@ class WebInterface(object):
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID "join_deviceid": headphones.CONFIG.JOIN_DEVICEID
} }
for k, v in config.iteritems(): for k, v in config.items():
if isinstance(v, headphones.config.path): if isinstance(v, headphones.config.path):
# need to apply SoftChroot to paths: # need to apply SoftChroot to paths:
nv = headphones.SOFT_CHROOT.apply(v) nv = headphones.SOFT_CHROOT.apply(v)
@@ -1435,7 +1433,7 @@ class WebInterface(object):
extras_list = [extra_munges.get(x, x) for x in headphones.POSSIBLE_EXTRAS] extras_list = [extra_munges.get(x, x) for x in headphones.POSSIBLE_EXTRAS]
if headphones.CONFIG.EXTRAS: if headphones.CONFIG.EXTRAS:
extras = map(int, headphones.CONFIG.EXTRAS.split(',')) extras = list(map(int, headphones.CONFIG.EXTRAS.split(',')))
else: else:
extras = [] extras = []
@@ -1464,8 +1462,8 @@ class WebInterface(object):
"use_waffles", "use_rutracker", "use_waffles", "use_rutracker",
"use_orpheus", "use_redacted", "redacted_use_fltoken", "preferred_bitrate_allow_lossless", "use_orpheus", "use_redacted", "redacted_use_fltoken", "preferred_bitrate_allow_lossless",
"detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files",
"rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", "rename_files", "rename_single_disc_ignore", "correct_metadata", "cleanup_files",
"embed_album_art", "embed_lyrics", "keep_nfo", "add_album_art", "embed_album_art", "embed_lyrics",
"replace_existing_folders", "keep_original_folder", "file_underscores", "replace_existing_folders", "keep_original_folder", "file_underscores",
"include_extras", "official_releases_only", "include_extras", "official_releases_only",
"wait_until_release_date", "autowant_upcoming", "autowant_all", "wait_until_release_date", "autowant_upcoming", "autowant_all",
@@ -1496,7 +1494,7 @@ class WebInterface(object):
kwargs[plain_config] = kwargs[use_config] kwargs[plain_config] = kwargs[use_config]
del kwargs[use_config] del kwargs[use_config]
for k, v in kwargs.iteritems(): for k, v in kwargs.items():
# TODO : HUGE crutch. It is all because there is no way to deal with options... # TODO : HUGE crutch. It is all because there is no way to deal with options...
try: try:
_conf = headphones.CONFIG._define(k) _conf = headphones.CONFIG._define(k)
@@ -1648,12 +1646,13 @@ class WebInterface(object):
return a.fetchData() return a.fetchData()
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getInfo(self, ArtistID=None, AlbumID=None): def getInfo(self, ArtistID=None, AlbumID=None):
from headphones import cache from headphones import cache
info_dict = cache.getInfo(ArtistID, AlbumID) info_dict = cache.getInfo(ArtistID, AlbumID)
return json.dumps(info_dict) return info_dict
@cherrypy.expose @cherrypy.expose
def getArtwork(self, ArtistID=None, AlbumID=None): def getArtwork(self, ArtistID=None, AlbumID=None):
@@ -1670,24 +1669,25 @@ class WebInterface(object):
# If you just want to get the last.fm image links for an album, make sure # If you just want to get the last.fm image links for an album, make sure
# to pass a releaseid and not a releasegroupid # to pass a releaseid and not a releasegroupid
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out()
def getImageLinks(self, ArtistID=None, AlbumID=None): def getImageLinks(self, ArtistID=None, AlbumID=None):
from headphones import cache from headphones import cache
image_dict = cache.getImageLinks(ArtistID, AlbumID) image_dict = cache.getImageLinks(ArtistID, AlbumID)
# Return the Cover Art Archive urls if not found on last.fm # Return the Cover Art Archive urls if not found on last.fm
if AlbumID and not image_dict: if AlbumID and not image_dict:
image_url = "http://coverartarchive.org/release/%s/front-500.jpg" % AlbumID image_url = "https://coverartarchive.org/release/%s/front-500.jpg" % AlbumID
thumb_url = "http://coverartarchive.org/release/%s/front-250.jpg" % AlbumID thumb_url = "https://coverartarchive.org/release/%s/front-250.jpg" % AlbumID
image_dict = {'artwork': image_url, 'thumbnail': thumb_url} image_dict = {'artwork': image_url, 'thumbnail': thumb_url}
elif AlbumID and (not image_dict['artwork'] or not image_dict['thumbnail']): elif AlbumID and (not image_dict['artwork'] or not image_dict['thumbnail']):
if not image_dict['artwork']: if not image_dict['artwork']:
image_dict[ image_dict[
'artwork'] = "http://coverartarchive.org/release/%s/front-500.jpg" % AlbumID 'artwork'] = "https://coverartarchive.org/release/%s/front-500.jpg" % AlbumID
if not image_dict['thumbnail']: if not image_dict['thumbnail']:
image_dict[ image_dict[
'thumbnail'] = "http://coverartarchive.org/release/%s/front-250.jpg" % AlbumID 'thumbnail'] = "https://coverartarchive.org/release/%s/front-250.jpg" % AlbumID
return json.dumps(image_dict) return image_dict
@cherrypy.expose @cherrypy.expose
def twitterStep1(self): def twitterStep1(self):
@@ -1700,7 +1700,7 @@ class WebInterface(object):
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
tweet = notifiers.TwitterNotifier() tweet = notifiers.TwitterNotifier()
result = tweet._get_credentials(key) result = tweet._get_credentials(key)
logger.info(u"result: " + str(result)) logger.info("result: " + str(result))
if result: if result:
return "Key verification successful" return "Key verification successful"
else: else:
@@ -1732,14 +1732,14 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
def testPushover(self): def testPushover(self):
logger.info(u"Sending Pushover notification") logger.info("Sending Pushover notification")
pushover = notifiers.PUSHOVER() pushover = notifiers.PUSHOVER()
result = pushover.notify("hooray!", "This is a test") result = pushover.notify("hooray!", "This is a test")
return str(result) return str(result)
@cherrypy.expose @cherrypy.expose
def testPlex(self): def testPlex(self):
logger.info(u"Testing plex update") logger.info("Testing plex update")
plex = notifiers.Plex() plex = notifiers.Plex()
plex.update() plex.update()
-3
View File
@@ -111,12 +111,9 @@ def initialize(options):
}) })
conf['/api'] = {'tools.auth_basic.on': False} 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) cherrypy.tree.mount(WebInterface(), str(options['http_root']), config=conf)
try: try:
cherrypy.process.servers.check_port(str(options['http_host']), options['http_port'])
cherrypy.server.start() cherrypy.server.start()
except IOError: except IOError:
sys.stderr.write( sys.stderr.write(
+8 -8
View File
@@ -15,8 +15,8 @@
# Lesser General Public License for more details. # Lesser General Public License for more details.
# #
import urllib import urllib.request, urllib.parse, urllib.error
import urllib2 import urllib.request, urllib.error, urllib.parse
import mimetools, mimetypes import mimetools, mimetypes
import os, sys import os, sys
@@ -24,8 +24,8 @@ import os, sys
# assigning a sequence. # assigning a sequence.
doseq = 1 doseq = 1
class MultipartPostHandler(urllib2.BaseHandler): class MultipartPostHandler(urllib.request.BaseHandler):
handler_order = urllib2.HTTPHandler.handler_order - 10 # needs to run first handler_order = urllib.request.HTTPHandler.handler_order - 10 # needs to run first
def http_request(self, request): def http_request(self, request):
data = request.get_data() data = request.get_data()
@@ -33,23 +33,23 @@ class MultipartPostHandler(urllib2.BaseHandler):
v_files = [] v_files = []
v_vars = [] v_vars = []
try: try:
for(key, value) in data.items(): for(key, value) in list(data.items()):
if type(value) in (file, list, tuple): if type(value) in (file, list, tuple):
v_files.append((key, value)) v_files.append((key, value))
else: else:
v_vars.append((key, value)) v_vars.append((key, value))
except TypeError: except TypeError:
systype, value, traceback = sys.exc_info() systype, value, traceback = sys.exc_info()
raise TypeError, "not a valid non-string sequence or mapping object", traceback raise TypeError("not a valid non-string sequence or mapping object").with_traceback(traceback)
if len(v_files) == 0: if len(v_files) == 0:
data = urllib.urlencode(v_vars, doseq) data = urllib.parse.urlencode(v_vars, doseq)
else: else:
boundary, data = MultipartPostHandler.multipart_encode(v_vars, v_files) boundary, data = MultipartPostHandler.multipart_encode(v_vars, v_files)
contenttype = 'multipart/form-data; boundary=%s' % boundary contenttype = 'multipart/form-data; boundary=%s' % boundary
if(request.has_header('Content-Type') if(request.has_header('Content-Type')
and request.get_header('Content-Type').find('multipart/form-data') != 0): 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_unredirected_header('Content-Type', contenttype)
request.add_data(data) request.add_data(data)
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import sys import sys
from apscheduler.executors.base import BaseExecutor, run_job from apscheduler.executors.base import BaseExecutor, run_job
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import sys import sys
from apscheduler.executors.base import BaseExecutor, run_job 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 from apscheduler.executors.base import BaseExecutor, run_job
+1 -1
View File
@@ -1,4 +1,4 @@
from collections import Iterable, Mapping from collections.abc import Iterable, Mapping
from uuid import uuid4 from uuid import uuid4
import six 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.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import datetime_to_utc_timestamp 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.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job from apscheduler.job import Job
try: try:
import cPickle as pickle import pickle as pickle
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
import pickle import pickle
+2 -2
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import six import six
@@ -7,7 +7,7 @@ from apscheduler.util import datetime_to_utc_timestamp, utc_timestamp_to_datetim
from apscheduler.job import Job from apscheduler.job import Job
try: try:
import cPickle as pickle import pickle as pickle
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
import pickle 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.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job from apscheduler.job import Job
try: try:
import cPickle as pickle import pickle as pickle
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
import pickle import pickle
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from functools import wraps from functools import wraps
from apscheduler.schedulers.base import BaseScheduler 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 threading import Thread, Event
from apscheduler.schedulers.base import BaseScheduler 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 abc import ABCMeta, abstractmethod
from collections import MutableMapping from collections.abc import MutableMapping
from threading import RLock from threading import RLock
from datetime import datetime from datetime import datetime
from logging import getLogger from logging import getLogger
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from threading import Event from threading import Event
from apscheduler.schedulers.base import BaseScheduler 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.blocking import BlockingScheduler
from apscheduler.schedulers.base import BaseScheduler 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 from apscheduler.schedulers.base import BaseScheduler
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from datetime import timedelta from datetime import timedelta
from functools import wraps from functools import wraps
+1 -1
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from functools import wraps from functools import wraps
from apscheduler.schedulers.base import BaseScheduler 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.""" """This module contains several handy functions primarily meant for internal use."""
from __future__ import division
from datetime import date, datetime, time, timedelta, tzinfo from datetime import date, datetime, time, timedelta, tzinfo
from inspect import isfunction, ismethod, getargspec from inspect import isfunction, ismethod, getargspec
from calendar import timegm from calendar import timegm
@@ -23,7 +23,7 @@ __all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_
class _Undefined(object): class _Undefined(object):
def __nonzero__(self): def __bool__(self):
return False return False
def __bool__(self): def __bool__(self):
@@ -116,7 +116,7 @@ def convert_to_datetime(input, tz, arg_name):
m = _DATE_REGEX.match(input) m = _DATE_REGEX.match(input)
if not m: if not m:
raise ValueError('Invalid date string') raise ValueError('Invalid date string')
values = [(k, int(v or 0)) for k, v in m.groupdict().items()] values = [(k, int(v or 0)) for k, v in list(m.groupdict().items())]
values = dict(values) values = dict(values)
datetime_ = datetime(**values) datetime_ = datetime(**values)
else: else:
Executable → Regular
+14 -19
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -13,33 +12,29 @@
# The above copyright notice and this permission notice shall be # The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
import os import confuse
from sys import stderr
from beets.util import confit __version__ = '1.6.0'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
# 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(confit.LazyConfig): class IncludeLazyConfig(confuse.LazyConfig):
"""A version of Confit's LazyConfig that also merges in data from """A version of Confuse's LazyConfig that also merges in data from
YAML files specified in an `include` setting. YAML files specified in an `include` setting.
""" """
def read(self, user=True, defaults=True): def read(self, user=True, defaults=True):
super(IncludeLazyConfig, self).read(user, defaults) super().read(user, defaults)
try: try:
for view in self['include']: for view in self['include']:
filename = view.as_filename() self.set_file(view.as_filename())
if os.path.isfile(filename): except confuse.NotFoundError:
self.set_file(filename)
except confit.NotFoundError:
pass pass
except confuse.ConfigReadError as err:
stderr.write("configuration `import` failed: {}"
.format(err.reason))
# headphones
#config = IncludeLazyConfig('beets', __name__) config = IncludeLazyConfig('beets', __name__)
config = IncludeLazyConfig(os.path.dirname(__file__), __name__)
Executable → Regular
-2
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2017, Adrian Sampson. # Copyright 2017, Adrian Sampson.
# #
@@ -17,7 +16,6 @@
`python -m beets`. `python -m beets`.
""" """
from __future__ import division, absolute_import, print_function
import sys import sys
from .ui import main from .ui import main
Executable → Regular
+37 -35
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -17,7 +16,6 @@
music and items' embedded album art. music and items' embedded album art.
""" """
from __future__ import division, absolute_import, print_function
import subprocess import subprocess
import platform import platform
@@ -26,7 +24,7 @@ import os
from beets.util import displayable_path, syspath, bytestring_path from beets.util import displayable_path, syspath, bytestring_path
from beets.util.artresizer import ArtResizer from beets.util.artresizer import ArtResizer
from beets import mediafile import mediafile
def mediafile_image(image_path, maxwidth=None): def mediafile_image(image_path, maxwidth=None):
@@ -43,7 +41,7 @@ def get_art(log, item):
try: try:
mf = mediafile.MediaFile(syspath(item.path)) mf = mediafile.MediaFile(syspath(item.path))
except mediafile.UnreadableFileError as exc: except mediafile.UnreadableFileError as exc:
log.warning(u'Could not extract art from {0}: {1}', log.warning('Could not extract art from {0}: {1}',
displayable_path(item.path), exc) displayable_path(item.path), exc)
return return
@@ -51,26 +49,27 @@ def get_art(log, item):
def embed_item(log, item, imagepath, maxwidth=None, itempath=None, def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
compare_threshold=0, ifempty=False, as_album=False): compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
quality=0):
"""Embed an image into the item's media file. """Embed an image into the item's media file.
""" """
# Conditions and filters. # Conditions and filters.
if compare_threshold: if compare_threshold:
if not check_art_similarity(log, item, imagepath, compare_threshold): if not check_art_similarity(log, item, imagepath, compare_threshold):
log.info(u'Image not similar; skipping.') log.info('Image not similar; skipping.')
return return
if ifempty and get_art(log, item): if ifempty and get_art(log, item):
log.info(u'media file already contained art') log.info('media file already contained art')
return return
if maxwidth and not as_album: if maxwidth and not as_album:
imagepath = resize_image(log, imagepath, maxwidth) imagepath = resize_image(log, imagepath, maxwidth, quality)
# Get the `Image` object from the file. # Get the `Image` object from the file.
try: try:
log.debug(u'embedding {0}', displayable_path(imagepath)) log.debug('embedding {0}', displayable_path(imagepath))
image = mediafile_image(imagepath, maxwidth) image = mediafile_image(imagepath, maxwidth)
except IOError as exc: except OSError as exc:
log.warning(u'could not read image file: {0}', exc) log.warning('could not read image file: {0}', exc)
return return
# Make sure the image kind is safe (some formats only support PNG # Make sure the image kind is safe (some formats only support PNG
@@ -80,36 +79,39 @@ def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
image.mime_type) image.mime_type)
return return
item.try_write(path=itempath, tags={'images': [image]}) item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
def embed_album(log, album, maxwidth=None, quiet=False, def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
compare_threshold=0, ifempty=False): ifempty=False, quality=0):
"""Embed album art into all of the album's items. """Embed album art into all of the album's items.
""" """
imagepath = album.artpath imagepath = album.artpath
if not imagepath: if not imagepath:
log.info(u'No album art present for {0}', album) log.info('No album art present for {0}', album)
return return
if not os.path.isfile(syspath(imagepath)): if not os.path.isfile(syspath(imagepath)):
log.info(u'Album art not found at {0} for {1}', log.info('Album art not found at {0} for {1}',
displayable_path(imagepath), album) displayable_path(imagepath), album)
return return
if maxwidth: if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth) imagepath = resize_image(log, imagepath, maxwidth, quality)
log.info(u'Embedding album art into {0}', album) log.info('Embedding album art into {0}', album)
for item in album.items(): for item in album.items():
embed_item(log, item, imagepath, maxwidth, None, embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
compare_threshold, ifempty, as_album=True) ifempty, as_album=True, quality=quality)
def resize_image(log, imagepath, maxwidth): def resize_image(log, imagepath, maxwidth, quality):
"""Returns path to an image resized to maxwidth. """Returns path to an image resized to maxwidth and encoded with the
specified quality level.
""" """
log.debug(u'Resizing album art to {0} pixels wide', maxwidth) log.debug('Resizing album art to {0} pixels wide and encoding at quality \
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath)) level {1}', maxwidth, quality)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
quality=quality)
return imagepath return imagepath
@@ -131,7 +133,7 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
syspath(art, prefix=False), syspath(art, prefix=False),
'-colorspace', 'gray', 'MIFF:-'] '-colorspace', 'gray', 'MIFF:-']
compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:'] compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
log.debug(u'comparing images with pipeline {} | {}', log.debug('comparing images with pipeline {} | {}',
convert_cmd, compare_cmd) convert_cmd, compare_cmd)
convert_proc = subprocess.Popen( convert_proc = subprocess.Popen(
convert_cmd, convert_cmd,
@@ -155,7 +157,7 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
convert_proc.wait() convert_proc.wait()
if convert_proc.returncode: if convert_proc.returncode:
log.debug( log.debug(
u'ImageMagick convert failed with status {}: {!r}', 'ImageMagick convert failed with status {}: {!r}',
convert_proc.returncode, convert_proc.returncode,
convert_stderr, convert_stderr,
) )
@@ -165,7 +167,7 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
stdout, stderr = compare_proc.communicate() stdout, stderr = compare_proc.communicate()
if compare_proc.returncode: if compare_proc.returncode:
if compare_proc.returncode != 1: if compare_proc.returncode != 1:
log.debug(u'ImageMagick compare failed: {0}, {1}', log.debug('ImageMagick compare failed: {0}, {1}',
displayable_path(imagepath), displayable_path(imagepath),
displayable_path(art)) displayable_path(art))
return return
@@ -176,10 +178,10 @@ def check_art_similarity(log, item, imagepath, compare_threshold):
try: try:
phash_diff = float(out_str) phash_diff = float(out_str)
except ValueError: except ValueError:
log.debug(u'IM output is not a number: {0!r}', out_str) log.debug('IM output is not a number: {0!r}', out_str)
return return
log.debug(u'ImageMagick compare score: {0}', phash_diff) log.debug('ImageMagick compare score: {0}', phash_diff)
return phash_diff <= compare_threshold return phash_diff <= compare_threshold
return True return True
@@ -189,18 +191,18 @@ def extract(log, outpath, item):
art = get_art(log, item) art = get_art(log, item)
outpath = bytestring_path(outpath) outpath = bytestring_path(outpath)
if not art: if not art:
log.info(u'No album art present in {0}, skipping.', item) log.info('No album art present in {0}, skipping.', item)
return return
# Add an extension to the filename. # Add an extension to the filename.
ext = mediafile.image_extension(art) ext = mediafile.image_extension(art)
if not ext: if not ext:
log.warning(u'Unknown image type in {0}.', log.warning('Unknown image type in {0}.',
displayable_path(item.path)) displayable_path(item.path))
return return
outpath += bytestring_path('.' + ext) outpath += bytestring_path('.' + ext)
log.info(u'Extracting album art from: {0} to: {1}', log.info('Extracting album art from: {0} to: {1}',
item, displayable_path(outpath)) item, displayable_path(outpath))
with open(syspath(outpath), 'wb') as f: with open(syspath(outpath), 'wb') as f:
f.write(art) f.write(art)
@@ -216,7 +218,7 @@ def extract_first(log, outpath, items):
def clear(log, lib, query): def clear(log, lib, query):
items = lib.items(query) items = lib.items(query)
log.info(u'Clearing album art from {0} items', len(items)) log.info('Clearing album art from {0} items', len(items))
for item in items: for item in items:
log.debug(u'Clearing art for {0}', item) log.debug('Clearing art for {0}', item)
item.try_write(tags={'images': None}) item.try_write(tags={'images': None})
Executable → Regular
+82 -45
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -16,19 +15,59 @@
"""Facilities for automatically determining files' correct metadata. """Facilities for automatically determining files' correct metadata.
""" """
from __future__ import division, absolute_import, print_function
from beets import logging from beets import logging
from beets import config from beets import config
# Parts of external interface. # Parts of external interface.
from .hooks import AlbumInfo, TrackInfo, AlbumMatch, TrackMatch # noqa from .hooks import ( # noqa
AlbumInfo,
TrackInfo,
AlbumMatch,
TrackMatch,
Distance,
)
from .match import tag_item, tag_album, Proposal # noqa from .match import tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa from .match import Recommendation # noqa
# Global logger. # Global logger.
log = logging.getLogger('beets') 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. # Additional utilities for the main interface.
@@ -40,17 +79,17 @@ def apply_item_metadata(item, track_info):
item.artist_credit = track_info.artist_credit item.artist_credit = track_info.artist_credit
item.title = track_info.title item.title = track_info.title
item.mb_trackid = track_info.track_id item.mb_trackid = track_info.track_id
item.mb_releasetrackid = track_info.release_track_id
if track_info.artist_id: if track_info.artist_id:
item.mb_artistid = track_info.artist_id item.mb_artistid = track_info.artist_id
if track_info.data_source:
item.data_source = track_info.data_source
if track_info.lyricist is not None: for field, value in track_info.items():
item.lyricist = track_info.lyricist # We only overwrite fields that are not already hardcoded.
if track_info.composer is not None: if field in SPECIAL_FIELDS['track']:
item.composer = track_info.composer continue
if track_info.arranger is not None: if value is None:
item.arranger = track_info.arranger continue
item[field] = value
# At the moment, the other metadata is left intact (including album # At the moment, the other metadata is left intact (including album
# and track number). Perhaps these should be emptied? # and track number). Perhaps these should be emptied?
@@ -61,12 +100,19 @@ def apply_metadata(album_info, mapping):
mapping from Items to TrackInfo objects. mapping from Items to TrackInfo objects.
""" """
for item, track_info in mapping.items(): for item, track_info in mapping.items():
# Album, artist, track count. # Artist or artist credit.
if track_info.artist: if config['artist_credit']:
item.artist = track_info.artist 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)
else: else:
item.artist = album_info.artist item.artist = (track_info.artist or album_info.artist)
item.albumartist = album_info.artist item.albumartist = album_info.artist
# Album.
item.album = album_info.album item.album = album_info.album
# Artist sort and credit names. # Artist sort and credit names.
@@ -120,6 +166,7 @@ def apply_metadata(album_info, mapping):
# MusicBrainz IDs. # MusicBrainz IDs.
item.mb_trackid = track_info.track_id item.mb_trackid = track_info.track_id
item.mb_releasetrackid = track_info.release_track_id
item.mb_albumid = album_info.album_id item.mb_albumid = album_info.album_id
if track_info.artist_id: if track_info.artist_id:
item.mb_artistid = track_info.artist_id item.mb_artistid = track_info.artist_id
@@ -131,34 +178,24 @@ def apply_metadata(album_info, mapping):
# Compilation flag. # Compilation flag.
item.comp = album_info.va item.comp = album_info.va
# Miscellaneous metadata. # Track alt.
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 item.track_alt = track_info.track_alt
# Headphones seal of approval # Don't overwrite fields with empty values unless the
item.comments = 'tagged by headphones/beets' # 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
Executable → Regular
+121 -93
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -14,7 +13,6 @@
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
"""Glue between metadata sources and the matching logic.""" """Glue between metadata sources and the matching logic."""
from __future__ import division, absolute_import, print_function
from collections import namedtuple from collections import namedtuple
from functools import total_ordering from functools import total_ordering
@@ -27,14 +25,36 @@ from beets.util import as_string
from beets.autotag import mb from beets.autotag import mb
from jellyfish import levenshtein_distance from jellyfish import levenshtein_distance
from unidecode import unidecode from unidecode import unidecode
import six
log = logging.getLogger('beets') 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. # Classes used to represent candidate options.
class AttrDict(dict):
"""A dictionary that supports attribute ("dot") access, so `d.field`
is equivalent to `d['field']`.
"""
class AlbumInfo(object): 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):
"""Describes a canonical release that may be used to match a release """Describes a canonical release that may be used to match a release
in the library. Consists of these data members: in the library. Consists of these data members:
@@ -43,38 +63,22 @@ class AlbumInfo(object):
- ``artist``: name of the release's primary artist - ``artist``: name of the release's primary artist
- ``artist_id`` - ``artist_id``
- ``tracks``: list of TrackInfo objects making up the release - ``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.
The fields up through ``tracks`` are required. The others are ``mediums`` along with the fields up through ``tracks`` are required.
optional and may be None. The others are optional and may be None.
""" """
def __init__(self, album, album_id, artist, artist_id, tracks, asin=None,
albumtype=None, va=False, year=None, month=None, day=None, def __init__(self, tracks, album=None, album_id=None, artist=None,
label=None, mediums=None, artist_sort=None, artist_id=None, asin=None, albumtype=None, va=False,
releasegroup_id=None, catalognum=None, script=None, year=None, month=None, day=None, label=None, mediums=None,
language=None, country=None, albumstatus=None, media=None, artist_sort=None, releasegroup_id=None, catalognum=None,
albumdisambig=None, artist_credit=None, original_year=None, script=None, language=None, country=None, style=None,
original_month=None, original_day=None, data_source=None, genre=None, albumstatus=None, media=None, albumdisambig=None,
data_url=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):
self.album = album self.album = album
self.album_id = album_id self.album_id = album_id
self.artist = artist self.artist = artist
@@ -94,15 +98,22 @@ class AlbumInfo(object):
self.script = script self.script = script
self.language = language self.language = language
self.country = country self.country = country
self.style = style
self.genre = genre
self.albumstatus = albumstatus self.albumstatus = albumstatus
self.media = media self.media = media
self.albumdisambig = albumdisambig self.albumdisambig = albumdisambig
self.releasegroupdisambig = releasegroupdisambig
self.artist_credit = artist_credit self.artist_credit = artist_credit
self.original_year = original_year self.original_year = original_year
self.original_month = original_month self.original_month = original_month
self.original_day = original_day self.original_day = original_day
self.data_source = data_source self.data_source = data_source
self.data_url = data_url 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 # Work around a bug in python-musicbrainz-ngs that causes some
# strings to be bytes rather than Unicode. # strings to be bytes rather than Unicode.
@@ -112,53 +123,49 @@ class AlbumInfo(object):
constituent `TrackInfo` objects, are decoded to Unicode. constituent `TrackInfo` objects, are decoded to Unicode.
""" """
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort', for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
'catalognum', 'script', 'language', 'country', 'catalognum', 'script', 'language', 'country', 'style',
'albumstatus', 'albumdisambig', 'artist_credit', 'media']: 'genre', 'albumstatus', 'albumdisambig',
'releasegroupdisambig', 'artist_credit',
'media', 'discogs_albumid', 'discogs_labelid',
'discogs_artistid']:
value = getattr(self, fld) value = getattr(self, fld)
if isinstance(value, bytes): if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore')) setattr(self, fld, value.decode(codec, 'ignore'))
if self.tracks: for track in self.tracks:
for track in self.tracks: track.decode(codec)
track.decode(codec)
def copy(self):
dupe = AlbumInfo([])
dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks]
return dupe
class TrackInfo(object): class TrackInfo(AttrDict):
"""Describes a canonical track present on a release. Appears as part """Describes a canonical track present on a release. Appears as part
of an AlbumInfo's ``tracks`` list. Consists of these data members: of an AlbumInfo's ``tracks`` list. Consists of these data members:
- ``title``: name of the track - ``title``: name of the track
- ``track_id``: MusicBrainz ID; UUID fragment only - ``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 Only ``title`` and ``track_id`` are required. The rest of the fields
may be None. The indices ``index``, ``medium``, and ``medium_index`` may be None. The indices ``index``, ``medium``, and ``medium_index``
are all 1-based. are all 1-based.
""" """
def __init__(self, title, track_id, artist=None, artist_id=None,
length=None, index=None, medium=None, medium_index=None, def __init__(self, title=None, track_id=None, release_track_id=None,
medium_total=None, artist_sort=None, disctitle=None, artist=None, artist_id=None, length=None, index=None,
artist_credit=None, data_source=None, data_url=None, medium=None, medium_index=None, medium_total=None,
media=None, lyricist=None, composer=None, arranger=None, artist_sort=None, disctitle=None, artist_credit=None,
track_alt=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):
self.title = title self.title = title
self.track_id = track_id self.track_id = track_id
self.release_track_id = release_track_id
self.artist = artist self.artist = artist
self.artist_id = artist_id self.artist_id = artist_id
self.length = length self.length = length
@@ -174,8 +181,16 @@ class TrackInfo(object):
self.data_url = data_url self.data_url = data_url
self.lyricist = lyricist self.lyricist = lyricist
self.composer = composer self.composer = composer
self.composer_sort = composer_sort
self.arranger = arranger self.arranger = arranger
self.track_alt = track_alt 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. # As above, work around a bug in python-musicbrainz-ngs.
def decode(self, codec='utf-8'): def decode(self, codec='utf-8'):
@@ -188,6 +203,11 @@ class TrackInfo(object):
if isinstance(value, bytes): if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore')) setattr(self, fld, value.decode(codec, 'ignore'))
def copy(self):
dupe = TrackInfo()
dupe.update(self)
return dupe
# Candidate distance scoring. # Candidate distance scoring.
@@ -215,8 +235,8 @@ def _string_dist_basic(str1, str2):
transliteration/lowering to ASCII characters. Normalized by string transliteration/lowering to ASCII characters. Normalized by string
length. length.
""" """
assert isinstance(str1, six.text_type) assert isinstance(str1, str)
assert isinstance(str2, six.text_type) assert isinstance(str2, str)
str1 = as_string(unidecode(str1)) str1 = as_string(unidecode(str1))
str2 = as_string(unidecode(str2)) str2 = as_string(unidecode(str2))
str1 = re.sub(r'[^a-z0-9]', '', str1.lower()) str1 = re.sub(r'[^a-z0-9]', '', str1.lower())
@@ -244,9 +264,9 @@ def string_dist(str1, str2):
# "something, the". # "something, the".
for word in SD_END_WORDS: for word in SD_END_WORDS:
if str1.endswith(', %s' % word): if str1.endswith(', %s' % word):
str1 = '%s %s' % (word, str1[:-len(word) - 2]) str1 = '{} {}'.format(word, str1[:-len(word) - 2])
if str2.endswith(', %s' % word): if str2.endswith(', %s' % word):
str2 = '%s %s' % (word, str2[:-len(word) - 2]) str2 = '{} {}'.format(word, str2[:-len(word) - 2])
# Perform a couple of basic normalizing substitutions. # Perform a couple of basic normalizing substitutions.
for pat, repl in SD_REPLACE: for pat, repl in SD_REPLACE:
@@ -284,11 +304,12 @@ def string_dist(str1, str2):
return base_dist + penalty return base_dist + penalty
class LazyClassProperty(object): class LazyClassProperty:
"""A decorator implementing a read-only property that is *lazy* in """A decorator implementing a read-only property that is *lazy* in
the sense that the getter is only invoked once. Subsequent accesses the sense that the getter is only invoked once. Subsequent accesses
through *any* instance use the cached result. through *any* instance use the cached result.
""" """
def __init__(self, getter): def __init__(self, getter):
self.getter = getter self.getter = getter
self.computed = False self.computed = False
@@ -301,17 +322,17 @@ class LazyClassProperty(object):
@total_ordering @total_ordering
@six.python_2_unicode_compatible class Distance:
class Distance(object):
"""Keeps track of multiple distance penalties. Provides a single """Keeps track of multiple distance penalties. Provides a single
weighted distance for all penalties as well as a weighted distance weighted distance for all penalties as well as a weighted distance
for each individual penalty. for each individual penalty.
""" """
def __init__(self): def __init__(self):
self._penalties = {} self._penalties = {}
@LazyClassProperty @LazyClassProperty
def _weights(cls): # noqa def _weights(cls): # noqa: N805
"""A dictionary from keys to floating-point weights. """A dictionary from keys to floating-point weights.
""" """
weights_view = config['match']['distance_weights'] weights_view = config['match']['distance_weights']
@@ -389,7 +410,7 @@ class Distance(object):
return other - self.distance return other - self.distance
def __str__(self): def __str__(self):
return "{0:.2f}".format(self.distance) return f"{self.distance:.2f}"
# Behave like a dict. # Behave like a dict.
@@ -416,7 +437,7 @@ class Distance(object):
""" """
if not isinstance(dist, Distance): if not isinstance(dist, Distance):
raise ValueError( raise ValueError(
u'`dist` must be a Distance object, not {0}'.format(type(dist)) '`dist` must be a Distance object, not {}'.format(type(dist))
) )
for key, penalties in dist._penalties.items(): for key, penalties in dist._penalties.items():
self._penalties.setdefault(key, []).extend(penalties) self._penalties.setdefault(key, []).extend(penalties)
@@ -428,7 +449,7 @@ class Distance(object):
be a compiled regular expression, in which case it will be be a compiled regular expression, in which case it will be
matched against `value2`. matched against `value2`.
""" """
if isinstance(value1, re._pattern_type): if isinstance(value1, Pattern):
return bool(value1.match(value2)) return bool(value1.match(value2))
return value1 == value2 return value1 == value2
@@ -440,7 +461,7 @@ class Distance(object):
""" """
if not 0.0 <= dist <= 1.0: if not 0.0 <= dist <= 1.0:
raise ValueError( raise ValueError(
u'`dist` must be between 0.0 and 1.0, not {0}'.format(dist) f'`dist` must be between 0.0 and 1.0, not {dist}'
) )
self._penalties.setdefault(key, []).append(dist) self._penalties.setdefault(key, []).append(dist)
@@ -534,7 +555,10 @@ def album_for_mbid(release_id):
if the ID is not found. if the ID is not found.
""" """
try: try:
return mb.album_for_id(release_id) album = mb.album_for_id(release_id)
if album:
plugins.send('albuminfo_received', info=album)
return album
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
@@ -544,12 +568,14 @@ def track_for_mbid(recording_id):
if the ID is not found. if the ID is not found.
""" """
try: try:
return mb.track_for_id(recording_id) track = mb.track_for_id(recording_id)
if track:
plugins.send('trackinfo_received', info=track)
return track
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
@plugins.notify_info_yielded(u'albuminfo_received')
def albums_for_id(album_id): def albums_for_id(album_id):
"""Get a list of albums for an ID.""" """Get a list of albums for an ID."""
a = album_for_mbid(album_id) a = album_for_mbid(album_id)
@@ -557,10 +583,10 @@ def albums_for_id(album_id):
yield a yield a
for a in plugins.album_for_id(album_id): for a in plugins.album_for_id(album_id):
if a: if a:
plugins.send('albuminfo_received', info=a)
yield a yield a
@plugins.notify_info_yielded(u'trackinfo_received')
def tracks_for_id(track_id): def tracks_for_id(track_id):
"""Get a list of tracks for an ID.""" """Get a list of tracks for an ID."""
t = track_for_mbid(track_id) t = track_for_mbid(track_id)
@@ -568,39 +594,43 @@ def tracks_for_id(track_id):
yield t yield t
for t in plugins.track_for_id(track_id): for t in plugins.track_for_id(track_id):
if t: if t:
plugins.send('trackinfo_received', info=t)
yield t yield t
@plugins.notify_info_yielded(u'albuminfo_received') @plugins.notify_info_yielded('albuminfo_received')
def album_candidates(items, artist, album, va_likely): def album_candidates(items, artist, album, va_likely, extra_tags):
"""Search for album matches. ``items`` is a list of Item objects """Search for album matches. ``items`` is a list of Item objects
that make up the album. ``artist`` and ``album`` are the respective that make up the album. ``artist`` and ``album`` are the respective
names (strings), which may be derived from the item list or may be names (strings), which may be derived from the item list or may be
entered by the user. ``va_likely`` is a boolean indicating whether entered by the user. ``va_likely`` is a boolean indicating whether
the album is likely to be a "various artists" release. 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.
""" """
# Base candidates if we have album and artist to match. # Base candidates if we have album and artist to match.
if artist and album: if artist and album:
try: try:
for candidate in mb.match_album(artist, album, len(items)): yield from mb.match_album(artist, album, len(items),
yield candidate extra_tags)
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
# Also add VA matches from MusicBrainz where appropriate. # Also add VA matches from MusicBrainz where appropriate.
if va_likely and album: if va_likely and album:
try: try:
for candidate in mb.match_album(None, album, len(items)): yield from mb.match_album(None, album, len(items),
yield candidate extra_tags)
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
# Candidates from plugins. # Candidates from plugins.
for candidate in plugins.candidates(items, artist, album, va_likely): yield from plugins.candidates(items, artist, album, va_likely,
yield candidate extra_tags)
@plugins.notify_info_yielded(u'trackinfo_received') @plugins.notify_info_yielded('trackinfo_received')
def item_candidates(item, artist, title): def item_candidates(item, artist, title):
"""Search for item matches. ``item`` is the Item to be matched. """Search for item matches. ``item`` is the Item to be matched.
``artist`` and ``title`` are strings and either reflect the item or ``artist`` and ``title`` are strings and either reflect the item or
@@ -610,11 +640,9 @@ def item_candidates(item, artist, title):
# MusicBrainz candidates. # MusicBrainz candidates.
if artist and title: if artist and title:
try: try:
for candidate in mb.match_track(artist, title): yield from mb.match_track(artist, title)
yield candidate
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
# Plugin candidates. # Plugin candidates.
for candidate in plugins.item_candidates(item, artist, title): yield from plugins.item_candidates(item, artist, title)
yield candidate
Executable → Regular
+30 -25
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -17,7 +16,6 @@
releases and tracks. releases and tracks.
""" """
from __future__ import division, absolute_import, print_function
import datetime import datetime
import re import re
@@ -35,7 +33,7 @@ from beets.util.enumeration import OrderedEnum
# album level to determine whether a given release is likely a VA # 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 # release and also on the track level to to remove the penalty for
# differing artists. # differing artists.
VA_ARTISTS = (u'', u'various artists', u'various', u'va', u'unknown') VA_ARTISTS = ('', 'various artists', 'various', 'va', 'unknown')
# Global logger. # Global logger.
log = logging.getLogger('beets') log = logging.getLogger('beets')
@@ -108,7 +106,7 @@ def assign_items(items, tracks):
log.debug('...done.') log.debug('...done.')
# Produce the output matching. # Produce the output matching.
mapping = dict((items[i], tracks[j]) for (i, j) in matching) mapping = {items[i]: tracks[j] for (i, j) in matching}
extra_items = list(set(items) - set(mapping.keys())) extra_items = list(set(items) - set(mapping.keys()))
extra_items.sort(key=lambda i: (i.disc, i.track, i.title)) extra_items.sort(key=lambda i: (i.disc, i.track, i.title))
extra_tracks = list(set(tracks) - set(mapping.values())) extra_tracks = list(set(tracks) - set(mapping.values()))
@@ -276,16 +274,16 @@ def match_by_id(items):
try: try:
first = next(albumids) first = next(albumids)
except StopIteration: except StopIteration:
log.debug(u'No album ID found.') log.debug('No album ID found.')
return None return None
# Is there a consensus on the MB album ID? # Is there a consensus on the MB album ID?
for other in albumids: for other in albumids:
if other != first: if other != first:
log.debug(u'No album ID consensus.') log.debug('No album ID consensus.')
return None return None
# If all album IDs are equal, look up the album. # If all album IDs are equal, look up the album.
log.debug(u'Searching for discovered album ID: {0}', first) log.debug('Searching for discovered album ID: {0}', first)
return hooks.album_for_mbid(first) return hooks.album_for_mbid(first)
@@ -351,23 +349,23 @@ def _add_candidate(items, results, info):
checking the track count, ordering the items, checking for checking the track count, ordering the items, checking for
duplicates, and calculating the distance. duplicates, and calculating the distance.
""" """
log.debug(u'Candidate: {0} - {1} ({2})', log.debug('Candidate: {0} - {1} ({2})',
info.artist, info.album, info.album_id) info.artist, info.album, info.album_id)
# Discard albums with zero tracks. # Discard albums with zero tracks.
if not info.tracks: if not info.tracks:
log.debug(u'No tracks.') log.debug('No tracks.')
return return
# Don't duplicate. # Don't duplicate.
if info.album_id in results: if info.album_id in results:
log.debug(u'Duplicate.') log.debug('Duplicate.')
return return
# Discard matches without required tags. # Discard matches without required tags.
for req_tag in config['match']['required'].as_str_seq(): for req_tag in config['match']['required'].as_str_seq():
if getattr(info, req_tag) is None: if getattr(info, req_tag) is None:
log.debug(u'Ignored. Missing required tag: {0}', req_tag) log.debug('Ignored. Missing required tag: {0}', req_tag)
return return
# Find mapping between the items and the track info. # Find mapping between the items and the track info.
@@ -380,10 +378,10 @@ def _add_candidate(items, results, info):
penalties = [key for key, _ in dist] penalties = [key for key, _ in dist]
for penalty in config['match']['ignored'].as_str_seq(): for penalty in config['match']['ignored'].as_str_seq():
if penalty in penalties: if penalty in penalties:
log.debug(u'Ignored. Penalty: {0}', penalty) log.debug('Ignored. Penalty: {0}', penalty)
return return
log.debug(u'Success. Distance: {0}', dist) log.debug('Success. Distance: {0}', dist)
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping, results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
extra_items, extra_tracks) extra_items, extra_tracks)
@@ -411,7 +409,7 @@ def tag_album(items, search_artist=None, search_album=None,
likelies, consensus = current_metadata(items) likelies, consensus = current_metadata(items)
cur_artist = likelies['artist'] cur_artist = likelies['artist']
cur_album = likelies['album'] cur_album = likelies['album']
log.debug(u'Tagging {0} - {1}', cur_artist, cur_album) log.debug('Tagging {0} - {1}', cur_artist, cur_album)
# The output result (distance, AlbumInfo) tuples (keyed by MB album # The output result (distance, AlbumInfo) tuples (keyed by MB album
# ID). # ID).
@@ -420,7 +418,7 @@ def tag_album(items, search_artist=None, search_album=None,
# Search by explicit ID. # Search by explicit ID.
if search_ids: if search_ids:
for search_id in search_ids: for search_id in search_ids:
log.debug(u'Searching for album ID: {0}', search_id) log.debug('Searching for album ID: {0}', search_id)
for id_candidate in hooks.albums_for_id(search_id): for id_candidate in hooks.albums_for_id(search_id):
_add_candidate(items, candidates, id_candidate) _add_candidate(items, candidates, id_candidate)
@@ -431,13 +429,13 @@ def tag_album(items, search_artist=None, search_album=None,
if id_info: if id_info:
_add_candidate(items, candidates, id_info) _add_candidate(items, candidates, id_info)
rec = _recommendation(list(candidates.values())) rec = _recommendation(list(candidates.values()))
log.debug(u'Album ID match recommendation is {0}', rec) log.debug('Album ID match recommendation is {0}', rec)
if candidates and not config['import']['timid']: if candidates and not config['import']['timid']:
# If we have a very good MBID match, return immediately. # If we have a very good MBID match, return immediately.
# Otherwise, this match will compete against metadata-based # Otherwise, this match will compete against metadata-based
# matches. # matches.
if rec == Recommendation.strong: if rec == Recommendation.strong:
log.debug(u'ID match.') log.debug('ID match.')
return cur_artist, cur_album, \ return cur_artist, cur_album, \
Proposal(list(candidates.values()), rec) Proposal(list(candidates.values()), rec)
@@ -445,22 +443,29 @@ def tag_album(items, search_artist=None, search_album=None,
if not (search_artist and search_album): if not (search_artist and search_album):
# No explicit search terms -- use current metadata. # No explicit search terms -- use current metadata.
search_artist, search_album = cur_artist, cur_album search_artist, search_album = cur_artist, cur_album
log.debug(u'Search terms: {0} - {1}', search_artist, search_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)
# Is this album likely to be a "various artist" release? # Is this album likely to be a "various artist" release?
va_likely = ((not consensus['artist']) or va_likely = ((not consensus['artist']) or
(search_artist.lower() in VA_ARTISTS) or (search_artist.lower() in VA_ARTISTS) or
any(item.comp for item in items)) any(item.comp for item in items))
log.debug(u'Album might be VA: {0}', va_likely) log.debug('Album might be VA: {0}', va_likely)
# Get the results from the data sources. # Get the results from the data sources.
for matched_candidate in hooks.album_candidates(items, for matched_candidate in hooks.album_candidates(items,
search_artist, search_artist,
search_album, search_album,
va_likely): va_likely,
extra_tags):
_add_candidate(items, candidates, matched_candidate) _add_candidate(items, candidates, matched_candidate)
log.debug(u'Evaluating {0} candidates.', len(candidates)) log.debug('Evaluating {0} candidates.', len(candidates))
# Sort and get the recommendation. # Sort and get the recommendation.
candidates = _sort_candidates(candidates.values()) candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates) rec = _recommendation(candidates)
@@ -485,7 +490,7 @@ def tag_item(item, search_artist=None, search_title=None,
trackids = search_ids or [t for t in [item.mb_trackid] if t] trackids = search_ids or [t for t in [item.mb_trackid] if t]
if trackids: if trackids:
for trackid in trackids: for trackid in trackids:
log.debug(u'Searching for track ID: {0}', trackid) log.debug('Searching for track ID: {0}', trackid)
for track_info in hooks.tracks_for_id(trackid): for track_info in hooks.tracks_for_id(trackid):
dist = track_distance(item, track_info, incl_artist=True) dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = \ candidates[track_info.track_id] = \
@@ -494,7 +499,7 @@ def tag_item(item, search_artist=None, search_title=None,
rec = _recommendation(_sort_candidates(candidates.values())) rec = _recommendation(_sort_candidates(candidates.values()))
if rec == Recommendation.strong and \ if rec == Recommendation.strong and \
not config['import']['timid']: not config['import']['timid']:
log.debug(u'Track ID match.') log.debug('Track ID match.')
return Proposal(_sort_candidates(candidates.values()), rec) return Proposal(_sort_candidates(candidates.values()), rec)
# If we're searching by ID, don't proceed. # If we're searching by ID, don't proceed.
@@ -507,7 +512,7 @@ def tag_item(item, search_artist=None, search_title=None,
# Search terms. # Search terms.
if not (search_artist and search_title): if not (search_artist and search_title):
search_artist, search_title = item.artist, item.title search_artist, search_title = item.artist, item.title
log.debug(u'Item search terms: {0} - {1}', search_artist, search_title) log.debug('Item search terms: {0} - {1}', search_artist, search_title)
# Get and evaluate candidate metadata. # Get and evaluate candidate metadata.
for track_info in hooks.item_candidates(item, search_artist, search_title): for track_info in hooks.item_candidates(item, search_artist, search_title):
@@ -515,7 +520,7 @@ def tag_item(item, search_artist=None, search_title=None,
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
# Sort by distance and return with recommendation. # Sort by distance and return with recommendation.
log.debug(u'Found {0} candidates.', len(candidates)) log.debug('Found {0} candidates.', len(candidates))
candidates = _sort_candidates(candidates.values()) candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates) rec = _recommendation(candidates)
return Proposal(candidates, rec) return Proposal(candidates, rec)
Executable → Regular
+183 -50
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,55 +14,72 @@
"""Searches for albums in the MusicBrainz database. """Searches for albums in the MusicBrainz database.
""" """
from __future__ import division, absolute_import, print_function
import musicbrainzngs import musicbrainzngs
import re import re
import traceback import traceback
from six.moves.urllib.parse import urljoin
from beets import logging from beets import logging
from beets import plugins
import beets.autotag.hooks import beets.autotag.hooks
import beets import beets
from beets import util from beets import util
from beets import config from beets import config
import six from collections import Counter
from urllib.parse import urljoin
VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377' VARIOUS_ARTISTS_ID = '89ad4ac3-39f7-470e-963a-56509c546377'
if util.SNI_SUPPORTED: BASE_URL = 'https://musicbrainz.org/'
BASE_URL = 'https://musicbrainz.org/'
else: SKIPPED_TRACKS = ['[data track]']
BASE_URL = 'http://musicbrainz.org/'
FIELDS_TO_MB_KEYS = {
'catalognum': 'catno',
'country': 'country',
'label': 'label',
'media': 'format',
'year': 'date',
}
musicbrainzngs.set_useragent('beets', beets.__version__, musicbrainzngs.set_useragent('beets', beets.__version__,
'http://beets.io/') 'https://beets.io/')
class MusicBrainzAPIError(util.HumanReadableException): class MusicBrainzAPIError(util.HumanReadableException):
"""An error while talking to MusicBrainz. The `query` field is the """An error while talking to MusicBrainz. The `query` field is the
parameter to the action and may have any type. parameter to the action and may have any type.
""" """
def __init__(self, reason, verb, query, tb=None): def __init__(self, reason, verb, query, tb=None):
self.query = query self.query = query
if isinstance(reason, musicbrainzngs.WebServiceError): if isinstance(reason, musicbrainzngs.WebServiceError):
reason = u'MusicBrainz not reachable' reason = 'MusicBrainz not reachable'
super(MusicBrainzAPIError, self).__init__(reason, verb, tb) super().__init__(reason, verb, tb)
def get_message(self): def get_message(self):
return u'{0} in {1} with query {2}'.format( return '{} in {} with query {}'.format(
self._reasonstr(), self.verb, repr(self.query) self._reasonstr(), self.verb, repr(self.query)
) )
log = logging.getLogger('beets') log = logging.getLogger('beets')
RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups', RELEASE_INCLUDES = ['artists', 'media', 'recordings', 'release-groups',
'labels', 'artist-credits', 'aliases', 'labels', 'artist-credits', 'aliases',
'recording-level-rels', 'work-rels', 'recording-level-rels', 'work-rels',
'work-level-rels', 'artist-rels'] 'work-level-rels', 'artist-rels', 'isrcs']
TRACK_INCLUDES = ['artists', 'aliases'] 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']
if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']: if 'work-level-rels' in musicbrainzngs.VALID_INCLUDES['recording']:
TRACK_INCLUDES += ['work-level-rels', 'artist-rels'] TRACK_INCLUDES += ['work-level-rels', 'artist-rels']
if 'genres' in musicbrainzngs.VALID_INCLUDES['recording']:
RELEASE_INCLUDES += ['genres']
def track_url(trackid): def track_url(trackid):
@@ -79,7 +95,11 @@ def configure():
from the beets configuration. This should be called at startup. from the beets configuration. This should be called at startup.
""" """
hostname = config['musicbrainz']['host'].as_str() hostname = config['musicbrainz']['host'].as_str()
musicbrainzngs.set_hostname(hostname) 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_rate_limit( musicbrainzngs.set_rate_limit(
config['musicbrainz']['ratelimit_interval'].as_number(), config['musicbrainz']['ratelimit_interval'].as_number(),
config['musicbrainz']['ratelimit'].get(int), config['musicbrainz']['ratelimit'].get(int),
@@ -109,6 +129,24 @@ def _preferred_alias(aliases):
return matches[0] 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): def _flatten_artist_credit(credit):
"""Given a list representing an ``artist-credit`` block, flatten the """Given a list representing an ``artist-credit`` block, flatten the
data into a triple of joined artist name strings: canonical, sort, and data into a triple of joined artist name strings: canonical, sort, and
@@ -118,7 +156,7 @@ def _flatten_artist_credit(credit):
artist_sort_parts = [] artist_sort_parts = []
artist_credit_parts = [] artist_credit_parts = []
for el in credit: for el in credit:
if isinstance(el, six.string_types): if isinstance(el, str):
# Join phrase. # Join phrase.
artist_parts.append(el) artist_parts.append(el)
artist_credit_parts.append(el) artist_credit_parts.append(el)
@@ -165,13 +203,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. the number of tracks on the medium. Each number is a 1-based index.
""" """
info = beets.autotag.hooks.TrackInfo( info = beets.autotag.hooks.TrackInfo(
recording['title'], title=recording['title'],
recording['id'], track_id=recording['id'],
index=index, index=index,
medium=medium, medium=medium,
medium_index=medium_index, medium_index=medium_index,
medium_total=medium_total, medium_total=medium_total,
data_source=u'MusicBrainz', data_source='MusicBrainz',
data_url=track_url(recording['id']), data_url=track_url(recording['id']),
) )
@@ -187,11 +225,22 @@ def track_info(recording, index=None, medium=None, medium_index=None,
if recording.get('length'): if recording.get('length'):
info.length = int(recording['length']) / (1000.0) info.length = int(recording['length']) / (1000.0)
info.trackdisambig = recording.get('disambiguation')
if recording.get('isrc-list'):
info.isrc = ';'.join(recording['isrc-list'])
lyricist = [] lyricist = []
composer = [] composer = []
composer_sort = []
for work_relation in recording.get('work-relation-list', ()): for work_relation in recording.get('work-relation-list', ()):
if work_relation['type'] != 'performance': if work_relation['type'] != 'performance':
continue 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( for artist_relation in work_relation['work'].get(
'artist-relation-list', ()): 'artist-relation-list', ()):
if 'type' in artist_relation: if 'type' in artist_relation:
@@ -200,10 +249,13 @@ def track_info(recording, index=None, medium=None, medium_index=None,
lyricist.append(artist_relation['artist']['name']) lyricist.append(artist_relation['artist']['name'])
elif type == 'composer': elif type == 'composer':
composer.append(artist_relation['artist']['name']) composer.append(artist_relation['artist']['name'])
composer_sort.append(
artist_relation['artist']['sort-name'])
if lyricist: if lyricist:
info.lyricist = u', '.join(lyricist) info.lyricist = ', '.join(lyricist)
if composer: if composer:
info.composer = u', '.join(composer) info.composer = ', '.join(composer)
info.composer_sort = ', '.join(composer_sort)
arranger = [] arranger = []
for artist_relation in recording.get('artist-relation-list', ()): for artist_relation in recording.get('artist-relation-list', ()):
@@ -212,7 +264,12 @@ def track_info(recording, index=None, medium=None, medium_index=None,
if type == 'arranger': if type == 'arranger':
arranger.append(artist_relation['artist']['name']) arranger.append(artist_relation['artist']['name'])
if arranger: if arranger:
info.arranger = u', '.join(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.decode() info.decode()
return info return info
@@ -246,6 +303,26 @@ def album_info(release):
artist_name, artist_sort_name, artist_credit_name = \ artist_name, artist_sort_name, artist_credit_name = \
_flatten_artist_credit(release['artist-credit']) _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. # Basic info.
track_infos = [] track_infos = []
index = 0 index = 0
@@ -253,11 +330,29 @@ def album_info(release):
disctitle = medium.get('title') disctitle = medium.get('title')
format = medium.get('format') format = medium.get('format')
if format in config['match']['ignored_media'].as_str_seq():
continue
all_tracks = medium['track-list'] 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: if 'pregap' in medium:
all_tracks.insert(0, medium['pregap']) all_tracks.insert(0, medium['pregap'])
for track in all_tracks: 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. # Basic information from the recording.
index += 1 index += 1
ti = track_info( ti = track_info(
@@ -265,8 +360,9 @@ def album_info(release):
index, index,
int(medium['position']), int(medium['position']),
int(track['position']), int(track['position']),
len(medium['track-list']), track_count,
) )
ti.release_track_id = track['id']
ti.disctitle = disctitle ti.disctitle = disctitle
ti.media = format ti.media = format
ti.track_alt = track['number'] ti.track_alt = track['number']
@@ -285,15 +381,15 @@ def album_info(release):
track_infos.append(ti) track_infos.append(ti)
info = beets.autotag.hooks.AlbumInfo( info = beets.autotag.hooks.AlbumInfo(
release['title'], album=release['title'],
release['id'], album_id=release['id'],
artist_name, artist=artist_name,
release['artist-credit'][0]['artist']['id'], artist_id=release['artist-credit'][0]['artist']['id'],
track_infos, tracks=track_infos,
mediums=len(release['medium-list']), mediums=len(release['medium-list']),
artist_sort=artist_sort_name, artist_sort=artist_sort_name,
artist_credit=artist_credit_name, artist_credit=artist_credit_name,
data_source=u'MusicBrainz', data_source='MusicBrainz',
data_url=album_url(release['id']), data_url=album_url(release['id']),
) )
info.va = info.artist_id == VARIOUS_ARTISTS_ID info.va = info.artist_id == VARIOUS_ARTISTS_ID
@@ -301,25 +397,36 @@ def album_info(release):
info.artist = config['va_name'].as_str() info.artist = config['va_name'].as_str()
info.asin = release.get('asin') info.asin = release.get('asin')
info.releasegroup_id = release['release-group']['id'] info.releasegroup_id = release['release-group']['id']
info.country = release.get('country')
info.albumstatus = release.get('status') info.albumstatus = release.get('status')
# Build up the disambiguation string from the release group and release. # Get the disambiguation strings at the release and release group level.
disambig = []
if release['release-group'].get('disambiguation'): if release['release-group'].get('disambiguation'):
disambig.append(release['release-group'].get('disambiguation')) info.releasegroupdisambig = \
release['release-group'].get('disambiguation')
if release.get('disambiguation'): if release.get('disambiguation'):
disambig.append(release.get('disambiguation')) info.albumdisambig = release.get('disambiguation')
info.albumdisambig = u', '.join(disambig)
# Release type not always populated. # Get the "classic" Release type. This data comes from a legacy API
# feature before MusicBrainz supported multiple release types.
if 'type' in release['release-group']: if 'type' in release['release-group']:
reltype = release['release-group']['type'] reltype = release['release-group']['type']
if reltype: if reltype:
info.albumtype = reltype.lower() info.albumtype = reltype.lower()
# Release dates. # Set the new-style "primary" and "secondary" release types.
release_date = release.get('date') 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_group_date = release['release-group'].get('first-release-date') release_group_date = release['release-group'].get('first-release-date')
if not release_date: if not release_date:
# Fall back if release-specific date is not available. # Fall back if release-specific date is not available.
@@ -347,17 +454,33 @@ def album_info(release):
first_medium = release['medium-list'][0] first_medium = release['medium-list'][0]
info.media = first_medium.get('format') 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() info.decode()
return info return info
def match_album(artist, album, tracks=None): def match_album(artist, album, tracks=None, extra_tags=None):
"""Searches for a single album ("release" in MusicBrainz parlance) """Searches for a single album ("release" in MusicBrainz parlance)
and returns an iterator over AlbumInfo objects. May raise a and returns an iterator over AlbumInfo objects. May raise a
MusicBrainzAPIError. MusicBrainzAPIError.
The query consists of an artist name, an album name, and, The query consists of an artist name, an album name, and,
optionally, a number of tracks on the album. optionally, a number of tracks on the album and any other extra tags.
""" """
# Build search criteria. # Build search criteria.
criteria = {'release': album.lower().strip()} criteria = {'release': album.lower().strip()}
@@ -367,14 +490,24 @@ def match_album(artist, album, tracks=None):
# Various Artists search. # Various Artists search.
criteria['arid'] = VARIOUS_ARTISTS_ID criteria['arid'] = VARIOUS_ARTISTS_ID
if tracks is not None: if tracks is not None:
criteria['tracks'] = six.text_type(tracks) 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
# Abort if we have no search terms. # Abort if we have no search terms.
if not any(criteria.values()): if not any(criteria.values()):
return return
try: try:
log.debug(u'Searching for MusicBrainz releases with: {!r}', criteria) log.debug('Searching for MusicBrainz releases with: {!r}', criteria)
res = musicbrainzngs.search_releases( res = musicbrainzngs.search_releases(
limit=config['musicbrainz']['searchlimit'].get(int), **criteria) limit=config['musicbrainz']['searchlimit'].get(int), **criteria)
except musicbrainzngs.MusicBrainzError as exc: except musicbrainzngs.MusicBrainzError as exc:
@@ -415,7 +548,7 @@ def _parse_id(s):
no ID can be found, return None. no ID can be found, return None.
""" """
# Find the first thing that looks like a UUID/MBID. # Find the first thing that looks like a UUID/MBID.
match = re.search(u'[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', s) match = re.search('[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}', s)
if match: if match:
return match.group() return match.group()
@@ -425,19 +558,19 @@ def album_for_id(releaseid):
object or None if the album is not found. May raise a object or None if the album is not found. May raise a
MusicBrainzAPIError. MusicBrainzAPIError.
""" """
log.debug(u'Requesting MusicBrainz release {}', releaseid) log.debug('Requesting MusicBrainz release {}', releaseid)
albumid = _parse_id(releaseid) albumid = _parse_id(releaseid)
if not albumid: if not albumid:
log.debug(u'Invalid MBID ({0}).', releaseid) log.debug('Invalid MBID ({0}).', releaseid)
return return
try: try:
res = musicbrainzngs.get_release_by_id(albumid, res = musicbrainzngs.get_release_by_id(albumid,
RELEASE_INCLUDES) RELEASE_INCLUDES)
except musicbrainzngs.ResponseError: except musicbrainzngs.ResponseError:
log.debug(u'Album ID match failed.') log.debug('Album ID match failed.')
return None return None
except musicbrainzngs.MusicBrainzError as exc: except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, u'get release by ID', albumid, raise MusicBrainzAPIError(exc, 'get release by ID', albumid,
traceback.format_exc()) traceback.format_exc())
return album_info(res['release']) return album_info(res['release'])
@@ -448,14 +581,14 @@ def track_for_id(releaseid):
""" """
trackid = _parse_id(releaseid) trackid = _parse_id(releaseid)
if not trackid: if not trackid:
log.debug(u'Invalid MBID ({0}).', releaseid) log.debug('Invalid MBID ({0}).', releaseid)
return return
try: try:
res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES) res = musicbrainzngs.get_recording_by_id(trackid, TRACK_INCLUDES)
except musicbrainzngs.ResponseError: except musicbrainzngs.ResponseError:
log.debug(u'Track ID match failed.') log.debug('Track ID match failed.')
return None return None
except musicbrainzngs.MusicBrainzError as exc: except musicbrainzngs.MusicBrainzError as exc:
raise MusicBrainzAPIError(exc, u'get recording by ID', trackid, raise MusicBrainzAPIError(exc, 'get recording by ID', trackid,
traceback.format_exc()) traceback.format_exc())
return track_info(res['recording']) return track_info(res['recording'])
Executable → Regular
+23
View File
@@ -7,9 +7,12 @@ import:
move: no move: no
link: no link: no
hardlink: no hardlink: no
reflink: no
delete: no delete: no
resume: ask resume: ask
incremental: no incremental: no
incremental_skip_later: no
from_scratch: no
quiet_fallback: skip quiet_fallback: skip
none_rec_action: ask none_rec_action: ask
timid: no timid: no
@@ -25,6 +28,8 @@ import:
pretend: false pretend: false
search_ids: [] search_ids: []
duplicate_action: ask duplicate_action: ask
bell: no
set_fields: {}
clutter: ["Thumbs.DB", ".DS_Store"] clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information", "lost+found"] ignore: [".*", "*~", "System Volume Information", "lost+found"]
@@ -38,11 +43,22 @@ replace:
'\.$': _ '\.$': _
'\s+$': '' '\s+$': ''
'^\s+': '' '^\s+': ''
'^-': _
path_sep_replace: _ path_sep_replace: _
drive_sep_replace: _
asciify_paths: false asciify_paths: false
art_filename: cover art_filename: cover
max_filename_length: 0 max_filename_length: 0
aunique:
keys: albumartist album
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
bracket: '[]'
overwrite_null:
album: []
track: []
plugins: [] plugins: []
pluginpath: [] pluginpath: []
threaded: yes threaded: yes
@@ -51,6 +67,7 @@ per_disc_numbering: no
verbose: 0 verbose: 0
terminal_encoding: terminal_encoding:
original_date: no original_date: no
artist_credit: no
id3v23: no id3v23: no
va_name: "Various Artists" va_name: "Various Artists"
@@ -85,9 +102,12 @@ statefile: state.pickle
musicbrainz: musicbrainz:
host: musicbrainz.org host: musicbrainz.org
https: no
ratelimit: 1 ratelimit: 1
ratelimit_interval: 1.0 ratelimit_interval: 1.0
searchlimit: 5 searchlimit: 5
extra_tags: []
genres: no
match: match:
strong_rec_thresh: 0.04 strong_rec_thresh: 0.04
@@ -122,5 +142,8 @@ match:
original_year: no original_year: no
ignored: [] ignored: []
required: [] required: []
ignored_media: []
ignore_data_tracks: yes
ignore_video_tracks: yes
track_length_grace: 10 track_length_grace: 10
track_length_max: 30 track_length_max: 30
Executable → Regular
-2
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -16,7 +15,6 @@
"""DBCore is an abstract database package that forms the basis for beets' """DBCore is an abstract database package that forms the basis for beets'
Library. Library.
""" """
from __future__ import division, absolute_import, print_function
from .db import Model, Database from .db import Model, Database
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
+331 -92
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,38 +14,56 @@
"""The central Model and Database constructs for DBCore. """The central Model and Database constructs for DBCore.
""" """
from __future__ import division, absolute_import, print_function
import time import time
import os import os
import re
from collections import defaultdict from collections import defaultdict
import threading import threading
import sqlite3 import sqlite3
import contextlib import contextlib
import collections
import beets import beets
from beets.util.functemplate import Template from beets.util import functemplate
from beets.util import py3_path from beets.util import py3_path
from beets.dbcore import types from beets.dbcore import types
from .query import MatchQuery, NullSort, TrueQuery from .query import MatchQuery, NullSort, TrueQuery
import six from collections.abc import Mapping
class FormattedMapping(collections.Mapping): 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):
"""A `dict`-like formatted view of a model. """A `dict`-like formatted view of a model.
The accessor `mapping[key]` returns the formatted version of The accessor `mapping[key]` returns the formatted version of
`model[key]` as a unicode string. `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 If `for_path` is true, all path separators in the formatted values
are replaced. are replaced.
""" """
def __init__(self, model, for_path=False): ALL_KEYS = '*'
def __init__(self, model, included_keys=ALL_KEYS, for_path=False):
self.for_path = for_path self.for_path = for_path
self.model = model self.model = model
self.model_keys = model.keys(True) 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
def __getitem__(self, key): def __getitem__(self, key):
if key in self.model_keys: if key in self.model_keys:
@@ -63,7 +80,7 @@ class FormattedMapping(collections.Mapping):
def get(self, key, default=None): def get(self, key, default=None):
if default is None: if default is None:
default = self.model._type(key).format(None) default = self.model._type(key).format(None)
return super(FormattedMapping, self).get(key, default) return super().get(key, default)
def _get_formatted(self, model, key): def _get_formatted(self, model, key):
value = model._type(key).format(model.get(key)) value = model._type(key).format(model.get(key))
@@ -72,6 +89,11 @@ class FormattedMapping(collections.Mapping):
if self.for_path: if self.for_path:
sep_repl = beets.config['path_sep_replace'].as_str() 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): for sep in (os.path.sep, os.path.altsep):
if sep: if sep:
value = value.replace(sep, sep_repl) value = value.replace(sep, sep_repl)
@@ -79,11 +101,105 @@ class FormattedMapping(collections.Mapping):
return value 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. # Abstract base for model classes.
class Model(object): class Model:
"""An abstract object representing an object in the database. Model """An abstract object representing an object in the database. Model
objects act like dictionaries (i.e., the allow subscript access like objects act like dictionaries (i.e., they allow subscript access like
``obj['field']``). The same field set is available via attribute ``obj['field']``). The same field set is available via attribute
access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are access as a shortcut (i.e., ``obj.field``). Three kinds of attributes are
available: available:
@@ -134,12 +250,22 @@ class Model(object):
are subclasses of `Sort`. 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 _always_dirty = False
"""By default, fields only become "dirty" when their value actually """By default, fields only become "dirty" when their value actually
changes. Enabling this flag marks fields as dirty even when the new 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`). 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 @classmethod
def _getters(cls): def _getters(cls):
"""Return a mapping from field names to getter functions. """Return a mapping from field names to getter functions.
@@ -163,8 +289,8 @@ class Model(object):
""" """
self._db = db self._db = db
self._dirty = set() self._dirty = set()
self._values_fixed = {} self._values_fixed = LazyConvertDict(self)
self._values_flex = {} self._values_flex = LazyConvertDict(self)
# Initial contents. # Initial contents.
self.update(values) self.update(values)
@@ -178,23 +304,25 @@ class Model(object):
ordinary construction are bypassed. ordinary construction are bypassed.
""" """
obj = cls(db) obj = cls(db)
for key, value in fixed_values.items():
obj._values_fixed[key] = cls._type(key).from_sql(value) obj._values_fixed.init(fixed_values)
for key, value in flex_values.items(): obj._values_flex.init(flex_values)
obj._values_flex[key] = cls._type(key).from_sql(value)
return obj return obj
def __repr__(self): def __repr__(self):
return '{0}({1})'.format( return '{}({})'.format(
type(self).__name__, type(self).__name__,
', '.join('{0}={1!r}'.format(k, v) for k, v in dict(self).items()), ', '.join(f'{k}={v!r}' for k, v in dict(self).items()),
) )
def clear_dirty(self): def clear_dirty(self):
"""Mark all fields as *clean* (i.e., not needing to be stored to """Mark all fields as *clean* (i.e., not needing to be stored to
the database). the database). Also update the revision.
""" """
self._dirty = set() self._dirty = set()
if self._db:
self._revision = self._db.revision
def _check_db(self, need_id=True): def _check_db(self, need_id=True):
"""Ensure that this object is associated with a database row: it """Ensure that this object is associated with a database row: it
@@ -203,10 +331,25 @@ class Model(object):
""" """
if not self._db: if not self._db:
raise ValueError( raise ValueError(
u'{0} has no database'.format(type(self).__name__) '{} has no database'.format(type(self).__name__)
) )
if need_id and not self.id: if need_id and not self.id:
raise ValueError(u'{0} has no id'.format(type(self).__name__)) 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
# Essential field accessors. # Essential field accessors.
@@ -219,22 +362,36 @@ class Model(object):
""" """
return cls._fields.get(key) or cls._types.get(key) or types.DEFAULT return cls._fields.get(key) or cls._types.get(key) or types.DEFAULT
def __getitem__(self, key): def _get(self, key, default=None, raise_=False):
"""Get the value for a field. Raise a KeyError if the field is """Get the value for a field, or `default`. Alternatively,
not available. raise a KeyError if the field is not available.
""" """
getters = self._getters() getters = self._getters()
if key in getters: # Computed. if key in getters: # Computed.
return getters[key](self) return getters[key](self)
elif key in self._fields: # Fixed. elif key in self._fields: # Fixed.
return self._values_fixed.get(key) if key in self._values_fixed:
return self._values_fixed[key]
else:
return self._type(key).null
elif key in self._values_flex: # Flexible. elif key in self._values_flex: # Flexible.
return self._values_flex[key] return self._values_flex[key]
else: elif raise_:
raise KeyError(key) raise KeyError(key)
else:
return default
def __setitem__(self, key, value): get = _get
"""Assign the value for a field.
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.
""" """
# Choose where to place the value. # Choose where to place the value.
if key in self._fields: if key in self._fields:
@@ -248,21 +405,29 @@ class Model(object):
# Assign value and possibly mark as dirty. # Assign value and possibly mark as dirty.
old_value = source.get(key) old_value = source.get(key)
source[key] = value source[key] = value
if self._always_dirty or old_value != value: changed = old_value != value
if self._always_dirty or changed:
self._dirty.add(key) 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): def __delitem__(self, key):
"""Remove a flexible attribute from the model. """Remove a flexible attribute from the model.
""" """
if key in self._values_flex: # Flexible. if key in self._values_flex: # Flexible.
del self._values_flex[key] del self._values_flex[key]
self._dirty.add(key) # Mark for dropping on store. 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. elif key in self._getters(): # Computed.
raise KeyError(u'computed field {0} cannot be deleted'.format(key)) raise KeyError(f'computed field {key} cannot be deleted')
elif key in self._fields: # Fixed.
raise KeyError(u'fixed field {0} cannot be deleted'.format(key))
else: else:
raise KeyError(u'no such field {0}'.format(key)) raise KeyError(f'no such field {key}')
def keys(self, computed=False): def keys(self, computed=False):
"""Get a list of available field names for this object. The """Get a list of available field names for this object. The
@@ -297,19 +462,10 @@ class Model(object):
for key in self: for key in self:
yield key, self[key] 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): def __contains__(self, key):
"""Determine whether `key` is an attribute on this object. """Determine whether `key` is an attribute on this object.
""" """
return key in self.keys(True) return key in self.keys(computed=True)
def __iter__(self): def __iter__(self):
"""Iterate over the available field names (excluding computed """Iterate over the available field names (excluding computed
@@ -321,22 +477,22 @@ class Model(object):
def __getattr__(self, key): def __getattr__(self, key):
if key.startswith('_'): if key.startswith('_'):
raise AttributeError(u'model has no attribute {0!r}'.format(key)) raise AttributeError(f'model has no attribute {key!r}')
else: else:
try: try:
return self[key] return self[key]
except KeyError: except KeyError:
raise AttributeError(u'no such field {0!r}'.format(key)) raise AttributeError(f'no such field {key!r}')
def __setattr__(self, key, value): def __setattr__(self, key, value):
if key.startswith('_'): if key.startswith('_'):
super(Model, self).__setattr__(key, value) super().__setattr__(key, value)
else: else:
self[key] = value self[key] = value
def __delattr__(self, key): def __delattr__(self, key):
if key.startswith('_'): if key.startswith('_'):
super(Model, self).__delattr__(key) super().__delattr__(key)
else: else:
del self[key] del self[key]
@@ -365,7 +521,7 @@ class Model(object):
with self._db.transaction() as tx: with self._db.transaction() as tx:
# Main table update. # Main table update.
if assignments: if assignments:
query = 'UPDATE {0} SET {1} WHERE id=?'.format( query = 'UPDATE {} SET {} WHERE id=?'.format(
self._table, assignments self._table, assignments
) )
subvars.append(self.id) subvars.append(self.id)
@@ -376,7 +532,7 @@ class Model(object):
if key in self._dirty: if key in self._dirty:
self._dirty.remove(key) self._dirty.remove(key)
tx.mutate( tx.mutate(
'INSERT INTO {0} ' 'INSERT INTO {} '
'(entity_id, key, value) ' '(entity_id, key, value) '
'VALUES (?, ?, ?);'.format(self._flex_table), 'VALUES (?, ?, ?);'.format(self._flex_table),
(self.id, key, value), (self.id, key, value),
@@ -385,7 +541,7 @@ class Model(object):
# Deleted flexible attributes. # Deleted flexible attributes.
for key in self._dirty: for key in self._dirty:
tx.mutate( tx.mutate(
'DELETE FROM {0} ' 'DELETE FROM {} '
'WHERE entity_id=? AND key=?'.format(self._flex_table), 'WHERE entity_id=? AND key=?'.format(self._flex_table),
(self.id, key) (self.id, key)
) )
@@ -394,12 +550,18 @@ class Model(object):
def load(self): def load(self):
"""Refresh the object's metadata from the library database. """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() 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) stored_obj = self._db._get(type(self), self.id)
assert stored_obj is not None, u"object {0} not in DB".format(self.id) assert stored_obj is not None, f"object {self.id} not in DB"
self._values_fixed = {} self._values_fixed = LazyConvertDict(self)
self._values_flex = {} self._values_flex = LazyConvertDict(self)
self.update(dict(stored_obj)) self.update(dict(stored_obj))
self.clear_dirty() self.clear_dirty()
@@ -409,11 +571,11 @@ class Model(object):
self._check_db() self._check_db()
with self._db.transaction() as tx: with self._db.transaction() as tx:
tx.mutate( tx.mutate(
'DELETE FROM {0} WHERE id=?'.format(self._table), f'DELETE FROM {self._table} WHERE id=?',
(self.id,) (self.id,)
) )
tx.mutate( tx.mutate(
'DELETE FROM {0} WHERE entity_id=?'.format(self._flex_table), f'DELETE FROM {self._flex_table} WHERE entity_id=?',
(self.id,) (self.id,)
) )
@@ -431,7 +593,7 @@ class Model(object):
with self._db.transaction() as tx: with self._db.transaction() as tx:
new_id = tx.mutate( new_id = tx.mutate(
'INSERT INTO {0} DEFAULT VALUES'.format(self._table) f'INSERT INTO {self._table} DEFAULT VALUES'
) )
self.id = new_id self.id = new_id
self.added = time.time() self.added = time.time()
@@ -446,11 +608,11 @@ class Model(object):
_formatter = FormattedMapping _formatter = FormattedMapping
def formatted(self, for_path=False): def formatted(self, included_keys=_formatter.ALL_KEYS, for_path=False):
"""Get a mapping containing all values on this object formatted """Get a mapping containing all values on this object formatted
as human-readable unicode strings. as human-readable unicode strings.
""" """
return self._formatter(self, for_path) return self._formatter(self, included_keys, for_path)
def evaluate_template(self, template, for_path=False): def evaluate_template(self, template, for_path=False):
"""Evaluate a template (a string or a `Template` object) using """Evaluate a template (a string or a `Template` object) using
@@ -458,9 +620,9 @@ class Model(object):
separators will be added to the template. separators will be added to the template.
""" """
# Perform substitution. # Perform substitution.
if isinstance(template, six.string_types): if isinstance(template, str):
template = Template(template) template = functemplate.template(template)
return template.substitute(self.formatted(for_path), return template.substitute(self.formatted(for_path=for_path),
self._template_funcs()) self._template_funcs())
# Parsing. # Parsing.
@@ -469,8 +631,8 @@ class Model(object):
def _parse(cls, key, string): def _parse(cls, key, string):
"""Parse a string as a value for the given key. """Parse a string as a value for the given key.
""" """
if not isinstance(string, six.string_types): if not isinstance(string, str):
raise TypeError(u"_parse() argument must be a string") raise TypeError("_parse() argument must be a string")
return cls._type(key).parse(string) return cls._type(key).parse(string)
@@ -482,11 +644,13 @@ class Model(object):
# Database controller and supporting interfaces. # Database controller and supporting interfaces.
class Results(object): class Results:
"""An item query result set. Iterating over the collection lazily """An item query result set. Iterating over the collection lazily
constructs LibModel objects that reflect database rows. constructs LibModel objects that reflect database rows.
""" """
def __init__(self, model_class, rows, db, query=None, sort=None):
def __init__(self, model_class, rows, db, flex_rows,
query=None, sort=None):
"""Create a result set that will construct objects of type """Create a result set that will construct objects of type
`model_class`. `model_class`.
@@ -506,6 +670,7 @@ class Results(object):
self.db = db self.db = db
self.query = query self.query = query
self.sort = sort self.sort = sort
self.flex_rows = flex_rows
# We keep a queue of rows we haven't yet consumed for # We keep a queue of rows we haven't yet consumed for
# materialization. We preserve the original total number of # materialization. We preserve the original total number of
@@ -527,6 +692,10 @@ class Results(object):
a `Results` object a second time should be much faster than the a `Results` object a second time should be much faster than the
first. 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. index = 0 # Position in the materialized objects.
while index < len(self._objects) or self._rows: while index < len(self._objects) or self._rows:
# Are there previously-materialized objects to produce? # Are there previously-materialized objects to produce?
@@ -539,7 +708,7 @@ class Results(object):
else: else:
while self._rows: while self._rows:
row = self._rows.pop(0) row = self._rows.pop(0)
obj = self._make_model(row) obj = self._make_model(row, flex_attrs.get(row['id'], {}))
# If there is a slow-query predicate, ensurer that the # If there is a slow-query predicate, ensurer that the
# object passes it. # object passes it.
if not self.query or self.query.match(obj): if not self.query or self.query.match(obj):
@@ -561,20 +730,24 @@ class Results(object):
# Objects are pre-sorted (i.e., by the database). # Objects are pre-sorted (i.e., by the database).
return self._get_objects() return self._get_objects()
def _make_model(self, row): def _get_indexed_flex_attrs(self):
# Get the flexible attributes for the object. """ Index flexible attributes by the entity id they belong to
with self.db.transaction() as tx: """
flex_rows = tx.query( flex_values = {}
'SELECT * FROM {0} WHERE entity_id=?'.format( for row in self.flex_rows:
self.model_class._flex_table if row['entity_id'] not in flex_values:
), flex_values[row['entity_id']] = {}
(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) cols = dict(row)
values = dict((k, v) for (k, v) in cols.items() values = {k: v for (k, v) in cols.items()
if not k[:4] == 'flex') if not k[:4] == 'flex'}
flex_values = dict((row['key'], row['value']) for row in flex_rows)
# Construct the Python object # Construct the Python object
obj = self.model_class._awaken(self.db, values, flex_values) obj = self.model_class._awaken(self.db, values, flex_values)
@@ -623,7 +796,7 @@ class Results(object):
next(it) next(it)
return next(it) return next(it)
except StopIteration: except StopIteration:
raise IndexError(u'result index {0} out of range'.format(n)) raise IndexError(f'result index {n} out of range')
def get(self): def get(self):
"""Return the first matching object, or None if no objects """Return the first matching object, or None if no objects
@@ -636,10 +809,16 @@ class Results(object):
return None return None
class Transaction(object): class Transaction:
"""A context manager for safe, concurrent access to the database. """A context manager for safe, concurrent access to the database.
All SQL commands should be executed through a transaction. 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): def __init__(self, db):
self.db = db self.db = db
@@ -661,12 +840,15 @@ class Transaction(object):
entered but not yet exited transaction. If it is the last active entered but not yet exited transaction. If it is the last active
transaction, the database updates are committed. 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: with self.db._tx_stack() as stack:
assert stack.pop() is self assert stack.pop() is self
empty = not stack empty = not stack
if empty: if empty:
# Ending a "root" transaction. End the SQLite transaction. # Ending a "root" transaction. End the SQLite transaction.
self.db._connection().commit() self.db._connection().commit()
self._mutated = False
self.db._db_lock.release() self.db._db_lock.release()
def query(self, statement, subvals=()): def query(self, statement, subvals=()):
@@ -680,28 +862,52 @@ class Transaction(object):
"""Execute an SQL statement with substitution values and return """Execute an SQL statement with substitution values and return
the row ID of the last affected row. the row ID of the last affected row.
""" """
cursor = self.db._connection().execute(statement, subvals) try:
return cursor.lastrowid 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
def script(self, statements): def script(self, statements):
"""Execute a string containing multiple SQL 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) self.db._connection().executescript(statements)
class Database(object): class Database:
"""A container for Model objects that wraps an SQLite database as """A container for Model objects that wraps an SQLite database as
the backend. the backend.
""" """
_models = () _models = ()
"""The Model subclasses representing tables in this database. """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): def __init__(self, path, timeout=5.0):
self.path = path self.path = path
self.timeout = timeout self.timeout = timeout
self._connections = {} self._connections = {}
self._tx_stacks = defaultdict(list) self._tx_stacks = defaultdict(list)
self._extensions = []
# A lock to protect the _connections and _tx_stacks maps, which # A lock to protect the _connections and _tx_stacks maps, which
# both map thread IDs to private resources. # both map thread IDs to private resources.
@@ -751,6 +957,13 @@ class Database(object):
py3_path(self.path), timeout=self.timeout 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. # Access SELECT results like dictionaries.
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
@@ -779,6 +992,18 @@ class Database(object):
""" """
return Transaction(self) 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. # Schema setup and migration.
def _make_table(self, table, fields): def _make_table(self, table, fields):
@@ -788,7 +1013,7 @@ class Database(object):
# Get current schema. # Get current schema.
with self.transaction() as tx: with self.transaction() as tx:
rows = tx.query('PRAGMA table_info(%s)' % table) rows = tx.query('PRAGMA table_info(%s)' % table)
current_fields = set([row[1] for row in rows]) current_fields = {row[1] for row in rows}
field_names = set(fields.keys()) field_names = set(fields.keys())
if current_fields.issuperset(field_names): if current_fields.issuperset(field_names):
@@ -799,9 +1024,9 @@ class Database(object):
# No table exists. # No table exists.
columns = [] columns = []
for name, typ in fields.items(): for name, typ in fields.items():
columns.append('{0} {1}'.format(name, typ.sql)) columns.append(f'{name} {typ.sql}')
setup_sql = 'CREATE TABLE {0} ({1});\n'.format(table, setup_sql = 'CREATE TABLE {} ({});\n'.format(table,
', '.join(columns)) ', '.join(columns))
else: else:
# Table exists does not match the field set. # Table exists does not match the field set.
@@ -809,7 +1034,7 @@ class Database(object):
for name, typ in fields.items(): for name, typ in fields.items():
if name in current_fields: if name in current_fields:
continue continue
setup_sql += 'ALTER TABLE {0} ADD COLUMN {1} {2};\n'.format( setup_sql += 'ALTER TABLE {} ADD COLUMN {} {};\n'.format(
table, name, typ.sql table, name, typ.sql
) )
@@ -845,17 +1070,31 @@ class Database(object):
where, subvals = query.clause() where, subvals = query.clause()
order_by = sort.order_clause() order_by = sort.order_clause()
sql = ("SELECT * FROM {0} WHERE {1} {2}").format( sql = ("SELECT * FROM {} WHERE {} {}").format(
model_cls._table, model_cls._table,
where or '1', where or '1',
"ORDER BY {0}".format(order_by) if order_by else '', 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',
)
) )
with self.transaction() as tx: with self.transaction() as tx:
rows = tx.query(sql, subvals) rows = tx.query(sql, subvals)
flex_rows = tx.query(flex_sql, subvals)
return Results( return Results(
model_cls, rows, self, model_cls, rows, self, flex_rows,
None if where else query, # Slow query component. None if where else query, # Slow query component.
sort if sort.is_slow() else None, # Slow sort component. sort if sort.is_slow() else None, # Slow sort component.
) )
Executable → Regular
+149 -81
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,7 +14,6 @@
"""The Query type hierarchy for DBCore. """The Query type hierarchy for DBCore.
""" """
from __future__ import division, absolute_import, print_function
import re import re
from operator import mul from operator import mul
@@ -23,10 +21,6 @@ from beets import util
from datetime import datetime, timedelta from datetime import datetime, timedelta
import unicodedata import unicodedata
from functools import reduce from functools import reduce
import six
if not six.PY2:
buffer = memoryview # sqlite won't accept memoryview in python 2
class ParsingError(ValueError): class ParsingError(ValueError):
@@ -40,29 +34,32 @@ class InvalidQueryError(ParsingError):
The query should be a unicode string or a list, which will be space-joined. The query should be a unicode string or a list, which will be space-joined.
""" """
def __init__(self, query, explanation): def __init__(self, query, explanation):
if isinstance(query, list): if isinstance(query, list):
query = " ".join(query) query = " ".join(query)
message = u"'{0}': {1}".format(query, explanation) message = f"'{query}': {explanation}"
super(InvalidQueryError, self).__init__(message) super().__init__(message)
class InvalidQueryArgumentTypeError(ParsingError): class InvalidQueryArgumentValueError(ParsingError):
"""Represent a query argument that could not be converted as expected. """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 It exists to be caught in upper stack levels so a meaningful (i.e. with the
query) InvalidQueryError can be raised. query) InvalidQueryError can be raised.
""" """
def __init__(self, what, expected, detail=None): def __init__(self, what, expected, detail=None):
message = u"'{0}' is not {1}".format(what, expected) message = f"'{what}' is not {expected}"
if detail: if detail:
message = u"{0}: {1}".format(message, detail) message = f"{message}: {detail}"
super(InvalidQueryArgumentTypeError, self).__init__(message) super().__init__(message)
class Query(object): class Query:
"""An abstract class representing a query into the item database. """An abstract class representing a query into the item database.
""" """
def clause(self): def clause(self):
"""Generate an SQLite expression implementing the query. """Generate an SQLite expression implementing the query.
@@ -79,7 +76,7 @@ class Query(object):
raise NotImplementedError raise NotImplementedError
def __repr__(self): def __repr__(self):
return "{0.__class__.__name__}()".format(self) return f"{self.__class__.__name__}()"
def __eq__(self, other): def __eq__(self, other):
return type(self) == type(other) return type(self) == type(other)
@@ -95,6 +92,7 @@ class FieldQuery(Query):
string. Subclasses may also provide `col_clause` to implement the string. Subclasses may also provide `col_clause` to implement the
same matching functionality in SQLite. same matching functionality in SQLite.
""" """
def __init__(self, field, pattern, fast=True): def __init__(self, field, pattern, fast=True):
self.field = field self.field = field
self.pattern = pattern self.pattern = pattern
@@ -125,7 +123,7 @@ class FieldQuery(Query):
"{0.fast})".format(self)) "{0.fast})".format(self))
def __eq__(self, other): def __eq__(self, other):
return super(FieldQuery, self).__eq__(other) and \ return super().__eq__(other) and \
self.field == other.field and self.pattern == other.pattern self.field == other.field and self.pattern == other.pattern
def __hash__(self): def __hash__(self):
@@ -134,6 +132,7 @@ class FieldQuery(Query):
class MatchQuery(FieldQuery): class MatchQuery(FieldQuery):
"""A query that looks for exact matches in an item field.""" """A query that looks for exact matches in an item field."""
def col_clause(self): def col_clause(self):
return self.field + " = ?", [self.pattern] return self.field + " = ?", [self.pattern]
@@ -143,19 +142,16 @@ class MatchQuery(FieldQuery):
class NoneQuery(FieldQuery): class NoneQuery(FieldQuery):
"""A query that checks whether a field is null."""
def __init__(self, field, fast=True): def __init__(self, field, fast=True):
super(NoneQuery, self).__init__(field, None, fast) super().__init__(field, None, fast)
def col_clause(self): def col_clause(self):
return self.field + " IS NULL", () return self.field + " IS NULL", ()
@classmethod def match(self, item):
def match(cls, item): return item.get(self.field) is None
try:
return item[cls.field] is None
except KeyError:
return True
def __repr__(self): def __repr__(self):
return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self) return "{0.__class__.__name__}({0.field!r}, {0.fast})".format(self)
@@ -165,6 +161,7 @@ class StringFieldQuery(FieldQuery):
"""A FieldQuery that converts values to strings before matching """A FieldQuery that converts values to strings before matching
them. them.
""" """
@classmethod @classmethod
def value_match(cls, pattern, value): def value_match(cls, pattern, value):
"""Determine whether the value matches the pattern. The value """Determine whether the value matches the pattern. The value
@@ -182,11 +179,12 @@ class StringFieldQuery(FieldQuery):
class SubstringQuery(StringFieldQuery): class SubstringQuery(StringFieldQuery):
"""A query that matches a substring in a specific item field.""" """A query that matches a substring in a specific item field."""
def col_clause(self): def col_clause(self):
pattern = (self.pattern pattern = (self.pattern
.replace('\\', '\\\\') .replace('\\', '\\\\')
.replace('%', '\\%') .replace('%', '\\%')
.replace('_', '\\_')) .replace('_', '\\_'))
search = '%' + pattern + '%' search = '%' + pattern + '%'
clause = self.field + " like ? escape '\\'" clause = self.field + " like ? escape '\\'"
subvals = [search] subvals = [search]
@@ -204,16 +202,17 @@ class RegexpQuery(StringFieldQuery):
Raises InvalidQueryError when the pattern is not a valid regular Raises InvalidQueryError when the pattern is not a valid regular
expression. expression.
""" """
def __init__(self, field, pattern, fast=True): def __init__(self, field, pattern, fast=True):
super(RegexpQuery, self).__init__(field, pattern, fast) super().__init__(field, pattern, fast)
pattern = self._normalize(pattern) pattern = self._normalize(pattern)
try: try:
self.pattern = re.compile(self.pattern) self.pattern = re.compile(self.pattern)
except re.error as exc: except re.error as exc:
# Invalid regular expression. # Invalid regular expression.
raise InvalidQueryArgumentTypeError(pattern, raise InvalidQueryArgumentValueError(pattern,
u"a regular expression", "a regular expression",
format(exc)) format(exc))
@staticmethod @staticmethod
def _normalize(s): def _normalize(s):
@@ -231,9 +230,10 @@ class BooleanQuery(MatchQuery):
"""Matches a boolean field. Pattern should either be a boolean or a """Matches a boolean field. Pattern should either be a boolean or a
string reflecting a boolean. string reflecting a boolean.
""" """
def __init__(self, field, pattern, fast=True): def __init__(self, field, pattern, fast=True):
super(BooleanQuery, self).__init__(field, pattern, fast) super().__init__(field, pattern, fast)
if isinstance(pattern, six.string_types): if isinstance(pattern, str):
self.pattern = util.str2bool(pattern) self.pattern = util.str2bool(pattern)
self.pattern = int(self.pattern) self.pattern = int(self.pattern)
@@ -244,17 +244,18 @@ class BytesQuery(MatchQuery):
`unicode` equivalently in Python 2. Always use this query instead of `unicode` equivalently in Python 2. Always use this query instead of
`MatchQuery` when matching on BLOB values. `MatchQuery` when matching on BLOB values.
""" """
def __init__(self, field, pattern): def __init__(self, field, pattern):
super(BytesQuery, self).__init__(field, pattern) super().__init__(field, pattern)
# Use a buffer/memoryview representation of the pattern for SQLite # Use a buffer/memoryview representation of the pattern for SQLite
# matching. This instructs SQLite to treat the blob as binary # matching. This instructs SQLite to treat the blob as binary
# rather than encoded Unicode. # rather than encoded Unicode.
if isinstance(self.pattern, (six.text_type, bytes)): if isinstance(self.pattern, (str, bytes)):
if isinstance(self.pattern, six.text_type): if isinstance(self.pattern, str):
self.pattern = self.pattern.encode('utf-8') self.pattern = self.pattern.encode('utf-8')
self.buf_pattern = buffer(self.pattern) self.buf_pattern = memoryview(self.pattern)
elif isinstance(self.pattern, buffer): elif isinstance(self.pattern, memoryview):
self.buf_pattern = self.pattern self.buf_pattern = self.pattern
self.pattern = bytes(self.pattern) self.pattern = bytes(self.pattern)
@@ -270,6 +271,7 @@ class NumericQuery(FieldQuery):
Raises InvalidQueryError when the pattern does not represent an int or Raises InvalidQueryError when the pattern does not represent an int or
a float. a float.
""" """
def _convert(self, s): def _convert(self, s):
"""Convert a string to a numeric type (float or int). """Convert a string to a numeric type (float or int).
@@ -285,10 +287,10 @@ class NumericQuery(FieldQuery):
try: try:
return float(s) return float(s)
except ValueError: except ValueError:
raise InvalidQueryArgumentTypeError(s, u"an int or a float") raise InvalidQueryArgumentValueError(s, "an int or a float")
def __init__(self, field, pattern, fast=True): def __init__(self, field, pattern, fast=True):
super(NumericQuery, self).__init__(field, pattern, fast) super().__init__(field, pattern, fast)
parts = pattern.split('..', 1) parts = pattern.split('..', 1)
if len(parts) == 1: if len(parts) == 1:
@@ -306,7 +308,7 @@ class NumericQuery(FieldQuery):
if self.field not in item: if self.field not in item:
return False return False
value = item[self.field] value = item[self.field]
if isinstance(value, six.string_types): if isinstance(value, str):
value = self._convert(value) value = self._convert(value)
if self.point is not None: if self.point is not None:
@@ -323,20 +325,21 @@ class NumericQuery(FieldQuery):
return self.field + '=?', (self.point,) return self.field + '=?', (self.point,)
else: else:
if self.rangemin is not None and self.rangemax is not None: if self.rangemin is not None and self.rangemax is not None:
return (u'{0} >= ? AND {0} <= ?'.format(self.field), return ('{0} >= ? AND {0} <= ?'.format(self.field),
(self.rangemin, self.rangemax)) (self.rangemin, self.rangemax))
elif self.rangemin is not None: elif self.rangemin is not None:
return u'{0} >= ?'.format(self.field), (self.rangemin,) return f'{self.field} >= ?', (self.rangemin,)
elif self.rangemax is not None: elif self.rangemax is not None:
return u'{0} <= ?'.format(self.field), (self.rangemax,) return f'{self.field} <= ?', (self.rangemax,)
else: else:
return u'1', () return '1', ()
class CollectionQuery(Query): class CollectionQuery(Query):
"""An abstract query class that aggregates other queries. Can be """An abstract query class that aggregates other queries. Can be
indexed like a list to access the sub-queries. indexed like a list to access the sub-queries.
""" """
def __init__(self, subqueries=()): def __init__(self, subqueries=()):
self.subqueries = subqueries self.subqueries = subqueries
@@ -374,7 +377,7 @@ class CollectionQuery(Query):
return "{0.__class__.__name__}({0.subqueries!r})".format(self) return "{0.__class__.__name__}({0.subqueries!r})".format(self)
def __eq__(self, other): def __eq__(self, other):
return super(CollectionQuery, self).__eq__(other) and \ return super().__eq__(other) and \
self.subqueries == other.subqueries self.subqueries == other.subqueries
def __hash__(self): def __hash__(self):
@@ -389,6 +392,7 @@ class AnyFieldQuery(CollectionQuery):
any field. The individual field query class is provided to the any field. The individual field query class is provided to the
constructor. constructor.
""" """
def __init__(self, pattern, fields, cls): def __init__(self, pattern, fields, cls):
self.pattern = pattern self.pattern = pattern
self.fields = fields self.fields = fields
@@ -397,7 +401,7 @@ class AnyFieldQuery(CollectionQuery):
subqueries = [] subqueries = []
for field in self.fields: for field in self.fields:
subqueries.append(cls(field, pattern, True)) subqueries.append(cls(field, pattern, True))
super(AnyFieldQuery, self).__init__(subqueries) super().__init__(subqueries)
def clause(self): def clause(self):
return self.clause_with_joiner('or') return self.clause_with_joiner('or')
@@ -413,7 +417,7 @@ class AnyFieldQuery(CollectionQuery):
"{0.query_class.__name__})".format(self)) "{0.query_class.__name__})".format(self))
def __eq__(self, other): def __eq__(self, other):
return super(AnyFieldQuery, self).__eq__(other) and \ return super().__eq__(other) and \
self.query_class == other.query_class self.query_class == other.query_class
def __hash__(self): def __hash__(self):
@@ -424,6 +428,7 @@ class MutableCollectionQuery(CollectionQuery):
"""A collection query whose subqueries may be modified after the """A collection query whose subqueries may be modified after the
query is initialized. query is initialized.
""" """
def __setitem__(self, key, value): def __setitem__(self, key, value):
self.subqueries[key] = value self.subqueries[key] = value
@@ -433,33 +438,36 @@ class MutableCollectionQuery(CollectionQuery):
class AndQuery(MutableCollectionQuery): class AndQuery(MutableCollectionQuery):
"""A conjunction of a list of other queries.""" """A conjunction of a list of other queries."""
def clause(self): def clause(self):
return self.clause_with_joiner('and') return self.clause_with_joiner('and')
def match(self, item): 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): class OrQuery(MutableCollectionQuery):
"""A conjunction of a list of other queries.""" """A conjunction of a list of other queries."""
def clause(self): def clause(self):
return self.clause_with_joiner('or') return self.clause_with_joiner('or')
def match(self, item): 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): class NotQuery(Query):
"""A query that matches the negation of its `subquery`, as a shorcut for """A query that matches the negation of its `subquery`, as a shorcut for
performing `not(subquery)` without using regular expressions. performing `not(subquery)` without using regular expressions.
""" """
def __init__(self, subquery): def __init__(self, subquery):
self.subquery = subquery self.subquery = subquery
def clause(self): def clause(self):
clause, subvals = self.subquery.clause() clause, subvals = self.subquery.clause()
if clause: if clause:
return 'not ({0})'.format(clause), subvals return f'not ({clause})', subvals
else: else:
# If there is no clause, there is nothing to negate. All the logic # If there is no clause, there is nothing to negate. All the logic
# is handled by match() for slow queries. # is handled by match() for slow queries.
@@ -472,7 +480,7 @@ class NotQuery(Query):
return "{0.__class__.__name__}({0.subquery!r})".format(self) return "{0.__class__.__name__}({0.subquery!r})".format(self)
def __eq__(self, other): def __eq__(self, other):
return super(NotQuery, self).__eq__(other) and \ return super().__eq__(other) and \
self.subquery == other.subquery self.subquery == other.subquery
def __hash__(self): def __hash__(self):
@@ -481,6 +489,7 @@ class NotQuery(Query):
class TrueQuery(Query): class TrueQuery(Query):
"""A query that always matches.""" """A query that always matches."""
def clause(self): def clause(self):
return '1', () return '1', ()
@@ -490,6 +499,7 @@ class TrueQuery(Query):
class FalseQuery(Query): class FalseQuery(Query):
"""A query that never matches.""" """A query that never matches."""
def clause(self): def clause(self):
return '0', () return '0', ()
@@ -526,42 +536,88 @@ def _parse_periods(pattern):
return (start, end) return (start, end)
class Period(object): class Period:
"""A period of time given by a date, time and precision. """A period of time given by a date, time and precision.
Example: 2014-01-01 10:50:30 with precision 'month' represents all Example: 2014-01-01 10:50:30 with precision 'month' represents all
instants of time during January 2014. instants of time during January 2014.
""" """
precisions = ('year', 'month', 'day') precisions = ('year', 'month', 'day', 'hour', 'minute', 'second')
date_formats = ('%Y', '%Y-%m', '%Y-%m-%d') 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])'
def __init__(self, date, precision): def __init__(self, date, precision):
"""Create a period with the given date (a `datetime` object) and """Create a period with the given date (a `datetime` object) and
precision (a string, one of "year", "month", or "day"). precision (a string, one of "year", "month", "day", "hour", "minute",
or "second").
""" """
if precision not in Period.precisions: if precision not in Period.precisions:
raise ValueError(u'Invalid precision {0}'.format(precision)) raise ValueError(f'Invalid precision {precision}')
self.date = date self.date = date
self.precision = precision self.precision = precision
@classmethod @classmethod
def parse(cls, string): def parse(cls, string):
"""Parse a date and return a `Period` object or `None` if the """Parse a date and return a `Period` object or `None` if the
string is empty. 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.
""" """
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: if not string:
return None return None
ordinal = string.count('-')
if ordinal >= len(cls.date_formats): # Check for a relative date.
# Too many components. match_dq = re.match(cls.relative_re, string)
return None if match_dq:
date_format = cls.date_formats[ordinal] sign = match_dq.group('sign')
try: quantity = match_dq.group('quantity')
date = datetime.strptime(string, date_format) timespan = match_dq.group('timespan')
except ValueError:
# Parsing failed. # Add or subtract the given amount of time from the current
return None # 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')
precision = cls.precisions[ordinal] precision = cls.precisions[ordinal]
return cls(date, precision) return cls(date, precision)
@@ -580,11 +636,17 @@ class Period(object):
return date.replace(year=date.year + 1, month=1) return date.replace(year=date.year + 1, month=1)
elif 'day' == precision: elif 'day' == precision:
return date + timedelta(days=1) 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: else:
raise ValueError(u'unhandled precision {0}'.format(precision)) raise ValueError(f'unhandled precision {precision}')
class DateInterval(object): class DateInterval:
"""A closed-open interval of dates. """A closed-open interval of dates.
A left endpoint of None means since the beginning of time. A left endpoint of None means since the beginning of time.
@@ -593,7 +655,7 @@ class DateInterval(object):
def __init__(self, start, end): def __init__(self, start, end):
if start is not None and end is not None and not start < end: if start is not None and end is not None and not start < end:
raise ValueError(u"start date {0} is not before end date {1}" raise ValueError("start date {} is not before end date {}"
.format(start, end)) .format(start, end))
self.start = start self.start = start
self.end = end self.end = end
@@ -614,7 +676,7 @@ class DateInterval(object):
return True return True
def __str__(self): def __str__(self):
return '[{0}, {1})'.format(self.start, self.end) return f'[{self.start}, {self.end})'
class DateQuery(FieldQuery): class DateQuery(FieldQuery):
@@ -626,8 +688,9 @@ class DateQuery(FieldQuery):
The value of a date field can be matched against a date interval by The value of a date field can be matched against a date interval by
using an ellipsis interval syntax similar to that of NumericQuery. using an ellipsis interval syntax similar to that of NumericQuery.
""" """
def __init__(self, field, pattern, fast=True): def __init__(self, field, pattern, fast=True):
super(DateQuery, self).__init__(field, pattern, fast) super().__init__(field, pattern, fast)
start, end = _parse_periods(pattern) start, end = _parse_periods(pattern)
self.interval = DateInterval.from_periods(start, end) self.interval = DateInterval.from_periods(start, end)
@@ -635,7 +698,7 @@ class DateQuery(FieldQuery):
if self.field not in item: if self.field not in item:
return False return False
timestamp = float(item[self.field]) timestamp = float(item[self.field])
date = datetime.utcfromtimestamp(timestamp) date = datetime.fromtimestamp(timestamp)
return self.interval.contains(date) return self.interval.contains(date)
_clause_tmpl = "{0} {1} ?" _clause_tmpl = "{0} {1} ?"
@@ -669,6 +732,7 @@ class DurationQuery(NumericQuery):
Raises InvalidQueryError when the pattern does not represent an int, float Raises InvalidQueryError when the pattern does not represent an int, float
or M:SS time interval. or M:SS time interval.
""" """
def _convert(self, s): def _convert(self, s):
"""Convert a M:SS or numeric string to a float. """Convert a M:SS or numeric string to a float.
@@ -683,14 +747,14 @@ class DurationQuery(NumericQuery):
try: try:
return float(s) return float(s)
except ValueError: except ValueError:
raise InvalidQueryArgumentTypeError( raise InvalidQueryArgumentValueError(
s, s,
u"a M:SS string or a float") "a M:SS string or a float")
# Sorting. # Sorting.
class Sort(object): class Sort:
"""An abstract class representing a sort operation for a query into """An abstract class representing a sort operation for a query into
the item database. the item database.
""" """
@@ -777,13 +841,13 @@ class MultipleSort(Sort):
return items return items
def __repr__(self): def __repr__(self):
return 'MultipleSort({!r})'.format(self.sorts) return f'MultipleSort({self.sorts!r})'
def __hash__(self): def __hash__(self):
return hash(tuple(self.sorts)) return hash(tuple(self.sorts))
def __eq__(self, other): def __eq__(self, other):
return super(MultipleSort, self).__eq__(other) and \ return super().__eq__(other) and \
self.sorts == other.sorts self.sorts == other.sorts
@@ -791,6 +855,7 @@ class FieldSort(Sort):
"""An abstract sort criterion that orders by a specific field (of """An abstract sort criterion that orders by a specific field (of
any kind). any kind).
""" """
def __init__(self, field, ascending=True, case_insensitive=True): def __init__(self, field, ascending=True, case_insensitive=True):
self.field = field self.field = field
self.ascending = ascending self.ascending = ascending
@@ -803,14 +868,14 @@ class FieldSort(Sort):
def key(item): def key(item):
field_val = item.get(self.field, '') field_val = item.get(self.field, '')
if self.case_insensitive and isinstance(field_val, six.text_type): if self.case_insensitive and isinstance(field_val, str):
field_val = field_val.lower() field_val = field_val.lower()
return field_val return field_val
return sorted(objs, key=key, reverse=not self.ascending) return sorted(objs, key=key, reverse=not self.ascending)
def __repr__(self): def __repr__(self):
return '<{0}: {1}{2}>'.format( return '<{}: {}{}>'.format(
type(self).__name__, type(self).__name__,
self.field, self.field,
'+' if self.ascending else '-', '+' if self.ascending else '-',
@@ -820,7 +885,7 @@ class FieldSort(Sort):
return hash((self.field, self.ascending)) return hash((self.field, self.ascending))
def __eq__(self, other): def __eq__(self, other):
return super(FieldSort, self).__eq__(other) and \ return super().__eq__(other) and \
self.field == other.field and \ self.field == other.field and \
self.ascending == other.ascending self.ascending == other.ascending
@@ -828,6 +893,7 @@ class FieldSort(Sort):
class FixedFieldSort(FieldSort): class FixedFieldSort(FieldSort):
"""Sort object to sort on a fixed field. """Sort object to sort on a fixed field.
""" """
def order_clause(self): def order_clause(self):
order = "ASC" if self.ascending else "DESC" order = "ASC" if self.ascending else "DESC"
if self.case_insensitive: if self.case_insensitive:
@@ -837,19 +903,21 @@ class FixedFieldSort(FieldSort):
'ELSE {0} END)'.format(self.field) 'ELSE {0} END)'.format(self.field)
else: else:
field = self.field field = self.field
return "{0} {1}".format(field, order) return f"{field} {order}"
class SlowFieldSort(FieldSort): class SlowFieldSort(FieldSort):
"""A sort criterion by some model field other than a fixed field: """A sort criterion by some model field other than a fixed field:
i.e., a computed or flexible field. i.e., a computed or flexible field.
""" """
def is_slow(self): def is_slow(self):
return True return True
class NullSort(Sort): class NullSort(Sort):
"""No sorting. Leave results unsorted.""" """No sorting. Leave results unsorted."""
def sort(self, items): def sort(self, items):
return items return items
Executable → Regular
+36 -34
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,12 +14,10 @@
"""Parsing of strings into DBCore queries. """Parsing of strings into DBCore queries.
""" """
from __future__ import division, absolute_import, print_function
import re import re
import itertools import itertools
from . import query from . import query
import beets
PARSE_QUERY_PART_REGEX = re.compile( PARSE_QUERY_PART_REGEX = re.compile(
# Non-capturing optional segment for the keyword. # Non-capturing optional segment for the keyword.
@@ -89,7 +86,7 @@ def parse_query_part(part, query_classes={}, prefixes={},
assert match # Regex should always match assert match # Regex should always match
negate = bool(match.group(1)) negate = bool(match.group(1))
key = match.group(2) 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 # Check whether there's a prefix in the query and use the
# corresponding query type. # corresponding query type.
@@ -119,12 +116,13 @@ def construct_query_part(model_cls, prefixes, query_part):
if not query_part: if not query_part:
return query.TrueQuery() return query.TrueQuery()
# Use `model_cls` to build up a map from field names to `Query` # Use `model_cls` to build up a map from field (or query) names to
# classes. # `Query` classes.
query_classes = {} query_classes = {}
for k, t in itertools.chain(model_cls._fields.items(), for k, t in itertools.chain(model_cls._fields.items(),
model_cls._types.items()): model_cls._types.items()):
query_classes[k] = t.query query_classes[k] = t.query
query_classes.update(model_cls._queries) # Non-field queries.
# Parse the string. # Parse the string.
key, pattern, query_class, negate = \ key, pattern, query_class, negate = \
@@ -137,26 +135,27 @@ def construct_query_part(model_cls, prefixes, query_part):
# The query type matches a specific field, but none was # The query type matches a specific field, but none was
# specified. So we use a version of the query that matches # specified. So we use a version of the query that matches
# any field. # any field.
q = query.AnyFieldQuery(pattern, model_cls._search_fields, out_query = query.AnyFieldQuery(pattern, model_cls._search_fields,
query_class) query_class)
if negate:
return query.NotQuery(q)
else:
return q
else: else:
# Non-field query type. # Non-field query type.
if negate: out_query = query_class(pattern)
return query.NotQuery(query_class(pattern))
else:
return query_class(pattern)
# Otherwise, this must be a `FieldQuery`. Use the field name to # Field queries get constructed according to the name of the field
# construct the query object. # they are querying.
key = key.lower() elif issubclass(query_class, query.FieldQuery):
q = query_class(key.lower(), pattern, key in model_cls._fields) 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.
if negate: if negate:
return query.NotQuery(q) return query.NotQuery(out_query)
return q else:
return out_query
def query_from_strings(query_cls, model_cls, prefixes, query_parts): def query_from_strings(query_cls, model_cls, prefixes, query_parts):
@@ -172,11 +171,13 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts):
return query_cls(subqueries) return query_cls(subqueries)
def construct_sort_part(model_cls, part): def construct_sort_part(model_cls, part, case_insensitive=True):
"""Create a `Sort` from a single string criterion. """Create a `Sort` from a single string criterion.
`model_cls` is the `Model` being queried. `part` is a single string `model_cls` is the `Model` being queried. `part` is a single string
ending in ``+`` or ``-`` indicating the sort. ending in ``+`` or ``-`` indicating the sort. `case_insensitive`
indicates whether or not the sort should be performed in a case
sensitive manner.
""" """
assert part, "part must be a field name and + or -" assert part, "part must be a field name and + or -"
field = part[:-1] field = part[:-1]
@@ -185,7 +186,6 @@ def construct_sort_part(model_cls, part):
assert direction in ('+', '-'), "part must end with + or -" assert direction in ('+', '-'), "part must end with + or -"
is_ascending = direction == '+' is_ascending = direction == '+'
case_insensitive = beets.config['sort_case_insensitive'].get(bool)
if field in model_cls._sorts: if field in model_cls._sorts:
sort = model_cls._sorts[field](model_cls, is_ascending, sort = model_cls._sorts[field](model_cls, is_ascending,
case_insensitive) case_insensitive)
@@ -197,21 +197,23 @@ def construct_sort_part(model_cls, part):
return sort return sort
def sort_from_strings(model_cls, sort_parts): def sort_from_strings(model_cls, sort_parts, case_insensitive=True):
"""Create a `Sort` from a list of sort criteria (strings). """Create a `Sort` from a list of sort criteria (strings).
""" """
if not sort_parts: if not sort_parts:
sort = query.NullSort() sort = query.NullSort()
elif len(sort_parts) == 1: elif len(sort_parts) == 1:
sort = construct_sort_part(model_cls, sort_parts[0]) sort = construct_sort_part(model_cls, sort_parts[0], case_insensitive)
else: else:
sort = query.MultipleSort() sort = query.MultipleSort()
for part in sort_parts: for part in sort_parts:
sort.add_sort(construct_sort_part(model_cls, part)) sort.add_sort(construct_sort_part(model_cls, part,
case_insensitive))
return sort return sort
def parse_sorted_query(model_cls, parts, prefixes={}): def parse_sorted_query(model_cls, parts, prefixes={},
case_insensitive=True):
"""Given a list of strings, create the `Query` and `Sort` that they """Given a list of strings, create the `Query` and `Sort` that they
represent. represent.
""" """
@@ -222,8 +224,8 @@ def parse_sorted_query(model_cls, parts, prefixes={}):
# Split up query in to comma-separated subqueries, each representing # Split up query in to comma-separated subqueries, each representing
# an AndQuery, which need to be joined together in one OrQuery # an AndQuery, which need to be joined together in one OrQuery
subquery_parts = [] subquery_parts = []
for part in parts + [u',']: for part in parts + [',']:
if part.endswith(u','): if part.endswith(','):
# Ensure we can catch "foo, bar" as well as "foo , bar" # Ensure we can catch "foo, bar" as well as "foo , bar"
last_subquery_part = part[:-1] last_subquery_part = part[:-1]
if last_subquery_part: if last_subquery_part:
@@ -237,8 +239,8 @@ def parse_sorted_query(model_cls, parts, prefixes={}):
else: else:
# Sort parts (1) end in + or -, (2) don't have a field, and # Sort parts (1) end in + or -, (2) don't have a field, and
# (3) consist of more than just the + or -. # (3) consist of more than just the + or -.
if part.endswith((u'+', u'-')) \ if part.endswith(('+', '-')) \
and u':' not in part \ and ':' not in part \
and len(part) > 1: and len(part) > 1:
sort_parts.append(part) sort_parts.append(part)
else: else:
@@ -246,5 +248,5 @@ def parse_sorted_query(model_cls, parts, prefixes={}):
# Avoid needlessly wrapping single statements in an OR # Avoid needlessly wrapping single statements in an OR
q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0] q = query.OrQuery(query_parts) if len(query_parts) > 1 else query_parts[0]
s = sort_from_strings(model_cls, sort_parts) s = sort_from_strings(model_cls, sort_parts, case_insensitive)
return q, s return q, s
Executable → Regular
+44 -26
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,25 +14,20 @@
"""Representation of type information for DBCore model fields. """Representation of type information for DBCore model fields.
""" """
from __future__ import division, absolute_import, print_function
from . import query from . import query
from beets.util import str2bool from beets.util import str2bool
import six
if not six.PY2:
buffer = memoryview # sqlite won't accept memoryview in python 2
# Abstract base. # Abstract base.
class Type(object): class Type:
"""An object encapsulating the type of a model field. Includes """An object encapsulating the type of a model field. Includes
information about how to store, query, format, and parse a given information about how to store, query, format, and parse a given
field. field.
""" """
sql = u'TEXT' sql = 'TEXT'
"""The SQLite column type for the value. """The SQLite column type for the value.
""" """
@@ -41,7 +35,7 @@ class Type(object):
"""The `Query` subclass to be used when querying the field. """The `Query` subclass to be used when querying the field.
""" """
model_type = six.text_type model_type = str
"""The Python type that is used to represent the value in the model. """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 The model is guaranteed to return a value of this type if the field
@@ -63,11 +57,11 @@ class Type(object):
value = self.null value = self.null
# `self.null` might be `None` # `self.null` might be `None`
if value is None: if value is None:
value = u'' value = ''
if isinstance(value, bytes): if isinstance(value, bytes):
value = value.decode('utf-8', 'ignore') value = value.decode('utf-8', 'ignore')
return six.text_type(value) return str(value)
def parse(self, string): def parse(self, string):
"""Parse a (possibly human-written) string and return the """Parse a (possibly human-written) string and return the
@@ -97,16 +91,16 @@ class Type(object):
For fixed fields the type of `value` is determined by the column For fixed fields the type of `value` is determined by the column
type affinity given in the `sql` property and the SQL to Python type affinity given in the `sql` property and the SQL to Python
mapping of the database adapter. For more information see: mapping of the database adapter. For more information see:
http://www.sqlite.org/datatype3.html https://www.sqlite.org/datatype3.html
https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types https://docs.python.org/2/library/sqlite3.html#sqlite-and-python-types
Flexible fields have the type affinity `TEXT`. This means the Flexible fields have the type affinity `TEXT`. This means the
`sql_value` is either a `buffer`/`memoryview` or a `unicode` object` `sql_value` is either a `memoryview` or a `unicode` object`
and the method must handle these in addition. and the method must handle these in addition.
""" """
if isinstance(sql_value, buffer): if isinstance(sql_value, memoryview):
sql_value = bytes(sql_value).decode('utf-8', 'ignore') sql_value = bytes(sql_value).decode('utf-8', 'ignore')
if isinstance(sql_value, six.text_type): if isinstance(sql_value, str):
return self.parse(sql_value) return self.parse(sql_value)
else: else:
return self.normalize(sql_value) return self.normalize(sql_value)
@@ -127,10 +121,18 @@ class Default(Type):
class Integer(Type): class Integer(Type):
"""A basic integer type. """A basic integer type.
""" """
sql = u'INTEGER' sql = 'INTEGER'
query = query.NumericQuery query = query.NumericQuery
model_type = int 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): class PaddedInt(Integer):
"""An integer field that is formatted with a given number of digits, """An integer field that is formatted with a given number of digits,
@@ -140,19 +142,25 @@ class PaddedInt(Integer):
self.digits = digits self.digits = digits
def format(self, value): def format(self, value):
return u'{0:0{1}d}'.format(value or 0, self.digits) 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
class ScaledInt(Integer): class ScaledInt(Integer):
"""An integer whose formatting operation scales the number by a """An integer whose formatting operation scales the number by a
constant and adds a suffix. Good for units with large magnitudes. constant and adds a suffix. Good for units with large magnitudes.
""" """
def __init__(self, unit, suffix=u''): def __init__(self, unit, suffix=''):
self.unit = unit self.unit = unit
self.suffix = suffix self.suffix = suffix
def format(self, value): def format(self, value):
return u'{0}{1}'.format((value or 0) // self.unit, self.suffix) return '{}{}'.format((value or 0) // self.unit, self.suffix)
class Id(Integer): class Id(Integer):
@@ -163,18 +171,22 @@ class Id(Integer):
def __init__(self, primary=True): def __init__(self, primary=True):
if primary: if primary:
self.sql = u'INTEGER PRIMARY KEY' self.sql = 'INTEGER PRIMARY KEY'
class Float(Type): class Float(Type):
"""A basic floating-point type. """A basic floating-point type. The `digits` parameter specifies how
many decimal places to use in the human-readable representation.
""" """
sql = u'REAL' sql = 'REAL'
query = query.NumericQuery query = query.NumericQuery
model_type = float model_type = float
def __init__(self, digits=1):
self.digits = digits
def format(self, value): def format(self, value):
return u'{0:.1f}'.format(value or 0.0) return '{0:.{1}f}'.format(value or 0, self.digits)
class NullFloat(Float): class NullFloat(Float):
@@ -186,19 +198,25 @@ class NullFloat(Float):
class String(Type): class String(Type):
"""A Unicode string type. """A Unicode string type.
""" """
sql = u'TEXT' sql = 'TEXT'
query = query.SubstringQuery query = query.SubstringQuery
def normalize(self, value):
if value is None:
return self.null
else:
return self.model_type(value)
class Boolean(Type): class Boolean(Type):
"""A boolean type. """A boolean type.
""" """
sql = u'INTEGER' sql = 'INTEGER'
query = query.BooleanQuery query = query.BooleanQuery
model_type = bool model_type = bool
def format(self, value): def format(self, value):
return six.text_type(bool(value)) return str(bool(value))
def parse(self, string): def parse(self, string):
return str2bool(string) return str2bool(string)
Executable → Regular
+267 -112
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -13,7 +12,6 @@
# The above copyright notice and this permission notice shall be # The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software. # 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 """Provides the basic, interface-agnostic workflow for importing and
autotagging music files. autotagging music files.
@@ -37,10 +35,10 @@ from beets import dbcore
from beets import plugins from beets import plugins
from beets import util from beets import util
from beets import config from beets import config
from beets.util import pipeline, sorted_walk, ancestry from beets.util import pipeline, sorted_walk, ancestry, MoveOperation
from beets.util import syspath, normpath, displayable_path from beets.util import syspath, normpath, displayable_path
from enum import Enum from enum import Enum
from beets import mediafile import mediafile
action = Enum('action', action = Enum('action',
['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG']) ['SKIP', 'ASIS', 'TRACKS', 'APPLY', 'ALBUMS', 'RETAG'])
@@ -75,7 +73,7 @@ def _open_state():
# unpickling, including ImportError. We use a catch-all # unpickling, including ImportError. We use a catch-all
# exception to avoid enumerating them all (the docs don't even have a # exception to avoid enumerating them all (the docs don't even have a
# full list!). # full list!).
log.debug(u'state file could not be read: {0}', exc) log.debug('state file could not be read: {0}', exc)
return {} return {}
@@ -84,8 +82,8 @@ def _save_state(state):
try: try:
with open(config['statefile'].as_filename(), 'wb') as f: with open(config['statefile'].as_filename(), 'wb') as f:
pickle.dump(state, f) pickle.dump(state, f)
except IOError as exc: except OSError as exc:
log.error(u'state file could not be written: {0}', exc) log.error('state file could not be written: {0}', exc)
# Utilities for reading and writing the beets progress file, which # Utilities for reading and writing the beets progress file, which
@@ -174,10 +172,11 @@ def history_get():
# Abstract session class. # Abstract session class.
class ImportSession(object): class ImportSession:
"""Controls an import action. Subclasses should implement methods to """Controls an import action. Subclasses should implement methods to
communicate with the user or otherwise make decisions. communicate with the user or otherwise make decisions.
""" """
def __init__(self, lib, loghandler, paths, query): def __init__(self, lib, loghandler, paths, query):
"""Create a session. `lib` is a Library object. `loghandler` is a """Create a session. `lib` is a Library object. `loghandler` is a
logging.Handler. Either `paths` or `query` is non-null and indicates logging.Handler. Either `paths` or `query` is non-null and indicates
@@ -187,7 +186,9 @@ class ImportSession(object):
self.logger = self._setup_logging(loghandler) self.logger = self._setup_logging(loghandler)
self.paths = paths self.paths = paths
self.query = query self.query = query
self._is_resuming = dict() self._is_resuming = {}
self._merged_items = set()
self._merged_dirs = set()
# Normalize the paths. # Normalize the paths.
if self.paths: if self.paths:
@@ -220,19 +221,31 @@ class ImportSession(object):
iconfig['resume'] = False iconfig['resume'] = False
iconfig['incremental'] = False iconfig['incremental'] = False
# Copy, move, link, and hardlink are mutually exclusive. if iconfig['reflink']:
iconfig['reflink'] = iconfig['reflink'] \
.as_choice(['auto', True, False])
# Copy, move, reflink, link, and hardlink are mutually exclusive.
if iconfig['move']: if iconfig['move']:
iconfig['copy'] = False iconfig['copy'] = False
iconfig['link'] = False iconfig['link'] = False
iconfig['hardlink'] = False iconfig['hardlink'] = False
iconfig['reflink'] = False
elif iconfig['link']: elif iconfig['link']:
iconfig['copy'] = False iconfig['copy'] = False
iconfig['move'] = False iconfig['move'] = False
iconfig['hardlink'] = False iconfig['hardlink'] = False
iconfig['reflink'] = False
elif iconfig['hardlink']: elif iconfig['hardlink']:
iconfig['copy'] = False iconfig['copy'] = False
iconfig['move'] = False iconfig['move'] = False
iconfig['link'] = 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. # Only delete when copying.
if not iconfig['copy']: if not iconfig['copy']:
@@ -244,7 +257,7 @@ class ImportSession(object):
"""Log a message about a given album to the importer log. The status """Log a message about a given album to the importer log. The status
should reflect the reason the album couldn't be tagged. should reflect the reason the album couldn't be tagged.
""" """
self.logger.info(u'{0} {1}', status, displayable_path(paths)) self.logger.info('{0} {1}', status, displayable_path(paths))
def log_choice(self, task, duplicate=False): def log_choice(self, task, duplicate=False):
"""Logs the task's current choice if it should be logged. If """Logs the task's current choice if it should be logged. If
@@ -255,17 +268,17 @@ class ImportSession(object):
if duplicate: if duplicate:
# Duplicate: log all three choices (skip, keep both, and trump). # Duplicate: log all three choices (skip, keep both, and trump).
if task.should_remove_duplicates: if task.should_remove_duplicates:
self.tag_log(u'duplicate-replace', paths) self.tag_log('duplicate-replace', paths)
elif task.choice_flag in (action.ASIS, action.APPLY): elif task.choice_flag in (action.ASIS, action.APPLY):
self.tag_log(u'duplicate-keep', paths) self.tag_log('duplicate-keep', paths)
elif task.choice_flag is (action.SKIP): elif task.choice_flag is (action.SKIP):
self.tag_log(u'duplicate-skip', paths) self.tag_log('duplicate-skip', paths)
else: else:
# Non-duplicate: log "skip" and "asis" choices. # Non-duplicate: log "skip" and "asis" choices.
if task.choice_flag is action.ASIS: if task.choice_flag is action.ASIS:
self.tag_log(u'asis', paths) self.tag_log('asis', paths)
elif task.choice_flag is action.SKIP: elif task.choice_flag is action.SKIP:
self.tag_log(u'skip', paths) self.tag_log('skip', paths)
def should_resume(self, path): def should_resume(self, path):
raise NotImplementedError raise NotImplementedError
@@ -282,7 +295,7 @@ class ImportSession(object):
def run(self): def run(self):
"""Run the import task. """Run the import task.
""" """
self.logger.info(u'import started {0}', time.asctime()) self.logger.info('import started {0}', time.asctime())
self.set_config(config['import']) self.set_config(config['import'])
# Set up the pipeline. # Set up the pipeline.
@@ -311,6 +324,8 @@ class ImportSession(object):
stages += [import_asis(self)] stages += [import_asis(self)]
# Plugin stages. # Plugin stages.
for stage_func in plugins.early_import_stages():
stages.append(plugin_stage(self, stage_func))
for stage_func in plugins.import_stages(): for stage_func in plugins.import_stages():
stages.append(plugin_stage(self, stage_func)) stages.append(plugin_stage(self, stage_func))
@@ -350,6 +365,24 @@ class ImportSession(object):
self._history_dirs = history_get() self._history_dirs = history_get()
return self._history_dirs 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): def is_resuming(self, toppath):
"""Return `True` if user wants to resume import of this path. """Return `True` if user wants to resume import of this path.
@@ -367,7 +400,7 @@ class ImportSession(object):
# Either accept immediately or prompt for input to decide. # Either accept immediately or prompt for input to decide.
if self.want_resume is True or \ if self.want_resume is True or \
self.should_resume(toppath): self.should_resume(toppath):
log.warning(u'Resuming interrupted import of {0}', log.warning('Resuming interrupted import of {0}',
util.displayable_path(toppath)) util.displayable_path(toppath))
self._is_resuming[toppath] = True self._is_resuming[toppath] = True
else: else:
@@ -377,11 +410,12 @@ class ImportSession(object):
# The importer task class. # The importer task class.
class BaseImportTask(object): class BaseImportTask:
"""An abstract base class for importer tasks. """An abstract base class for importer tasks.
Tasks flow through the importer pipeline. Each stage can update Tasks flow through the importer pipeline. Each stage can update
them. """ them. """
def __init__(self, toppath, paths, items): def __init__(self, toppath, paths, items):
"""Create a task. The primary fields that define a task are: """Create a task. The primary fields that define a task are:
@@ -419,7 +453,7 @@ class ImportTask(BaseImportTask):
from the `candidates` list. from the `candidates` list.
* `find_duplicates()` Returns a list of albums from `lib` with the * `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 * `apply_metadata()` Sets the attributes of the items from the
task's `match` attribute. task's `match` attribute.
@@ -429,17 +463,22 @@ class ImportTask(BaseImportTask):
* `manipulate_files()` Copy, move, and write files depending on the * `manipulate_files()` Copy, move, and write files depending on the
session configuration. 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 * `finalize()` Update the import progress and cleanup the file
system. system.
""" """
def __init__(self, toppath, paths, items): def __init__(self, toppath, paths, items):
super(ImportTask, self).__init__(toppath, paths, items) super().__init__(toppath, paths, items)
self.choice_flag = None self.choice_flag = None
self.cur_album = None self.cur_album = None
self.cur_artist = None self.cur_artist = None
self.candidates = [] self.candidates = []
self.rec = None self.rec = None
self.should_remove_duplicates = False self.should_remove_duplicates = False
self.should_merge_duplicates = False
self.is_album = True self.is_album = True
self.search_ids = [] # user-supplied candidate IDs. self.search_ids = [] # user-supplied candidate IDs.
@@ -510,6 +549,10 @@ class ImportTask(BaseImportTask):
def apply_metadata(self): def apply_metadata(self):
"""Copy metadata from match info to the items. """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) autotag.apply_metadata(self.match.info, self.match.mapping)
def duplicate_items(self, lib): def duplicate_items(self, lib):
@@ -520,23 +563,45 @@ class ImportTask(BaseImportTask):
def remove_duplicates(self, lib): def remove_duplicates(self, lib):
duplicate_items = self.duplicate_items(lib) duplicate_items = self.duplicate_items(lib)
log.debug(u'removing {0} old duplicated items', len(duplicate_items)) log.debug('removing {0} old duplicated items', len(duplicate_items))
for item in duplicate_items: for item in duplicate_items:
item.remove() item.remove()
if lib.directory in util.ancestry(item.path): if lib.directory in util.ancestry(item.path):
log.debug(u'deleting duplicate {0}', log.debug('deleting duplicate {0}',
util.displayable_path(item.path)) util.displayable_path(item.path))
util.remove(item.path) util.remove(item.path)
util.prune_dirs(os.path.dirname(item.path), util.prune_dirs(os.path.dirname(item.path),
lib.directory) 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): def finalize(self, session):
"""Save progress, clean up files, and emit plugin event. """Save progress, clean up files, and emit plugin event.
""" """
# Update progress. # Update progress.
if session.want_resume: if session.want_resume:
self.save_progress() self.save_progress()
if session.config['incremental']: if session.config['incremental'] and not (
# Should we skip recording to incremental list?
self.skip and session.config['incremental_skip_later']
):
self.save_history() self.save_history()
self.cleanup(copy=session.config['copy'], self.cleanup(copy=session.config['copy'],
@@ -609,17 +674,18 @@ class ImportTask(BaseImportTask):
return [] return []
duplicates = [] duplicates = []
task_paths = set(i.path for i in self.items if i) task_paths = {i.path for i in self.items if i}
duplicate_query = dbcore.AndQuery(( duplicate_query = dbcore.AndQuery((
dbcore.MatchQuery('albumartist', artist), dbcore.MatchQuery('albumartist', artist),
dbcore.MatchQuery('album', album), dbcore.MatchQuery('album', album),
)) ))
for album in lib.albums(duplicate_query): for album in lib.albums(duplicate_query):
# Check whether the album is identical in contents, in which # Check whether the album paths are all present in the task
# case it is not a duplicate (will be replaced). # i.e. album is being completely re-imported by the task,
album_paths = set(i.path for i in album.items()) # in which case it is not a duplicate (will be replaced).
if album_paths != task_paths: album_paths = {i.path for i in album.items()}
if not (album_paths <= task_paths):
duplicates.append(album) duplicates.append(album)
return duplicates return duplicates
@@ -659,20 +725,28 @@ class ImportTask(BaseImportTask):
for item in self.items: for item in self.items:
item.update(changes) item.update(changes)
def manipulate_files(self, move=False, copy=False, write=False, def manipulate_files(self, operation=None, write=False, session=None):
link=False, hardlink=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.
"""
items = self.imported_items() items = self.imported_items()
# Save the original paths of all items for deletion and pruning # Save the original paths of all items for deletion and pruning
# in the next step (finalization). # in the next step (finalization).
self.old_paths = [item.path for item in items] self.old_paths = [item.path for item in items]
for item in items: for item in items:
if move or copy or link or hardlink: if operation is not None:
# In copy and link modes, treat re-imports specially: # In copy and link modes, treat re-imports specially:
# move in-library files. (Out-of-library files are # move in-library files. (Out-of-library files are
# copied/moved as usual). # copied/moved as usual).
old_path = item.path old_path = item.path
if (copy or link or hardlink) and self.replaced_items[item] \ if (operation != MoveOperation.MOVE
and session.lib.directory in util.ancestry(old_path): and self.replaced_items[item]
and session.lib.directory in util.ancestry(old_path)):
item.move() item.move()
# We moved the item, so remove the # We moved the item, so remove the
# now-nonexistent file from old_paths. # now-nonexistent file from old_paths.
@@ -680,7 +754,7 @@ class ImportTask(BaseImportTask):
else: else:
# A normal import. Just copy files and keep track of # A normal import. Just copy files and keep track of
# old paths. # old paths.
item.move(copy, link, hardlink) item.move(operation)
if write and (self.apply or self.choice_flag == action.RETAG): if write and (self.apply or self.choice_flag == action.RETAG):
item.try_write() item.try_write()
@@ -699,6 +773,8 @@ class ImportTask(BaseImportTask):
self.record_replaced(lib) self.record_replaced(lib)
self.remove_replaced(lib) self.remove_replaced(lib)
self.album = lib.add_album(self.imported_items()) 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) self.reimport_metadata(lib)
def record_replaced(self, lib): def record_replaced(self, lib):
@@ -717,7 +793,7 @@ class ImportTask(BaseImportTask):
if (not dup_item.album_id or if (not dup_item.album_id or
dup_item.album_id in replaced_album_ids): dup_item.album_id in replaced_album_ids):
continue continue
replaced_album = dup_item.get_album() replaced_album = dup_item._cached_album
if replaced_album: if replaced_album:
replaced_album_ids.add(dup_item.album_id) replaced_album_ids.add(dup_item.album_id)
self.replaced_albums[replaced_album.path] = replaced_album self.replaced_albums[replaced_album.path] = replaced_album
@@ -734,8 +810,8 @@ class ImportTask(BaseImportTask):
self.album.artpath = replaced_album.artpath self.album.artpath = replaced_album.artpath
self.album.store() self.album.store()
log.debug( log.debug(
u'Reimported album: added {0}, flexible ' 'Reimported album: added {0}, flexible '
u'attributes {1} from album {2} for {3}', 'attributes {1} from album {2} for {3}',
self.album.added, self.album.added,
replaced_album._values_flex.keys(), replaced_album._values_flex.keys(),
replaced_album.id, replaced_album.id,
@@ -748,16 +824,16 @@ class ImportTask(BaseImportTask):
if dup_item.added and dup_item.added != item.added: if dup_item.added and dup_item.added != item.added:
item.added = dup_item.added item.added = dup_item.added
log.debug( log.debug(
u'Reimported item added {0} ' 'Reimported item added {0} '
u'from item {1} for {2}', 'from item {1} for {2}',
item.added, item.added,
dup_item.id, dup_item.id,
displayable_path(item.path) displayable_path(item.path)
) )
item.update(dup_item._values_flex) item.update(dup_item._values_flex)
log.debug( log.debug(
u'Reimported item flexible attributes {0} ' 'Reimported item flexible attributes {0} '
u'from item {1} for {2}', 'from item {1} for {2}',
dup_item._values_flex.keys(), dup_item._values_flex.keys(),
dup_item.id, dup_item.id,
displayable_path(item.path) displayable_path(item.path)
@@ -770,10 +846,10 @@ class ImportTask(BaseImportTask):
""" """
for item in self.imported_items(): for item in self.imported_items():
for dup_item in self.replaced_items[item]: for dup_item in self.replaced_items[item]:
log.debug(u'Replacing item {0}: {1}', log.debug('Replacing item {0}: {1}',
dup_item.id, displayable_path(item.path)) dup_item.id, displayable_path(item.path))
dup_item.remove() dup_item.remove()
log.debug(u'{0} of {1} items replaced', log.debug('{0} of {1} items replaced',
sum(bool(l) for l in self.replaced_items.values()), sum(bool(l) for l in self.replaced_items.values()),
len(self.imported_items())) len(self.imported_items()))
@@ -811,7 +887,7 @@ class SingletonImportTask(ImportTask):
""" """
def __init__(self, toppath, item): def __init__(self, toppath, item):
super(SingletonImportTask, self).__init__(toppath, [item.path], [item]) super().__init__(toppath, [item.path], [item])
self.item = item self.item = item
self.is_album = False self.is_album = False
self.paths = [item.path] self.paths = [item.path]
@@ -877,6 +953,19 @@ class SingletonImportTask(ImportTask):
def reload(self): def reload(self):
self.item.load() 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 # FIXME The inheritance relationships are inverted. This is why there
# are so many methods which pass. More responsibility should be delegated to # are so many methods which pass. More responsibility should be delegated to
@@ -891,7 +980,7 @@ class SentinelImportTask(ImportTask):
""" """
def __init__(self, toppath, paths): def __init__(self, toppath, paths):
super(SentinelImportTask, self).__init__(toppath, paths, ()) super().__init__(toppath, paths, ())
# TODO Remove the remaining attributes eventually # TODO Remove the remaining attributes eventually
self.should_remove_duplicates = False self.should_remove_duplicates = False
self.is_album = True self.is_album = True
@@ -935,7 +1024,7 @@ class ArchiveImportTask(SentinelImportTask):
""" """
def __init__(self, toppath): def __init__(self, toppath):
super(ArchiveImportTask, self).__init__(toppath, ()) super().__init__(toppath, ())
self.extracted = False self.extracted = False
@classmethod @classmethod
@@ -964,14 +1053,20 @@ class ArchiveImportTask(SentinelImportTask):
cls._handlers = [] cls._handlers = []
from zipfile import is_zipfile, ZipFile from zipfile import is_zipfile, ZipFile
cls._handlers.append((is_zipfile, ZipFile)) cls._handlers.append((is_zipfile, ZipFile))
from tarfile import is_tarfile, TarFile import tarfile
cls._handlers.append((is_tarfile, TarFile)) cls._handlers.append((tarfile.is_tarfile, tarfile.open))
try: try:
from rarfile import is_rarfile, RarFile from rarfile import is_rarfile, RarFile
except ImportError: except ImportError:
pass pass
else: else:
cls._handlers.append((is_rarfile, RarFile)) 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 return cls._handlers
@@ -979,7 +1074,7 @@ class ArchiveImportTask(SentinelImportTask):
"""Removes the temporary directory the archive was extracted to. """Removes the temporary directory the archive was extracted to.
""" """
if self.extracted: if self.extracted:
log.debug(u'Removing extracted directory: {0}', log.debug('Removing extracted directory: {0}',
displayable_path(self.toppath)) displayable_path(self.toppath))
shutil.rmtree(self.toppath) shutil.rmtree(self.toppath)
@@ -991,9 +1086,9 @@ class ArchiveImportTask(SentinelImportTask):
if path_test(util.py3_path(self.toppath)): if path_test(util.py3_path(self.toppath)):
break break
extract_to = mkdtemp()
archive = handler_class(util.py3_path(self.toppath), mode='r')
try: try:
extract_to = mkdtemp()
archive = handler_class(util.py3_path(self.toppath), mode='r')
archive.extractall(extract_to) archive.extractall(extract_to)
finally: finally:
archive.close() archive.close()
@@ -1001,10 +1096,11 @@ class ArchiveImportTask(SentinelImportTask):
self.toppath = extract_to self.toppath = extract_to
class ImportTaskFactory(object): class ImportTaskFactory:
"""Generate album and singleton import tasks for all media files """Generate album and singleton import tasks for all media files
indicated by a path. indicated by a path.
""" """
def __init__(self, toppath, session): def __init__(self, toppath, session):
"""Create a new task factory. """Create a new task factory.
@@ -1042,14 +1138,12 @@ class ImportTaskFactory(object):
if self.session.config['singletons']: if self.session.config['singletons']:
for path in paths: for path in paths:
tasks = self._create(self.singleton(path)) tasks = self._create(self.singleton(path))
for task in tasks: yield from tasks
yield task
yield self.sentinel(dirs) yield self.sentinel(dirs)
else: else:
tasks = self._create(self.album(paths, dirs)) tasks = self._create(self.album(paths, dirs))
for task in tasks: yield from tasks
yield task
# Produce the final sentinel for this toppath to indicate that # Produce the final sentinel for this toppath to indicate that
# it is finished. This is usually just a SentinelImportTask, but # it is finished. This is usually just a SentinelImportTask, but
@@ -1097,7 +1191,7 @@ class ImportTaskFactory(object):
"""Return a `SingletonImportTask` for the music file. """Return a `SingletonImportTask` for the music file.
""" """
if self.session.already_imported(self.toppath, [path]): if self.session.already_imported(self.toppath, [path]):
log.debug(u'Skipping previously-imported path: {0}', log.debug('Skipping previously-imported path: {0}',
displayable_path(path)) displayable_path(path))
self.skipped += 1 self.skipped += 1
return None return None
@@ -1118,10 +1212,10 @@ class ImportTaskFactory(object):
return None return None
if dirs is None: if dirs is None:
dirs = list(set(os.path.dirname(p) for p in paths)) dirs = list({os.path.dirname(p) for p in paths})
if self.session.already_imported(self.toppath, dirs): if self.session.already_imported(self.toppath, dirs):
log.debug(u'Skipping previously-imported path: {0}', log.debug('Skipping previously-imported path: {0}',
displayable_path(dirs)) displayable_path(dirs))
self.skipped += 1 self.skipped += 1
return None return None
@@ -1151,22 +1245,22 @@ class ImportTaskFactory(object):
if not (self.session.config['move'] or if not (self.session.config['move'] or
self.session.config['copy']): self.session.config['copy']):
log.warning(u"Archive importing requires either " log.warning("Archive importing requires either "
u"'copy' or 'move' to be enabled.") "'copy' or 'move' to be enabled.")
return return
log.debug(u'Extracting archive: {0}', log.debug('Extracting archive: {0}',
displayable_path(self.toppath)) displayable_path(self.toppath))
archive_task = ArchiveImportTask(self.toppath) archive_task = ArchiveImportTask(self.toppath)
try: try:
archive_task.extract() archive_task.extract()
except Exception as exc: except Exception as exc:
log.error(u'extraction failed: {0}', exc) log.error('extraction failed: {0}', exc)
return return
# Now read albums from the extracted directory. # Now read albums from the extracted directory.
self.toppath = archive_task.toppath self.toppath = archive_task.toppath
log.debug(u'Archive extracted to: {0}', self.toppath) log.debug('Archive extracted to: {0}', self.toppath)
return archive_task return archive_task
def read_item(self, path): def read_item(self, path):
@@ -1182,12 +1276,33 @@ class ImportTaskFactory(object):
# Silently ignore non-music files. # Silently ignore non-music files.
pass pass
elif isinstance(exc.reason, mediafile.UnreadableFileError): elif isinstance(exc.reason, mediafile.UnreadableFileError):
log.warning(u'unreadable file: {0}', displayable_path(path)) log.warning('unreadable file: {0}', displayable_path(path))
else: else:
log.error(u'error reading {0}: {1}', log.error('error reading {0}: {1}',
displayable_path(path), exc) 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. # Full-album pipeline stages.
def read_tasks(session): def read_tasks(session):
@@ -1202,17 +1317,16 @@ def read_tasks(session):
# Generate tasks. # Generate tasks.
task_factory = ImportTaskFactory(toppath, session) task_factory = ImportTaskFactory(toppath, session)
for t in task_factory.tasks(): yield from task_factory.tasks()
yield t
skipped += task_factory.skipped skipped += task_factory.skipped
if not task_factory.imported: if not task_factory.imported:
log.warning(u'No files imported from {0}', log.warning('No files imported from {0}',
displayable_path(toppath)) displayable_path(toppath))
# Show skipped directories (due to incremental/resume). # Show skipped directories (due to incremental/resume).
if skipped: if skipped:
log.info(u'Skipped {0} paths.', skipped) log.info('Skipped {0} paths.', skipped)
def query_tasks(session): def query_tasks(session):
@@ -1230,15 +1344,10 @@ def query_tasks(session):
else: else:
# Search for albums. # Search for albums.
for album in session.lib.albums(session.query): for album in session.lib.albums(session.query):
log.debug(u'yielding album {0}: {1} - {2}', log.debug('yielding album {0}: {1} - {2}',
album.id, album.albumartist, album.album) album.id, album.albumartist, album.album)
items = list(album.items()) 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) task = ImportTask(None, [album.item_dir()], items)
for task in task.handle_created(session): for task in task.handle_created(session):
@@ -1258,7 +1367,7 @@ def lookup_candidates(session, task):
return return
plugins.send('import_task_start', session=session, task=task) plugins.send('import_task_start', session=session, task=task)
log.debug(u'Looking up: {0}', displayable_path(task.paths)) log.debug('Looking up: {0}', displayable_path(task.paths))
# Restrict the initial lookup to IDs specified by the user via the -m # Restrict the initial lookup to IDs specified by the user via the -m
# option. Currently all the IDs are passed onto the tasks directly. # option. Currently all the IDs are passed onto the tasks directly.
@@ -1284,6 +1393,9 @@ def user_query(session, task):
if task.skip: if task.skip:
return task return task
if session.already_merged(task.paths):
return pipeline.BUBBLE
# Ask the user for a choice. # Ask the user for a choice.
task.choose_match(session) task.choose_match(session)
plugins.send('import_task_choice', session=session, task=task) plugins.send('import_task_choice', session=session, task=task)
@@ -1294,28 +1406,41 @@ def user_query(session, task):
def emitter(task): def emitter(task):
for item in task.items: for item in task.items:
task = SingletonImportTask(task.toppath, item) task = SingletonImportTask(task.toppath, item)
for new_task in task.handle_created(session): yield from task.handle_created(session)
yield new_task
yield SentinelImportTask(task.toppath, task.paths) yield SentinelImportTask(task.toppath, task.paths)
ipl = pipeline.Pipeline([ return _extend_pipeline(emitter(task),
emitter(task), lookup_candidates(session),
lookup_candidates(session), user_query(session))
user_query(session),
])
return pipeline.multiple(ipl.pull())
# As albums: group items by albums and create task for each album # As albums: group items by albums and create task for each album
if task.choice_flag is action.ALBUMS: if task.choice_flag is action.ALBUMS:
ipl = pipeline.Pipeline([ return _extend_pipeline([task],
iter([task]), group_albums(session),
group_albums(session), lookup_candidates(session),
lookup_candidates(session), user_query(session))
user_query(session)
])
return pipeline.multiple(ipl.pull())
resolve_duplicates(session, task) 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) apply_choice(session, task)
return task return task
@@ -1327,28 +1452,32 @@ def resolve_duplicates(session, task):
if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG): if task.choice_flag in (action.ASIS, action.APPLY, action.RETAG):
found_duplicates = task.find_duplicates(session.lib) found_duplicates = task.find_duplicates(session.lib)
if found_duplicates: if found_duplicates:
log.debug(u'found duplicates: {}'.format( log.debug('found duplicates: {}'.format(
[o.id for o in found_duplicates] [o.id for o in found_duplicates]
)) ))
# Get the default action to follow from config. # Get the default action to follow from config.
duplicate_action = config['import']['duplicate_action'].as_choice({ duplicate_action = config['import']['duplicate_action'].as_choice({
u'skip': u's', 'skip': 's',
u'keep': u'k', 'keep': 'k',
u'remove': u'r', 'remove': 'r',
u'ask': u'a', 'merge': 'm',
'ask': 'a',
}) })
log.debug(u'default action for duplicates: {0}', duplicate_action) log.debug('default action for duplicates: {0}', duplicate_action)
if duplicate_action == u's': if duplicate_action == 's':
# Skip new. # Skip new.
task.set_choice(action.SKIP) task.set_choice(action.SKIP)
elif duplicate_action == u'k': elif duplicate_action == 'k':
# Keep both. Do nothing; leave the choice intact. # Keep both. Do nothing; leave the choice intact.
pass pass
elif duplicate_action == u'r': elif duplicate_action == 'r':
# Remove old. # Remove old.
task.should_remove_duplicates = True task.should_remove_duplicates = True
elif duplicate_action == 'm':
# Merge duplicates together
task.should_merge_duplicates = True
else: else:
# No default action set; ask the session. # No default action set; ask the session.
session.resolve_duplicate(task, found_duplicates) session.resolve_duplicate(task, found_duplicates)
@@ -1366,7 +1495,7 @@ def import_asis(session, task):
if task.skip: if task.skip:
return return
log.info(u'{}', displayable_path(task.paths)) log.info('{}', displayable_path(task.paths))
task.set_choice(action.ASIS) task.set_choice(action.ASIS)
apply_choice(session, task) apply_choice(session, task)
@@ -1385,6 +1514,14 @@ def apply_choice(session, task):
task.add(session.lib) 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 @pipeline.mutator_stage
def plugin_stage(session, func, task): def plugin_stage(session, func, task):
@@ -1413,12 +1550,22 @@ def manipulate_files(session, task):
if task.should_remove_duplicates: if task.should_remove_duplicates:
task.remove_duplicates(session.lib) 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( task.manipulate_files(
move=session.config['move'], operation,
copy=session.config['copy'],
write=session.config['write'], write=session.config['write'],
link=session.config['link'],
hardlink=session.config['hardlink'],
session=session, session=session,
) )
@@ -1431,11 +1578,11 @@ def log_files(session, task):
"""A coroutine (pipeline stage) to log each file to be imported. """A coroutine (pipeline stage) to log each file to be imported.
""" """
if isinstance(task, SingletonImportTask): if isinstance(task, SingletonImportTask):
log.info(u'Singleton: {0}', displayable_path(task.item['path'])) log.info('Singleton: {0}', displayable_path(task.item['path']))
elif task.items: elif task.items:
log.info(u'Album: {0}', displayable_path(task.paths[0])) log.info('Album: {0}', displayable_path(task.paths[0]))
for item in task.items: for item in task.items:
log.info(u' {0}', displayable_path(item['path'])) log.info(' {0}', displayable_path(item['path']))
def group_albums(session): def group_albums(session):
@@ -1469,6 +1616,14 @@ MULTIDISC_MARKERS = (br'dis[ck]', br'cd')
MULTIDISC_PAT_FMT = br'^(.*%s[\W_]*)\d' 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): def albums_in_dir(path):
"""Recursively searches the given directory and returns an iterable """Recursively searches the given directory and returns an iterable
of (paths, items) where paths is a list of directories and items is of (paths, items) where paths is a list of directories and items is
@@ -1488,7 +1643,7 @@ def albums_in_dir(path):
# and add the current directory. If so, just add the directory # and add the current directory. If so, just add the directory
# and move on to the next directory. If not, stop collapsing. # and move on to the next directory. If not, stop collapsing.
if collapse_paths: if collapse_paths:
if (not collapse_pat and collapse_paths[0] in ancestry(root)) or \ if (is_subdir_of_any_in_list(root, collapse_paths)) or \
(collapse_pat and (collapse_pat and
collapse_pat.match(os.path.basename(root))): collapse_pat.match(os.path.basename(root))):
# Still collapsing. # Still collapsing.
Executable → Regular
+456 -261
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
+7 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -21,13 +20,11 @@ that when getLogger(name) instantiates a logger that logger uses
{}-style formatting. {}-style formatting.
""" """
from __future__ import division, absolute_import, print_function
from copy import copy from copy import copy
from logging import * # noqa from logging import * # noqa
import subprocess import subprocess
import threading import threading
import six
def logsafe(val): def logsafe(val):
@@ -43,7 +40,7 @@ def logsafe(val):
example. example.
""" """
# Already Unicode. # Already Unicode.
if isinstance(val, six.text_type): if isinstance(val, str):
return val return val
# Bytestring: needs decoding. # Bytestring: needs decoding.
@@ -57,7 +54,7 @@ def logsafe(val):
# A "problem" object: needs a workaround. # A "problem" object: needs a workaround.
elif isinstance(val, subprocess.CalledProcessError): elif isinstance(val, subprocess.CalledProcessError):
try: try:
return six.text_type(val) return str(val)
except UnicodeDecodeError: except UnicodeDecodeError:
# An object with a broken __unicode__ formatter. Use __str__ # An object with a broken __unicode__ formatter. Use __str__
# instead. # instead.
@@ -74,7 +71,7 @@ class StrFormatLogger(Logger):
instead of %-style formatting. instead of %-style formatting.
""" """
class _LogMessage(object): class _LogMessage:
def __init__(self, msg, args, kwargs): def __init__(self, msg, args, kwargs):
self.msg = msg self.msg = msg
self.args = args self.args = args
@@ -82,22 +79,23 @@ class StrFormatLogger(Logger):
def __str__(self): def __str__(self):
args = [logsafe(a) for a in self.args] args = [logsafe(a) for a in self.args]
kwargs = dict((k, logsafe(v)) for (k, v) in self.kwargs.items()) kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
return self.msg.format(*args, **kwargs) return self.msg.format(*args, **kwargs)
def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs): def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs):
"""Log msg.format(*args, **kwargs)""" """Log msg.format(*args, **kwargs)"""
m = self._LogMessage(msg, args, kwargs) m = self._LogMessage(msg, args, kwargs)
return super(StrFormatLogger, self)._log(level, m, (), exc_info, extra) return super()._log(level, m, (), exc_info, extra)
class ThreadLocalLevelLogger(Logger): class ThreadLocalLevelLogger(Logger):
"""A version of `Logger` whose level is thread-local instead of shared. """A version of `Logger` whose level is thread-local instead of shared.
""" """
def __init__(self, name, level=NOTSET): def __init__(self, name, level=NOTSET):
self._thread_level = threading.local() self._thread_level = threading.local()
self.default_level = NOTSET self.default_level = NOTSET
super(ThreadLocalLevelLogger, self).__init__(name, level) super().__init__(name, level)
@property @property
def level(self): def level(self):
Executable → Regular
+8 -2047
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
+304 -42
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,19 +14,19 @@
"""Support for beets plugins.""" """Support for beets plugins."""
from __future__ import division, absolute_import, print_function
import inspect
import traceback import traceback
import re import re
import inspect
import abc
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
import beets import beets
from beets import logging from beets import logging
from beets import mediafile import mediafile
import six
PLUGIN_NAMESPACE = 'beetsplug' PLUGIN_NAMESPACE = 'beetsplug'
@@ -50,26 +49,28 @@ class PluginLogFilter(logging.Filter):
"""A logging filter that identifies the plugin that emitted a log """A logging filter that identifies the plugin that emitted a log
message. message.
""" """
def __init__(self, plugin): def __init__(self, plugin):
self.prefix = u'{0}: '.format(plugin.name) self.prefix = f'{plugin.name}: '
def filter(self, record): def filter(self, record):
if hasattr(record.msg, 'msg') and isinstance(record.msg.msg, if hasattr(record.msg, 'msg') and isinstance(record.msg.msg,
six.string_types): str):
# A _LogMessage from our hacked-up Logging replacement. # A _LogMessage from our hacked-up Logging replacement.
record.msg.msg = self.prefix + record.msg.msg record.msg.msg = self.prefix + record.msg.msg
elif isinstance(record.msg, six.string_types): elif isinstance(record.msg, str):
record.msg = self.prefix + record.msg record.msg = self.prefix + record.msg
return True return True
# Managing the plugins themselves. # Managing the plugins themselves.
class BeetsPlugin(object): class BeetsPlugin:
"""The base class for all beets plugins. Plugins provide """The base class for all beets plugins. Plugins provide
functionality by defining a subclass of BeetsPlugin and overriding functionality by defining a subclass of BeetsPlugin and overriding
the abstract methods defined here. the abstract methods defined here.
""" """
def __init__(self, name=None): def __init__(self, name=None):
"""Perform one-time plugin setup. """Perform one-time plugin setup.
""" """
@@ -81,6 +82,7 @@ class BeetsPlugin(object):
self.template_fields = {} self.template_fields = {}
if not self.album_template_fields: if not self.album_template_fields:
self.album_template_fields = {} self.album_template_fields = {}
self.early_import_stages = []
self.import_stages = [] self.import_stages = []
self._log = log.getChild(self.name) self._log = log.getChild(self.name)
@@ -94,6 +96,22 @@ class BeetsPlugin(object):
""" """
return () 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): def get_import_stages(self):
"""Return a list of functions that should be called as importer """Return a list of functions that should be called as importer
pipelines stages. pipelines stages.
@@ -102,8 +120,7 @@ class BeetsPlugin(object):
`self.import_stages`. Wrapping provides some bookkeeping for the `self.import_stages`. Wrapping provides some bookkeeping for the
plugin: specifically, the logging level is adjusted to WARNING. plugin: specifically, the logging level is adjusted to WARNING.
""" """
return [self._set_log_level_and_params(logging.WARNING, import_stage) return self._set_stage_log_level(self.import_stages)
for import_stage in self.import_stages]
def _set_log_level_and_params(self, base_log_level, func): def _set_log_level_and_params(self, base_log_level, func):
"""Wrap `func` to temporarily set this plugin's logger level to """Wrap `func` to temporarily set this plugin's logger level to
@@ -111,27 +128,24 @@ class BeetsPlugin(object):
value after the function returns). Also determines which params may not value after the function returns). Also determines which params may not
be sent for backwards-compatibility. be sent for backwards-compatibility.
""" """
argspec = inspect.getargspec(func) argspec = inspect.getfullargspec(func)
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
assert self._log.level == logging.NOTSET assert self._log.level == logging.NOTSET
verbosity = beets.config['verbose'].get(int) verbosity = beets.config['verbose'].get(int)
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity) log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
self._log.setLevel(log_level) 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: try:
try: return func(*args, **kwargs)
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: finally:
self._log.setLevel(logging.NOTSET) self._log.setLevel(logging.NOTSET)
return wrapper return wrapper
def queries(self): def queries(self):
@@ -151,7 +165,7 @@ class BeetsPlugin(object):
""" """
return beets.autotag.hooks.Distance() return beets.autotag.hooks.Distance()
def candidates(self, items, artist, album, va_likely): def candidates(self, items, artist, album, va_likely, extra_tags=None):
"""Should return a sequence of AlbumInfo objects that match the """Should return a sequence of AlbumInfo objects that match the
album whose items are provided. album whose items are provided.
""" """
@@ -185,7 +199,7 @@ class BeetsPlugin(object):
``descriptor`` must be an instance of ``mediafile.MediaField``. ``descriptor`` must be an instance of ``mediafile.MediaField``.
""" """
# Defer impor to prevent circular dependency # Defer import to prevent circular dependency
from beets import library from beets import library
mediafile.MediaFile.add_field(name, descriptor) mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name) library.Item._media_fields.add(name)
@@ -248,14 +262,14 @@ def load_plugins(names=()):
BeetsPlugin subclasses desired. BeetsPlugin subclasses desired.
""" """
for name in names: for name in names:
modname = '{0}.{1}'.format(PLUGIN_NAMESPACE, name) modname = f'{PLUGIN_NAMESPACE}.{name}'
try: try:
try: try:
namespace = __import__(modname, None, None) namespace = __import__(modname, None, None)
except ImportError as exc: except ImportError as exc:
# Again, this is hacky: # Again, this is hacky:
if exc.args[0].endswith(' ' + name): if exc.args[0].endswith(' ' + name):
log.warning(u'** plugin {0} not found', name) log.warning('** plugin {0} not found', name)
else: else:
raise raise
else: else:
@@ -264,9 +278,9 @@ def load_plugins(names=()):
and obj != BeetsPlugin and obj not in _classes: and obj != BeetsPlugin and obj not in _classes:
_classes.add(obj) _classes.add(obj)
except: except Exception:
log.warning( log.warning(
u'** error loading plugin {}:\n{}', '** error loading plugin {}:\n{}',
name, name,
traceback.format_exc(), traceback.format_exc(),
) )
@@ -280,6 +294,11 @@ def find_plugins():
currently loaded beets plugins. Loads the default plugin set currently loaded beets plugins. Loads the default plugin set
first. 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() load_plugins()
plugins = [] plugins = []
for cls in _classes: for cls in _classes:
@@ -313,21 +332,31 @@ def queries():
def types(model_cls): def types(model_cls):
# Gives us `item_types` and `album_types` # Gives us `item_types` and `album_types`
attr_name = '{0}_types'.format(model_cls.__name__.lower()) attr_name = f'{model_cls.__name__.lower()}_types'
types = {} types = {}
for plugin in find_plugins(): for plugin in find_plugins():
plugin_types = getattr(plugin, attr_name, {}) plugin_types = getattr(plugin, attr_name, {})
for field in plugin_types: for field in plugin_types:
if field in types and plugin_types[field] != types[field]: if field in types and plugin_types[field] != types[field]:
raise PluginConflictException( raise PluginConflictException(
u'Plugin {0} defines flexible field {1} ' 'Plugin {} defines flexible field {} '
u'which has already been defined with ' 'which has already been defined with '
u'another type.'.format(plugin.name, field) 'another type.'.format(plugin.name, field)
) )
types.update(plugin_types) types.update(plugin_types)
return 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): def track_distance(item, info):
"""Gets the track distance calculated by all loaded plugins. """Gets the track distance calculated by all loaded plugins.
Returns a Distance object. Returns a Distance object.
@@ -348,20 +377,19 @@ def album_distance(items, album_info, mapping):
return dist return dist
def candidates(items, artist, album, va_likely): def candidates(items, artist, album, va_likely, extra_tags=None):
"""Gets MusicBrainz candidates for an album from each plugin. """Gets MusicBrainz candidates for an album from each plugin.
""" """
for plugin in find_plugins(): for plugin in find_plugins():
for candidate in plugin.candidates(items, artist, album, va_likely): yield from plugin.candidates(items, artist, album, va_likely,
yield candidate extra_tags)
def item_candidates(item, artist, title): def item_candidates(item, artist, title):
"""Gets MusicBrainz candidates for an item from the plugins. """Gets MusicBrainz candidates for an item from the plugins.
""" """
for plugin in find_plugins(): for plugin in find_plugins():
for item_candidate in plugin.item_candidates(item, artist, title): yield from plugin.item_candidates(item, artist, title)
yield item_candidate
def album_for_id(album_id): def album_for_id(album_id):
@@ -393,6 +421,14 @@ def template_funcs():
return 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(): def import_stages():
"""Get a list of import stage functions defined by plugins.""" """Get a list of import stage functions defined by plugins."""
stages = [] stages = []
@@ -446,7 +482,7 @@ def send(event, **arguments):
Return a list of non-None values returned from the handlers. Return a list of non-None values returned from the handlers.
""" """
log.debug(u'Sending event: {0}', event) log.debug('Sending event: {0}', event)
results = [] results = []
for handler in event_handlers()[event]: for handler in event_handlers()[event]:
result = handler(**arguments) result = handler(**arguments)
@@ -464,7 +500,7 @@ def feat_tokens(for_artist=True):
feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.'] feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.']
if for_artist: if for_artist:
feat_words += ['with', 'vs', 'and', 'con', '&'] feat_words += ['with', 'vs', 'and', 'con', '&']
return '(?<=\s)(?:{0})(?=\s)'.format( return r'(?<=\s)(?:{})(?=\s)'.format(
'|'.join(re.escape(x) for x in feat_words) '|'.join(re.escape(x) for x in feat_words)
) )
@@ -478,9 +514,50 @@ def sanitize_choices(choices, choices_all):
others = [x for x in choices_all if x not in choices] others = [x for x in choices_all if x not in choices]
res = [] res = []
for s in choices: for s in choices:
if s in list(choices_all) + ['*']: if s not in seen:
if not (s in seen or seen.add(s)): if s in list(choices_all):
res.extend(list(others) if s == '*' else [s]) 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)
return res return res
@@ -498,3 +575,188 @@ def notify_info_yielded(event):
yield v yield v
return decorated return decorated
return decorator 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
@@ -0,0 +1,113 @@
# 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)
Executable → Regular
+229 -157
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -18,7 +17,6 @@ interface. To invoke the CLI, just call beets.ui.main(). The actual
CLI commands are implemented in the ui.commands module. CLI commands are implemented in the ui.commands module.
""" """
from __future__ import division, absolute_import, print_function
import optparse import optparse
import textwrap import textwrap
@@ -30,18 +28,18 @@ import re
import struct import struct
import traceback import traceback
import os.path import os.path
from six.moves import input
from beets import logging from beets import logging
from beets import library from beets import library
from beets import plugins from beets import plugins
from beets import util from beets import util
from beets.util.functemplate import Template from beets.util.functemplate import template
from beets import config from beets import config
from beets.util import confit, as_string from beets.util import as_string
from beets.autotag import mb from beets.autotag import mb
from beets.dbcore import query as db_query from beets.dbcore import query as db_query
import six from beets.dbcore import db
import confuse
# On Windows platforms, use colorama to support "ANSI" terminal colors. # On Windows platforms, use colorama to support "ANSI" terminal colors.
if sys.platform == 'win32': if sys.platform == 'win32':
@@ -60,8 +58,8 @@ log.propagate = False # Don't propagate to root handler.
PF_KEY_QUERIES = { PF_KEY_QUERIES = {
'comp': u'comp:true', 'comp': 'comp:true',
'singleton': u'singleton:true', 'singleton': 'singleton:true',
} }
@@ -111,10 +109,7 @@ def decargs(arglist):
"""Given a list of command-line argument bytestrings, attempts to """Given a list of command-line argument bytestrings, attempts to
decode them to Unicode strings when running under Python 2. decode them to Unicode strings when running under Python 2.
""" """
if six.PY2: return arglist
return [s.decode(util.arg_encoding()) for s in arglist]
else:
return arglist
def print_(*strings, **kwargs): def print_(*strings, **kwargs):
@@ -129,29 +124,25 @@ def print_(*strings, **kwargs):
(it defaults to a newline). (it defaults to a newline).
""" """
if not strings: if not strings:
strings = [u''] strings = ['']
assert isinstance(strings[0], six.text_type) assert isinstance(strings[0], str)
txt = u' '.join(strings) txt = ' '.join(strings)
txt += kwargs.get('end', u'\n') txt += kwargs.get('end', '\n')
# Encode the string and write it to stdout. # Encode the string and write it to stdout.
if six.PY2: # On Python 3, sys.stdout expects text strings and uses the
# On Python 2, sys.stdout expects bytes. # 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') out = txt.encode(_out_encoding(), 'replace')
sys.stdout.write(out) sys.stdout.buffer.write(out)
sys.stdout.buffer.flush()
else: else:
# On Python 3, sys.stdout expects text strings and uses the # In our test harnesses (e.g., DummyOut), sys.stdout.buffer
# exception-throwing encoding error policy. To avoid throwing # does not exist. We instead just record the text string.
# errors and use our configurable encoding override, we use the sys.stdout.write(txt)
# 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. # Configuration wrappers.
@@ -201,19 +192,16 @@ def input_(prompt=None):
""" """
# raw_input incorrectly sends prompts to stderr, not stdout, so we # raw_input incorrectly sends prompts to stderr, not stdout, so we
# use print_() explicitly to display prompts. # use print_() explicitly to display prompts.
# http://bugs.python.org/issue1927 # https://bugs.python.org/issue1927
if prompt: if prompt:
print_(prompt, end=u' ') print_(prompt, end=' ')
try: try:
resp = input() resp = input()
except EOFError: except EOFError:
raise UserError(u'stdin stream ended while input required') raise UserError('stdin stream ended while input required')
if six.PY2: return resp
return resp.decode(_in_encoding(), 'ignore')
else:
return resp
def input_options(options, require=False, prompt=None, fallback_prompt=None, def input_options(options, require=False, prompt=None, fallback_prompt=None,
@@ -257,7 +245,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
found_letter = letter found_letter = letter
break break
else: else:
raise ValueError(u'no unambiguous lettering found') raise ValueError('no unambiguous lettering found')
letters[found_letter.lower()] = option letters[found_letter.lower()] = option
index = option.index(found_letter) index = option.index(found_letter)
@@ -265,7 +253,7 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
# Mark the option's shortcut letter for display. # Mark the option's shortcut letter for display.
if not require and ( if not require and (
(default is None and not numrange and first) or (default is None and not numrange and first) or
(isinstance(default, six.string_types) and (isinstance(default, str) and
found_letter.lower() == default.lower())): found_letter.lower() == default.lower())):
# The first option is the default; mark it. # The first option is the default; mark it.
show_letter = '[%s]' % found_letter.upper() show_letter = '[%s]' % found_letter.upper()
@@ -301,11 +289,11 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
prompt_part_lengths = [] prompt_part_lengths = []
if numrange: if numrange:
if isinstance(default, int): if isinstance(default, int):
default_name = six.text_type(default) default_name = str(default)
default_name = colorize('action_default', default_name) default_name = colorize('action_default', default_name)
tmpl = '# selection (default %s)' tmpl = '# selection (default %s)'
prompt_parts.append(tmpl % default_name) prompt_parts.append(tmpl % default_name)
prompt_part_lengths.append(len(tmpl % six.text_type(default))) prompt_part_lengths.append(len(tmpl % str(default)))
else: else:
prompt_parts.append('# selection') prompt_parts.append('# selection')
prompt_part_lengths.append(len(prompt_parts[-1])) prompt_part_lengths.append(len(prompt_parts[-1]))
@@ -340,9 +328,9 @@ def input_options(options, require=False, prompt=None, fallback_prompt=None,
# Make a fallback prompt too. This is displayed if the user enters # Make a fallback prompt too. This is displayed if the user enters
# something that is not recognized. # something that is not recognized.
if not fallback_prompt: if not fallback_prompt:
fallback_prompt = u'Enter one of ' fallback_prompt = 'Enter one of '
if numrange: if numrange:
fallback_prompt += u'%i-%i, ' % numrange fallback_prompt += '%i-%i, ' % numrange
fallback_prompt += ', '.join(display_letters) + ':' fallback_prompt += ', '.join(display_letters) + ':'
resp = input_(prompt) resp = input_(prompt)
@@ -381,34 +369,41 @@ def input_yn(prompt, require=False):
"yes" unless `require` is `True`, in which case there is no default. "yes" unless `require` is `True`, in which case there is no default.
""" """
sel = input_options( sel = input_options(
('y', 'n'), require, prompt, u'Enter Y or N:' ('y', 'n'), require, prompt, 'Enter Y or N:'
) )
return sel == u'y' return sel == 'y'
def input_select_objects(prompt, objs, rep): def input_select_objects(prompt, objs, rep, prompt_all=None):
"""Prompt to user to choose all, none, or some of the given objects. """Prompt to user to choose all, none, or some of the given objects.
Return the list of selected objects. Return the list of selected objects.
`prompt` is the prompt string to use for each question (it should be `prompt` is the prompt string to use for each question (it should be
phrased as an imperative verb). `rep` is a function to call on each phrased as an imperative verb). If `prompt_all` is given, it is used
object to print it out when confirming objects individually. 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.
""" """
choice = input_options( choice = input_options(
(u'y', u'n', u's'), False, ('y', 'n', 's'), False,
u'%s? (Yes/no/select)' % prompt) '%s? (Yes/no/select)' % (prompt_all or prompt))
print() # Blank line. print() # Blank line.
if choice == u'y': # Yes. if choice == 'y': # Yes.
return objs return objs
elif choice == u's': # Select. elif choice == 's': # Select.
out = [] out = []
for obj in objs: for obj in objs:
rep(obj) rep(obj)
if input_yn(u'%s? (yes/no)' % prompt, True): answer = input_options(
('y', 'n', 'q'), True, '%s? (yes/no/quit)' % prompt,
'Enter Y or N:'
)
if answer == 'y':
out.append(obj) out.append(obj)
print() # go to a new line elif answer == 'q':
return out
return out return out
else: # No. else: # No.
@@ -419,14 +414,14 @@ def input_select_objects(prompt, objs, rep):
def human_bytes(size): def human_bytes(size):
"""Formats size, a number of bytes, in a human-readable way.""" """Formats size, a number of bytes, in a human-readable way."""
powers = [u'', u'K', u'M', u'G', u'T', u'P', u'E', u'Z', u'Y', u'H'] powers = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'H']
unit = 'B' unit = 'B'
for power in powers: for power in powers:
if size < 1024: if size < 1024:
return u"%3.1f %s%s" % (size, power, unit) return f"{size:3.1f} {power}{unit}"
size /= 1024.0 size /= 1024.0
unit = u'iB' unit = 'iB'
return u"big" return "big"
def human_seconds(interval): def human_seconds(interval):
@@ -434,13 +429,13 @@ def human_seconds(interval):
interval using English words. interval using English words.
""" """
units = [ units = [
(1, u'second'), (1, 'second'),
(60, u'minute'), (60, 'minute'),
(60, u'hour'), (60, 'hour'),
(24, u'day'), (24, 'day'),
(7, u'week'), (7, 'week'),
(52, u'year'), (52, 'year'),
(10, u'decade'), (10, 'decade'),
] ]
for i in range(len(units) - 1): for i in range(len(units) - 1):
increment, suffix = units[i] increment, suffix = units[i]
@@ -453,7 +448,7 @@ def human_seconds(interval):
increment, suffix = units[-1] increment, suffix = units[-1]
interval /= float(increment) interval /= float(increment)
return u"%3.1f %ss" % (interval, suffix) return f"{interval:3.1f} {suffix}s"
def human_seconds_short(interval): def human_seconds_short(interval):
@@ -461,13 +456,13 @@ def human_seconds_short(interval):
string. string.
""" """
interval = int(interval) interval = int(interval)
return u'%i:%02i' % (interval // 60, interval % 60) return '%i:%02i' % (interval // 60, interval % 60)
# Colorization. # Colorization.
# ANSI terminal colorization code heavily inspired by pygments: # ANSI terminal colorization code heavily inspired by pygments:
# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py # https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
# (pygments is by Tim Hatch, Armin Ronacher, et al.) # (pygments is by Tim Hatch, Armin Ronacher, et al.)
COLOR_ESCAPE = "\x1b[" COLOR_ESCAPE = "\x1b["
DARK_COLORS = { DARK_COLORS = {
@@ -514,7 +509,7 @@ def _colorize(color, text):
elif color in LIGHT_COLORS: elif color in LIGHT_COLORS:
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30) escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS[color] + 30)
else: else:
raise ValueError(u'no such color %s', color) raise ValueError('no such color %s', color)
return escape + text + RESET_COLOR return escape + text + RESET_COLOR
@@ -522,22 +517,22 @@ def colorize(color_name, text):
"""Colorize text if colored output is enabled. (Like _colorize but """Colorize text if colored output is enabled. (Like _colorize but
conditional.) conditional.)
""" """
if config['ui']['color']: if not config['ui']['color'] or 'NO_COLOR' in os.environ.keys():
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 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', def _colordiff(a, b, highlight='text_highlight',
minor_highlight='text_highlight_minor'): minor_highlight='text_highlight_minor'):
@@ -546,11 +541,11 @@ def _colordiff(a, b, highlight='text_highlight',
highlighted intelligently to show differences; other values are highlighted intelligently to show differences; other values are
stringified and highlighted in their entirety. stringified and highlighted in their entirety.
""" """
if not isinstance(a, six.string_types) \ if not isinstance(a, str) \
or not isinstance(b, six.string_types): or not isinstance(b, str):
# Non-strings: use ordinary equality. # Non-strings: use ordinary equality.
a = six.text_type(a) a = str(a)
b = six.text_type(b) b = str(b)
if a == b: if a == b:
return a, b return a, b
else: else:
@@ -588,7 +583,7 @@ def _colordiff(a, b, highlight='text_highlight',
else: else:
assert(False) assert(False)
return u''.join(a_out), u''.join(b_out) return ''.join(a_out), ''.join(b_out)
def colordiff(a, b, highlight='text_highlight'): def colordiff(a, b, highlight='text_highlight'):
@@ -598,7 +593,7 @@ def colordiff(a, b, highlight='text_highlight'):
if config['ui']['color']: if config['ui']['color']:
return _colordiff(a, b, highlight) return _colordiff(a, b, highlight)
else: else:
return six.text_type(a), six.text_type(b) return str(a), str(b)
def get_path_formats(subview=None): def get_path_formats(subview=None):
@@ -609,12 +604,12 @@ def get_path_formats(subview=None):
subview = subview or config['paths'] subview = subview or config['paths']
for query, view in subview.items(): for query, view in subview.items():
query = PF_KEY_QUERIES.get(query, query) # Expand common queries. 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 return path_formats
def get_replacements(): def get_replacements():
"""Confit validation function that reads regex/string pairs. """Confuse validation function that reads regex/string pairs.
""" """
replacements = [] replacements = []
for pattern, repl in config['replace'].get(dict).items(): for pattern, repl in config['replace'].get(dict).items():
@@ -623,7 +618,7 @@ def get_replacements():
replacements.append((re.compile(pattern), repl)) replacements.append((re.compile(pattern), repl))
except re.error: except re.error:
raise UserError( raise UserError(
u'malformed regular expression in replace: {0}'.format( 'malformed regular expression in replace: {}'.format(
pattern pattern
) )
) )
@@ -644,7 +639,7 @@ def term_width():
try: try:
buf = fcntl.ioctl(0, termios.TIOCGWINSZ, ' ' * 4) buf = fcntl.ioctl(0, termios.TIOCGWINSZ, ' ' * 4)
except IOError: except OSError:
return fallback return fallback
try: try:
height, width = struct.unpack('hh', buf) height, width = struct.unpack('hh', buf)
@@ -656,10 +651,10 @@ def term_width():
FLOAT_EPSILON = 0.01 FLOAT_EPSILON = 0.01
def _field_diff(field, old, new): def _field_diff(field, old, old_fmt, new, new_fmt):
"""Given two Model objects, format their values for `field` and """Given two Model objects and their formatted views, format their values
highlight changes among them. Return a human-readable string. If the for `field` and highlight changes among them. Return a human-readable
value has not changed, return None instead. string. If the value has not changed, return None instead.
""" """
oldval = old.get(field) oldval = old.get(field)
newval = new.get(field) newval = new.get(field)
@@ -672,18 +667,18 @@ def _field_diff(field, old, new):
return None return None
# Get formatted values for output. # Get formatted values for output.
oldstr = old.formatted().get(field, u'') oldstr = old_fmt.get(field, '')
newstr = new.formatted().get(field, u'') newstr = new_fmt.get(field, '')
# For strings, highlight changes. For others, colorize the whole # For strings, highlight changes. For others, colorize the whole
# thing. # thing.
if isinstance(oldval, six.string_types): if isinstance(oldval, str):
oldstr, newstr = colordiff(oldval, newstr) oldstr, newstr = colordiff(oldval, newstr)
else: else:
oldstr = colorize('text_error', oldstr) oldstr = colorize('text_error', oldstr)
newstr = colorize('text_error', newstr) newstr = colorize('text_error', newstr)
return u'{0} -> {1}'.format(oldstr, newstr) return f'{oldstr} -> {newstr}'
def show_model_changes(new, old=None, fields=None, always=False): def show_model_changes(new, old=None, fields=None, always=False):
@@ -698,6 +693,11 @@ def show_model_changes(new, old=None, fields=None, always=False):
""" """
old = old or new._db._get(type(new), new.id) 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. # Build up lines showing changed fields.
changes = [] changes = []
for field in old: for field in old:
@@ -706,25 +706,25 @@ def show_model_changes(new, old=None, fields=None, always=False):
continue continue
# Detect and show difference for this field. # Detect and show difference for this field.
line = _field_diff(field, old, new) line = _field_diff(field, old, old_fmt, new, new_fmt)
if line: if line:
changes.append(u' {0}: {1}'.format(field, line)) changes.append(f' {field}: {line}')
# New fields. # New fields.
for field in set(new) - set(old): for field in set(new) - set(old):
if fields and field not in fields: if fields and field not in fields:
continue continue
changes.append(u' {0}: {1}'.format( changes.append(' {}: {}'.format(
field, field,
colorize('text_highlight', new.formatted()[field]) colorize('text_highlight', new_fmt[field])
)) ))
# Print changes. # Print changes.
if changes or always: if changes or always:
print_(format(old)) print_(format(old))
if changes: if changes:
print_(u'\n'.join(changes)) print_('\n'.join(changes))
return bool(changes) return bool(changes)
@@ -757,18 +757,55 @@ def show_path_changes(path_changes):
if max_width > col_width: if max_width > col_width:
# Print every change over two lines # Print every change over two lines
for source, dest in zip(sources, destinations): for source, dest in zip(sources, destinations):
log.info(u'{0} \n -> {1}', source, dest) color_source, color_dest = colordiff(source, dest)
print_('{0} \n -> {1}'.format(color_source, color_dest))
else: else:
# Print every change on a single line, and add a header # Print every change on a single line, and add a header
title_pad = max_width - len('Source ') + len(' -> ') title_pad = max_width - len('Source ') + len(' -> ')
log.info(u'Source {0} Destination', ' ' * title_pad) print_('Source {0} Destination'.format(' ' * title_pad))
for source, dest in zip(sources, destinations): for source, dest in zip(sources, destinations):
pad = max_width - len(source) pad = max_width - len(source)
log.info(u'{0} {1} -> {2}', source, ' ' * pad, dest) color_source, color_dest = colordiff(source, dest)
print_('{0} {1} -> {2}'.format(
color_source,
' ' * pad,
color_dest,
))
class CommonOptionsParser(optparse.OptionParser, object): # 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):
"""Offers a simple way to add common formatting options. """Offers a simple way to add common formatting options.
Options available include: Options available include:
@@ -783,8 +820,9 @@ class CommonOptionsParser(optparse.OptionParser, object):
Each method is fully documented in the related method. Each method is fully documented in the related method.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(CommonOptionsParser, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._album_flags = False self._album_flags = False
# this serves both as an indicator that we offer the feature AND allows # 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 # us to check whether it has been specified on the CLI - bypassing the
@@ -798,7 +836,7 @@ class CommonOptionsParser(optparse.OptionParser, object):
Sets the album property on the options extracted from the CLI. Sets the album property on the options extracted from the CLI.
""" """
album = optparse.Option(*flags, action='store_true', album = optparse.Option(*flags, action='store_true',
help=u'match albums instead of tracks') help='match albums instead of tracks')
self.add_option(album) self.add_option(album)
self._album_flags = set(flags) self._album_flags = set(flags)
@@ -816,7 +854,7 @@ class CommonOptionsParser(optparse.OptionParser, object):
elif value: elif value:
value, = decargs([value]) value, = decargs([value])
else: else:
value = u'' value = ''
parser.values.format = value parser.values.format = value
if target: if target:
@@ -843,14 +881,14 @@ class CommonOptionsParser(optparse.OptionParser, object):
By default this affects both items and albums. If add_album_option() By default this affects both items and albums. If add_album_option()
is used then the target will be autodetected. is used then the target will be autodetected.
Sets the format property to u'$path' on the options extracted from the Sets the format property to '$path' on the options extracted from the
CLI. CLI.
""" """
path = optparse.Option(*flags, nargs=0, action='callback', path = optparse.Option(*flags, nargs=0, action='callback',
callback=self._set_format, callback=self._set_format,
callback_kwargs={'fmt': u'$path', callback_kwargs={'fmt': '$path',
'store_true': True}, 'store_true': True},
help=u'print paths for matched items or albums') help='print paths for matched items or albums')
self.add_option(path) self.add_option(path)
def add_format_option(self, flags=('-f', '--format'), target=None): def add_format_option(self, flags=('-f', '--format'), target=None):
@@ -870,7 +908,7 @@ class CommonOptionsParser(optparse.OptionParser, object):
""" """
kwargs = {} kwargs = {}
if target: if target:
if isinstance(target, six.string_types): if isinstance(target, str):
target = {'item': library.Item, target = {'item': library.Item,
'album': library.Album}[target] 'album': library.Album}[target]
kwargs['target'] = target kwargs['target'] = target
@@ -878,7 +916,7 @@ class CommonOptionsParser(optparse.OptionParser, object):
opt = optparse.Option(*flags, action='callback', opt = optparse.Option(*flags, action='callback',
callback=self._set_format, callback=self._set_format,
callback_kwargs=kwargs, callback_kwargs=kwargs,
help=u'print with custom format') help='print with custom format')
self.add_option(opt) self.add_option(opt)
def add_all_common_options(self): def add_all_common_options(self):
@@ -893,14 +931,15 @@ class CommonOptionsParser(optparse.OptionParser, object):
# #
# This is a fairly generic subcommand parser for optparse. It is # This is a fairly generic subcommand parser for optparse. It is
# maintained externally here: # maintained externally here:
# http://gist.github.com/462717 # https://gist.github.com/462717
# There you will also find a better description of the code and a more # There you will also find a better description of the code and a more
# succinct example program. # succinct example program.
class Subcommand(object): class Subcommand:
"""A subcommand of a root command-line application that may be """A subcommand of a root command-line application that may be
invoked by a SubcommandOptionParser. invoked by a SubcommandOptionParser.
""" """
def __init__(self, name, parser=None, help='', aliases=(), hide=False): def __init__(self, name, parser=None, help='', aliases=(), hide=False):
"""Creates a new subcommand. name is the primary way to invoke """Creates a new subcommand. name is the primary way to invoke
the subcommand; aliases are alternate names. parser is an the subcommand; aliases are alternate names. parser is an
@@ -928,7 +967,7 @@ class Subcommand(object):
@root_parser.setter @root_parser.setter
def root_parser(self, root_parser): def root_parser(self, root_parser):
self._root_parser = root_parser self._root_parser = root_parser
self.parser.prog = '{0} {1}'.format( self.parser.prog = '{} {}'.format(
as_string(root_parser.get_prog_name()), self.name) as_string(root_parser.get_prog_name()), self.name)
@@ -944,13 +983,13 @@ class SubcommandsOptionParser(CommonOptionsParser):
""" """
# A more helpful default usage. # A more helpful default usage.
if 'usage' not in kwargs: if 'usage' not in kwargs:
kwargs['usage'] = u""" kwargs['usage'] = """
%prog COMMAND [ARGS...] %prog COMMAND [ARGS...]
%prog help COMMAND""" %prog help COMMAND"""
kwargs['add_help_option'] = False kwargs['add_help_option'] = False
# Super constructor. # Super constructor.
super(SubcommandsOptionParser, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Our root parser needs to stop on the first unrecognized argument. # Our root parser needs to stop on the first unrecognized argument.
self.disable_interspersed_args() self.disable_interspersed_args()
@@ -967,7 +1006,7 @@ class SubcommandsOptionParser(CommonOptionsParser):
# Add the list of subcommands to the help message. # Add the list of subcommands to the help message.
def format_help(self, formatter=None): def format_help(self, formatter=None):
# Get the original help message, to which we will append. # Get the original help message, to which we will append.
out = super(SubcommandsOptionParser, self).format_help(formatter) out = super().format_help(formatter)
if formatter is None: if formatter is None:
formatter = self.formatter formatter = self.formatter
@@ -1053,7 +1092,7 @@ class SubcommandsOptionParser(CommonOptionsParser):
cmdname = args.pop(0) cmdname = args.pop(0)
subcommand = self._subcommand_for_name(cmdname) subcommand = self._subcommand_for_name(cmdname)
if not subcommand: if not subcommand:
raise UserError(u"unknown command '{0}'".format(cmdname)) raise UserError(f"unknown command '{cmdname}'")
suboptions, subargs = subcommand.parse_args(args) suboptions, subargs = subcommand.parse_args(args)
return subcommand, suboptions, subargs return subcommand, suboptions, subargs
@@ -1064,26 +1103,32 @@ optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
# The main entry point and bootstrapping. # The main entry point and bootstrapping.
def _load_plugins(config): def _load_plugins(options, config):
"""Load the plugins specified in the configuration. """Load the plugins specified on the command line or in the configuration.
""" """
paths = config['pluginpath'].as_str_seq(split=False) paths = config['pluginpath'].as_str_seq(split=False)
paths = [util.normpath(p) for p in paths] paths = [util.normpath(p) for p in paths]
log.debug(u'plugin paths: {0}', util.displayable_path(paths)) log.debug('plugin paths: {0}', util.displayable_path(paths))
# On Python 3, the search paths need to be unicode. # On Python 3, the search paths need to be unicode.
paths = [util.py3_path(p) for p in paths] paths = [util.py3_path(p) for p in paths]
# Extend the `beetsplug` package to include the plugin paths. # Extend the `beetsplug` package to include the plugin paths.
import beetsplug import beetsplug
beetsplug.__path__ = paths + beetsplug.__path__ beetsplug.__path__ = paths + list(beetsplug.__path__)
# For backwards compatibility, also support plugin paths that # For backwards compatibility, also support plugin paths that
# *contain* a `beetsplug` package. # *contain* a `beetsplug` package.
sys.path += paths sys.path += paths
plugins.load_plugins(config['plugins'].as_str_seq()) # If we were given any plugins on the command line, use those.
plugins.send("pluginload") 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)
return plugins return plugins
@@ -1097,7 +1142,20 @@ def _setup(options, lib=None):
config = _configure(options) config = _configure(options)
plugins = _load_plugins(config) 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")
# Get the default subcommands. # Get the default subcommands.
from beets.ui.commands import default_commands from beets.ui.commands import default_commands
@@ -1108,8 +1166,6 @@ def _setup(options, lib=None):
if lib is None: if lib is None:
lib = _open_library(config) lib = _open_library(config)
plugins.send("library_opened", lib=lib) 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 return subcommands, plugins, lib
@@ -1121,9 +1177,11 @@ def _configure(options):
# special handling lets specified plugins get loaded before we # special handling lets specified plugins get loaded before we
# finish parsing the command line. # finish parsing the command line.
if getattr(options, 'config', None) is not None: if getattr(options, 'config', None) is not None:
config_path = options.config overlay_path = options.config
del options.config del options.config
config.set_file(config_path) config.set_file(overlay_path)
else:
overlay_path = None
config.set_args(options) config.set_args(options)
# Configure the logger. # Configure the logger.
@@ -1132,15 +1190,19 @@ def _configure(options):
else: else:
log.set_global_level(logging.INFO) 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() config_path = config.user_config_path()
if os.path.isfile(config_path): if os.path.isfile(config_path):
log.debug(u'user configuration: {0}', log.debug('user configuration: {0}',
util.displayable_path(config_path)) util.displayable_path(config_path))
else: else:
log.debug(u'no user configuration found at {0}', log.debug('no user configuration found at {0}',
util.displayable_path(config_path)) util.displayable_path(config_path))
log.debug(u'data directory: {0}', log.debug('data directory: {0}',
util.displayable_path(config.config_dir())) util.displayable_path(config.config_dir()))
return config return config
@@ -1157,13 +1219,14 @@ def _open_library(config):
get_replacements(), get_replacements(),
) )
lib.get_item(0) # Test database connection. lib.get_item(0) # Test database connection.
except (sqlite3.OperationalError, sqlite3.DatabaseError): except (sqlite3.OperationalError, sqlite3.DatabaseError) as db_error:
log.debug(u'{}', traceback.format_exc()) log.debug('{}', traceback.format_exc())
raise UserError(u"database file {0} could not be opened".format( raise UserError("database file {} cannot not be opened: {}".format(
util.displayable_path(dbpath) util.displayable_path(dbpath),
db_error
)) ))
log.debug(u'library database: {0}\n' log.debug('library database: {0}\n'
u'library directory: {1}', 'library directory: {1}',
util.displayable_path(lib.path), util.displayable_path(lib.path),
util.displayable_path(lib.directory)) util.displayable_path(lib.directory))
return lib return lib
@@ -1177,15 +1240,17 @@ def _raw_main(args, lib=None):
parser.add_format_option(flags=('--format-item',), target=library.Item) parser.add_format_option(flags=('--format-item',), target=library.Item)
parser.add_format_option(flags=('--format-album',), target=library.Album) parser.add_format_option(flags=('--format-album',), target=library.Album)
parser.add_option('-l', '--library', dest='library', parser.add_option('-l', '--library', dest='library',
help=u'library database file to use') help='library database file to use')
parser.add_option('-d', '--directory', dest='directory', parser.add_option('-d', '--directory', dest='directory',
help=u"destination music directory") help="destination music directory")
parser.add_option('-v', '--verbose', dest='verbose', action='count', parser.add_option('-v', '--verbose', dest='verbose', action='count',
help=u'log more details (use twice for even more)') help='log more details (use twice for even more)')
parser.add_option('-c', '--config', dest='config', parser.add_option('-c', '--config', dest='config',
help=u'path to configuration file') help='path to configuration file')
parser.add_option('-p', '--plugins', dest='plugins',
help='a comma-separated list of plugins to load')
parser.add_option('-h', '--help', dest='help', action='store_true', parser.add_option('-h', '--help', dest='help', action='store_true',
help=u'show this help message and exit') help='show this help message and exit')
parser.add_option('--version', dest='version', action='store_true', parser.add_option('--version', dest='version', action='store_true',
help=optparse.SUPPRESS_HELP) help=optparse.SUPPRESS_HELP)
@@ -1220,7 +1285,7 @@ def main(args=None):
_raw_main(args) _raw_main(args)
except UserError as exc: except UserError as exc:
message = exc.args[0] if exc.args else None message = exc.args[0] if exc.args else None
log.error(u'error: {0}', message) log.error('error: {0}', message)
sys.exit(1) sys.exit(1)
except util.HumanReadableException as exc: except util.HumanReadableException as exc:
exc.log(log) exc.log(log)
@@ -1231,18 +1296,25 @@ def main(args=None):
log.debug('{}', traceback.format_exc()) log.debug('{}', traceback.format_exc())
log.error('{}', exc) log.error('{}', exc)
sys.exit(1) sys.exit(1)
except confit.ConfigError as exc: except confuse.ConfigError as exc:
log.error(u'configuration error: {0}', exc) log.error('configuration error: {0}', exc)
sys.exit(1) sys.exit(1)
except db_query.InvalidQueryError as exc: except db_query.InvalidQueryError as exc:
log.error(u'invalid query: {0}', exc) log.error('invalid query: {0}', exc)
sys.exit(1) sys.exit(1)
except IOError as exc: except OSError as exc:
if exc.errno == errno.EPIPE: if exc.errno == errno.EPIPE:
# "Broken pipe". End silently. # "Broken pipe". End silently.
pass sys.stderr.close()
else: else:
raise raise
except KeyboardInterrupt: except KeyboardInterrupt:
# Silently ignore ^C except in verbose mode. # Silently ignore ^C except in verbose mode.
log.debug(u'{}', traceback.format_exc()) 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)
+437 -327
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
+5 -5
View File
@@ -70,7 +70,7 @@ _beet_dispatch() {
# Replace command shortcuts # Replace command shortcuts
if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then
eval "cmd=\$alias__$cmd" eval "cmd=\$alias__${cmd//-/_}"
fi fi
case $cmd in case $cmd in
@@ -94,8 +94,8 @@ _beet_dispatch() {
_beet_complete() { _beet_complete() {
if [[ $cur == -* ]]; then if [[ $cur == -* ]]; then
local opts flags completions local opts flags completions
eval "opts=\$opts__$cmd" eval "opts=\$opts__${cmd//-/_}"
eval "flags=\$flags__$cmd" eval "flags=\$flags__${cmd//-/_}"
completions="${flags___common} ${opts} ${flags}" completions="${flags___common} ${opts} ${flags}"
COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
else else
@@ -129,7 +129,7 @@ _beet_complete_global() {
COMPREPLY+=( $(compgen -W "$completions" -- $cur) ) COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then
local cmd local cmd
eval "cmd=\$alias__$cur" eval "cmd=\$alias__${cur//-/_}"
COMPREPLY+=( "$cmd" ) COMPREPLY+=( "$cmd" )
else else
COMPREPLY+=( $(compgen -W "$commands" -- $cur) ) COMPREPLY+=( $(compgen -W "$commands" -- $cur) )
@@ -138,7 +138,7 @@ _beet_complete_global() {
_beet_complete_query() { _beet_complete_query() {
local opts local opts
eval "opts=\$opts__$cmd" eval "opts=\$opts__${cmd//-/_}"
if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then
_beet_complete _beet_complete
Executable → Regular
+210 -100
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -15,27 +14,28 @@
"""Miscellaneous utility functions.""" """Miscellaneous utility functions."""
from __future__ import division, absolute_import, print_function
import os import os
import sys import sys
import errno import errno
import locale import locale
import re import re
import tempfile
import shutil import shutil
import fnmatch import fnmatch
from collections import Counter import functools
from collections import Counter, namedtuple
from multiprocessing.pool import ThreadPool
import traceback import traceback
import subprocess import subprocess
import platform import platform
import shlex import shlex
from beets.util import hidden from beets.util import hidden
import six
from unidecode import unidecode from unidecode import unidecode
from enum import Enum
MAX_FILENAME_LENGTH = 200 MAX_FILENAME_LENGTH = 200
WINDOWS_MAGIC_PREFIX = u'\\\\?\\' WINDOWS_MAGIC_PREFIX = '\\\\?\\'
SNI_SUPPORTED = sys.version_info >= (2, 7, 9)
class HumanReadableException(Exception): class HumanReadableException(Exception):
@@ -57,27 +57,27 @@ class HumanReadableException(Exception):
self.reason = reason self.reason = reason
self.verb = verb self.verb = verb
self.tb = tb self.tb = tb
super(HumanReadableException, self).__init__(self.get_message()) super().__init__(self.get_message())
def _gerund(self): def _gerund(self):
"""Generate a (likely) gerund form of the English verb. """Generate a (likely) gerund form of the English verb.
""" """
if u' ' in self.verb: if ' ' in self.verb:
return self.verb return self.verb
gerund = self.verb[:-1] if self.verb.endswith(u'e') else self.verb gerund = self.verb[:-1] if self.verb.endswith('e') else self.verb
gerund += u'ing' gerund += 'ing'
return gerund return gerund
def _reasonstr(self): def _reasonstr(self):
"""Get the reason as a string.""" """Get the reason as a string."""
if isinstance(self.reason, six.text_type): if isinstance(self.reason, str):
return self.reason return self.reason
elif isinstance(self.reason, bytes): elif isinstance(self.reason, bytes):
return self.reason.decode('utf-8', 'ignore') return self.reason.decode('utf-8', 'ignore')
elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError elif hasattr(self.reason, 'strerror'): # i.e., EnvironmentError
return self.reason.strerror return self.reason.strerror
else: else:
return u'"{0}"'.format(six.text_type(self.reason)) return '"{}"'.format(str(self.reason))
def get_message(self): def get_message(self):
"""Create the human-readable description of the error, sans """Create the human-readable description of the error, sans
@@ -91,7 +91,7 @@ class HumanReadableException(Exception):
""" """
if self.tb: if self.tb:
logger.debug(self.tb) logger.debug(self.tb)
logger.error(u'{0}: {1}', self.error_kind, self.args[0]) logger.error('{0}: {1}', self.error_kind, self.args[0])
class FilesystemError(HumanReadableException): class FilesystemError(HumanReadableException):
@@ -99,29 +99,41 @@ class FilesystemError(HumanReadableException):
via a function in this module. The `paths` field is a sequence of via a function in this module. The `paths` field is a sequence of
pathnames involved in the operation. pathnames involved in the operation.
""" """
def __init__(self, reason, verb, paths, tb=None): def __init__(self, reason, verb, paths, tb=None):
self.paths = paths self.paths = paths
super(FilesystemError, self).__init__(reason, verb, tb) super().__init__(reason, verb, tb)
def get_message(self): def get_message(self):
# Use a nicer English phrasing for some specific verbs. # Use a nicer English phrasing for some specific verbs.
if self.verb in ('move', 'copy', 'rename'): if self.verb in ('move', 'copy', 'rename'):
clause = u'while {0} {1} to {2}'.format( clause = 'while {} {} to {}'.format(
self._gerund(), self._gerund(),
displayable_path(self.paths[0]), displayable_path(self.paths[0]),
displayable_path(self.paths[1]) displayable_path(self.paths[1])
) )
elif self.verb in ('delete', 'write', 'create', 'read'): elif self.verb in ('delete', 'write', 'create', 'read'):
clause = u'while {0} {1}'.format( clause = 'while {} {}'.format(
self._gerund(), self._gerund(),
displayable_path(self.paths[0]) displayable_path(self.paths[0])
) )
else: else:
clause = u'during {0} of paths {1}'.format( clause = 'during {} of paths {}'.format(
self.verb, u', '.join(displayable_path(p) for p in self.paths) self.verb, ', '.join(displayable_path(p) for p in self.paths)
) )
return u'{0} {1}'.format(self._reasonstr(), clause) 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
def normpath(path): def normpath(path):
@@ -172,7 +184,7 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
contents = os.listdir(syspath(path)) contents = os.listdir(syspath(path))
except OSError as exc: except OSError as exc:
if logger: if logger:
logger.warning(u'could not list directory {0}: {1}'.format( logger.warning('could not list directory {}: {}'.format(
displayable_path(path), exc.strerror displayable_path(path), exc.strerror
)) ))
return return
@@ -185,6 +197,10 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
skip = False skip = False
for pat in ignore: for pat in ignore:
if fnmatch.fnmatch(base, pat): if fnmatch.fnmatch(base, pat):
if logger:
logger.debug('ignoring {} due to ignore rule {}'.format(
base, pat
))
skip = True skip = True
break break
if skip: if skip:
@@ -207,8 +223,14 @@ def sorted_walk(path, ignore=(), ignore_hidden=False, logger=None):
for base in dirs: for base in dirs:
cur = os.path.join(path, base) cur = os.path.join(path, base)
# yield from sorted_walk(...) # yield from sorted_walk(...)
for res in sorted_walk(cur, ignore, ignore_hidden, logger): yield from sorted_walk(cur, ignore, ignore_hidden, logger)
yield res
def path_as_posix(path):
"""Return the string representation of the path with forward (/)
slashes.
"""
return path.replace(b'\\', b'/')
def mkdirall(path): def mkdirall(path):
@@ -219,7 +241,7 @@ def mkdirall(path):
if not os.path.isdir(syspath(ancestor)): if not os.path.isdir(syspath(ancestor)):
try: try:
os.mkdir(syspath(ancestor)) os.mkdir(syspath(ancestor))
except (OSError, IOError) as exc: except OSError as exc:
raise FilesystemError(exc, 'create', (ancestor,), raise FilesystemError(exc, 'create', (ancestor,),
traceback.format_exc()) traceback.format_exc())
@@ -272,13 +294,13 @@ def prune_dirs(path, root=None, clutter=('.DS_Store', 'Thumbs.db')):
continue continue
clutter = [bytestring_path(c) for c in clutter] clutter = [bytestring_path(c) for c in clutter]
match_paths = [bytestring_path(d) for d in os.listdir(directory)] match_paths = [bytestring_path(d) for d in os.listdir(directory)]
if fnmatch_all(match_paths, clutter): try:
# Directory contains only clutter (or nothing). if fnmatch_all(match_paths, clutter):
try: # Directory contains only clutter (or nothing).
shutil.rmtree(directory) shutil.rmtree(directory)
except OSError: else:
break break
else: except OSError:
break break
@@ -357,18 +379,18 @@ def bytestring_path(path):
PATH_SEP = bytestring_path(os.sep) PATH_SEP = bytestring_path(os.sep)
def displayable_path(path, separator=u'; '): def displayable_path(path, separator='; '):
"""Attempts to decode a bytestring path to a unicode object for the """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 purpose of displaying it to the user. If the `path` argument is a
list or a tuple, the elements are joined with `separator`. list or a tuple, the elements are joined with `separator`.
""" """
if isinstance(path, (list, tuple)): if isinstance(path, (list, tuple)):
return separator.join(displayable_path(p) for p in path) return separator.join(displayable_path(p) for p in path)
elif isinstance(path, six.text_type): elif isinstance(path, str):
return path return path
elif not isinstance(path, bytes): elif not isinstance(path, bytes):
# A non-string object: just get its unicode representation. # A non-string object: just get its unicode representation.
return six.text_type(path) return str(path)
try: try:
return path.decode(_fsencoding(), 'ignore') return path.decode(_fsencoding(), 'ignore')
@@ -387,7 +409,7 @@ def syspath(path, prefix=True):
if os.path.__name__ != 'ntpath': if os.path.__name__ != 'ntpath':
return path return path
if not isinstance(path, six.text_type): if not isinstance(path, str):
# Beets currently represents Windows paths internally with UTF-8 # Beets currently represents Windows paths internally with UTF-8
# arbitrarily. But earlier versions used MBCS because it is # arbitrarily. But earlier versions used MBCS because it is
# reported as the FS encoding by Windows. Try both. # reported as the FS encoding by Windows. Try both.
@@ -400,11 +422,11 @@ def syspath(path, prefix=True):
path = path.decode(encoding, 'replace') path = path.decode(encoding, 'replace')
# Add the magic prefix if it isn't already there. # Add the magic prefix if it isn't already there.
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX): if prefix and not path.startswith(WINDOWS_MAGIC_PREFIX):
if path.startswith(u'\\\\'): if path.startswith('\\\\'):
# UNC path. Final path should look like \\?\UNC\... # UNC path. Final path should look like \\?\UNC\...
path = u'UNC' + path[1:] path = 'UNC' + path[1:]
path = WINDOWS_MAGIC_PREFIX + path path = WINDOWS_MAGIC_PREFIX + path
return path return path
@@ -412,6 +434,8 @@ def syspath(path, prefix=True):
def samefile(p1, p2): def samefile(p1, p2):
"""Safer equality for paths.""" """Safer equality for paths."""
if p1 == p2:
return True
return shutil._samefile(syspath(p1), syspath(p2)) return shutil._samefile(syspath(p1), syspath(p2))
@@ -424,7 +448,7 @@ def remove(path, soft=True):
return return
try: try:
os.remove(path) os.remove(path)
except (OSError, IOError) as exc: except OSError as exc:
raise FilesystemError(exc, 'delete', (path,), traceback.format_exc()) raise FilesystemError(exc, 'delete', (path,), traceback.format_exc())
@@ -439,10 +463,10 @@ def copy(path, dest, replace=False):
path = syspath(path) path = syspath(path)
dest = syspath(dest) dest = syspath(dest)
if not replace and os.path.exists(dest): if not replace and os.path.exists(dest):
raise FilesystemError(u'file exists', 'copy', (path, dest)) raise FilesystemError('file exists', 'copy', (path, dest))
try: try:
shutil.copyfile(path, dest) shutil.copyfile(path, dest)
except (OSError, IOError) as exc: except OSError as exc:
raise FilesystemError(exc, 'copy', (path, dest), raise FilesystemError(exc, 'copy', (path, dest),
traceback.format_exc()) traceback.format_exc())
@@ -455,24 +479,37 @@ def move(path, dest, replace=False):
instead, in which case metadata will *not* be preserved. Paths are instead, in which case metadata will *not* be preserved. Paths are
translated to system paths. 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): if samefile(path, dest):
return return
path = syspath(path) path = syspath(path)
dest = syspath(dest) dest = syspath(dest)
if os.path.exists(dest) and not replace: if os.path.exists(dest) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest)) raise FilesystemError('file exists', 'rename', (path, dest))
# First, try renaming the file. # First, try renaming the file.
try: try:
os.rename(path, dest) os.replace(path, dest)
except OSError: except OSError:
# Otherwise, copy and delete the original. tmp = tempfile.mktemp(suffix='.beets',
prefix=py3_path(b'.' + os.path.basename(dest)),
dir=py3_path(os.path.dirname(dest)))
tmp = syspath(tmp)
try: try:
shutil.copyfile(path, dest) shutil.copyfile(path, tmp)
os.replace(tmp, dest)
tmp = None
os.remove(path) os.remove(path)
except (OSError, IOError) as exc: except OSError as exc:
raise FilesystemError(exc, 'move', (path, dest), raise FilesystemError(exc, 'move', (path, dest),
traceback.format_exc()) traceback.format_exc())
finally:
if tmp is not None:
os.remove(tmp)
def link(path, dest, replace=False): def link(path, dest, replace=False):
@@ -484,18 +521,18 @@ def link(path, dest, replace=False):
return return
if os.path.exists(syspath(dest)) and not replace: if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest)) raise FilesystemError('file exists', 'rename', (path, dest))
try: try:
os.symlink(syspath(path), syspath(dest)) os.symlink(syspath(path), syspath(dest))
except NotImplementedError: except NotImplementedError:
# raised on python >= 3.2 and Windows versions before Vista # raised on python >= 3.2 and Windows versions before Vista
raise FilesystemError(u'OS does not support symbolic links.' raise FilesystemError('OS does not support symbolic links.'
'link', (path, dest), traceback.format_exc()) 'link', (path, dest), traceback.format_exc())
except OSError as exc: except OSError as exc:
# TODO: Windows version checks can be removed for python 3 # TODO: Windows version checks can be removed for python 3
if hasattr('sys', 'getwindowsversion'): if hasattr('sys', 'getwindowsversion'):
if sys.getwindowsversion()[0] < 6: # is before Vista if sys.getwindowsversion()[0] < 6: # is before Vista
exc = u'OS does not support symbolic links.' exc = 'OS does not support symbolic links.'
raise FilesystemError(exc, 'link', (path, dest), raise FilesystemError(exc, 'link', (path, dest),
traceback.format_exc()) traceback.format_exc())
@@ -509,21 +546,50 @@ def hardlink(path, dest, replace=False):
return return
if os.path.exists(syspath(dest)) and not replace: if os.path.exists(syspath(dest)) and not replace:
raise FilesystemError(u'file exists', 'rename', (path, dest)) raise FilesystemError('file exists', 'rename', (path, dest))
try: try:
os.link(syspath(path), syspath(dest)) os.link(syspath(path), syspath(dest))
except NotImplementedError: except NotImplementedError:
raise FilesystemError(u'OS does not support hard links.' raise FilesystemError('OS does not support hard links.'
'link', (path, dest), traceback.format_exc()) 'link', (path, dest), traceback.format_exc())
except OSError as exc: except OSError as exc:
if exc.errno == errno.EXDEV: if exc.errno == errno.EXDEV:
raise FilesystemError(u'Cannot hard link across devices.' raise FilesystemError('Cannot hard link across devices.'
'link', (path, dest), traceback.format_exc()) 'link', (path, dest), traceback.format_exc())
else: else:
raise FilesystemError(exc, 'link', (path, dest), raise FilesystemError(exc, 'link', (path, dest),
traceback.format_exc()) 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): def unique_path(path):
"""Returns a version of ``path`` that does not exist on the """Returns a version of ``path`` that does not exist on the
filesystem. Specifically, if ``path` itself already exists, then filesystem. Specifically, if ``path` itself already exists, then
@@ -541,22 +607,23 @@ def unique_path(path):
num = 0 num = 0
while True: while True:
num += 1 num += 1
suffix = u'.{}'.format(num).encode() + ext suffix = f'.{num}'.encode() + ext
new_path = base + suffix new_path = base + suffix
if not os.path.exists(new_path): if not os.path.exists(new_path):
return new_path return new_path
# Note: The Windows "reserved characters" are, of course, allowed on # Note: The Windows "reserved characters" are, of course, allowed on
# Unix. They are forbidden here because they cause problems on Samba # Unix. They are forbidden here because they cause problems on Samba
# shares, which are sufficiently common as to cause frequent problems. # shares, which are sufficiently common as to cause frequent problems.
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx # https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247.aspx
CHAR_REPLACE = [ CHAR_REPLACE = [
(re.compile(r'[\\/]'), u'_'), # / and \ -- forbidden everywhere. (re.compile(r'[\\/]'), '_'), # / and \ -- forbidden everywhere.
(re.compile(r'^\.'), u'_'), # Leading dot (hidden files on Unix). (re.compile(r'^\.'), '_'), # Leading dot (hidden files on Unix).
(re.compile(r'[\x00-\x1f]'), u''), # Control characters. (re.compile(r'[\x00-\x1f]'), ''), # Control characters.
(re.compile(r'[<>:"\?\*\|]'), u'_'), # Windows "reserved characters". (re.compile(r'[<>:"\?\*\|]'), '_'), # Windows "reserved characters".
(re.compile(r'\.$'), u'_'), # Trailing dots. (re.compile(r'\.$'), '_'), # Trailing dots.
(re.compile(r'\s+$'), u''), # Trailing whitespace. (re.compile(r'\s+$'), ''), # Trailing whitespace.
] ]
@@ -680,36 +747,29 @@ def py3_path(path):
it is. So this function helps us "smuggle" the true bytes data it is. So this function helps us "smuggle" the true bytes data
through APIs that took Python 3's Unicode mandate too seriously. through APIs that took Python 3's Unicode mandate too seriously.
""" """
if isinstance(path, six.text_type): if isinstance(path, str):
return path return path
assert isinstance(path, bytes) assert isinstance(path, bytes)
if six.PY2:
return path
return os.fsdecode(path) return os.fsdecode(path)
def str2bool(value): def str2bool(value):
"""Returns a boolean reflecting a human-entered string.""" """Returns a boolean reflecting a human-entered string."""
return value.lower() in (u'yes', u'1', u'true', u't', u'y') return value.lower() in ('yes', '1', 'true', 't', 'y')
def as_string(value): def as_string(value):
"""Convert a value to a Unicode object for matching with a query. """Convert a value to a Unicode object for matching with a query.
None becomes the empty string. Bytestrings are silently decoded. 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: if value is None:
return u'' return ''
elif isinstance(value, buffer_types): elif isinstance(value, memoryview):
return bytes(value).decode('utf-8', 'ignore') return bytes(value).decode('utf-8', 'ignore')
elif isinstance(value, bytes): elif isinstance(value, bytes):
return value.decode('utf-8', 'ignore') return value.decode('utf-8', 'ignore')
else: else:
return six.text_type(value) return str(value)
def text_string(value, encoding='utf-8'): def text_string(value, encoding='utf-8'):
@@ -732,7 +792,7 @@ def plurality(objs):
""" """
c = Counter(objs) c = Counter(objs)
if not c: if not c:
raise ValueError(u'sequence must be non-empty') raise ValueError('sequence must be non-empty')
return c.most_common(1)[0] return c.most_common(1)[0]
@@ -749,7 +809,11 @@ def cpu_count():
num = 0 num = 0
elif sys.platform == 'darwin': elif sys.platform == 'darwin':
try: try:
num = int(command_output(['/usr/sbin/sysctl', '-n', 'hw.ncpu'])) num = int(command_output([
'/usr/sbin/sysctl',
'-n',
'hw.ncpu',
]).stdout)
except (ValueError, OSError, subprocess.CalledProcessError): except (ValueError, OSError, subprocess.CalledProcessError):
num = 0 num = 0
else: else:
@@ -769,20 +833,23 @@ def convert_command_args(args):
assert isinstance(args, list) assert isinstance(args, list)
def convert(arg): def convert(arg):
if six.PY2: if isinstance(arg, bytes):
if isinstance(arg, six.text_type): arg = arg.decode(arg_encoding(), 'surrogateescape')
arg = arg.encode(arg_encoding())
else:
if isinstance(arg, bytes):
arg = arg.decode(arg_encoding(), 'surrogateescape')
return arg return arg
return [convert(a) for a in args] return [convert(a) for a in args]
# stdout and stderr as bytes
CommandOutput = namedtuple("CommandOutput", ("stdout", "stderr"))
def command_output(cmd, shell=False): def command_output(cmd, shell=False):
"""Runs the command and returns its output after it has exited. """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 ``cmd`` is a list of arguments starting with the command names. The
arguments are bytes on Unix and strings on Windows. arguments are bytes on Unix and strings on Windows.
If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a If ``shell`` is true, ``cmd`` is assumed to be a string and passed to a
@@ -797,10 +864,16 @@ def command_output(cmd, shell=False):
""" """
cmd = convert_command_args(cmd) cmd = convert_command_args(cmd)
try: # python >= 3.3
devnull = subprocess.DEVNULL
except AttributeError:
devnull = open(os.devnull, 'r+b')
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
stdin=devnull,
close_fds=platform.system() != 'Windows', close_fds=platform.system() != 'Windows',
shell=shell shell=shell
) )
@@ -811,7 +884,7 @@ def command_output(cmd, shell=False):
cmd=' '.join(cmd), cmd=' '.join(cmd),
output=stdout + stderr, output=stdout + stderr,
) )
return stdout return CommandOutput(stdout, stderr)
def max_filename_length(path, limit=MAX_FILENAME_LENGTH): def max_filename_length(path, limit=MAX_FILENAME_LENGTH):
@@ -858,25 +931,6 @@ def editor_command():
return open_anything() 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): def interactive_open(targets, command):
"""Open the files in `targets` by `exec`ing a new `command`, given """Open the files in `targets` by `exec`ing a new `command`, given
as a Unicode string. (The new program takes over, and Python as a Unicode string. (The new program takes over, and Python
@@ -888,7 +942,7 @@ def interactive_open(targets, command):
# Split the command string into its arguments. # Split the command string into its arguments.
try: try:
args = shlex_split(command) args = shlex.split(command)
except ValueError: # Malformed shell tokens. except ValueError: # Malformed shell tokens.
args = [command] args = [command]
@@ -903,7 +957,7 @@ def _windows_long_path_name(short_path):
"""Use Windows' `GetLongPathNameW` via ctypes to get the canonical, """Use Windows' `GetLongPathNameW` via ctypes to get the canonical,
long path given a short filename. long path given a short filename.
""" """
if not isinstance(short_path, six.text_type): if not isinstance(short_path, str):
short_path = short_path.decode(_fsencoding()) short_path = short_path.decode(_fsencoding())
import ctypes import ctypes
@@ -964,7 +1018,7 @@ def raw_seconds_short(string):
""" """
match = re.match(r'^(\d+):([0-5]\d)$', string) match = re.match(r'^(\d+):([0-5]\d)$', string)
if not match: if not match:
raise ValueError(u'String not in M:SS format') raise ValueError('String not in M:SS format')
minutes, seconds = map(int, match.groups()) minutes, seconds = map(int, match.groups())
return float(minutes * 60 + seconds) return float(minutes * 60 + seconds)
@@ -991,3 +1045,59 @@ def asciify_path(path, sep_replace):
sep_replace sep_replace
) )
return os.sep.join(path_components) 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')
Executable → Regular
+305 -75
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Fabrice Laporte # Copyright 2016, Fabrice Laporte
# #
@@ -16,38 +15,39 @@
"""Abstraction layer to resize images using PIL, ImageMagick, or a """Abstraction layer to resize images using PIL, ImageMagick, or a
public resizing proxy if neither is available. public resizing proxy if neither is available.
""" """
from __future__ import division, absolute_import, print_function
import subprocess import subprocess
import os import os
import os.path
import re import re
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from six.moves.urllib.parse import urlencode from urllib.parse import urlencode
from beets import logging from beets import logging
from beets import util from beets import util
import six
# Resizing methods # Resizing methods
PIL = 1 PIL = 1
IMAGEMAGICK = 2 IMAGEMAGICK = 2
WEBPROXY = 3 WEBPROXY = 3
if util.SNI_SUPPORTED: PROXY_URL = 'https://images.weserv.nl/'
PROXY_URL = 'https://images.weserv.nl/'
else:
PROXY_URL = 'http://images.weserv.nl/'
log = logging.getLogger('beets') log = logging.getLogger('beets')
def resize_url(url, maxwidth): def resize_url(url, maxwidth, quality=0):
"""Return a proxied image URL that resizes the original image to """Return a proxied image URL that resizes the original image to
maxwidth (preserving aspect ratio). maxwidth (preserving aspect ratio).
""" """
return '{0}?{1}'.format(PROXY_URL, urlencode({ params = {
'url': url.replace('http://', ''), 'url': url.replace('http://', ''),
'w': maxwidth, 'w': maxwidth,
})) }
if quality > 0:
params['q'] = quality
return '{}?{}'.format(PROXY_URL, urlencode(params))
def temp_file_for(path): def temp_file_for(path):
@@ -59,49 +59,102 @@ def temp_file_for(path):
return util.bytestring_path(f.name) return util.bytestring_path(f.name)
def pil_resize(maxwidth, path_in, path_out=None): def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using Python Imaging Library (PIL). Return the output path """Resize using Python Imaging Library (PIL). Return the output path
of resized image. of resized image.
""" """
path_out = path_out or temp_file_for(path_in) path_out = path_out or temp_file_for(path_in)
from PIL import Image from PIL import Image
log.debug(u'artresizer: PIL resizing {0} to {1}',
log.debug('artresizer: PIL resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out)) util.displayable_path(path_in), util.displayable_path(path_out))
try: try:
im = Image.open(util.syspath(path_in)) im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS) im.thumbnail(size, Image.ANTIALIAS)
im.save(path_out)
return path_out if quality == 0:
except IOError: # Use PIL's default quality.
log.error(u"PIL cannot create thumbnail for '{0}'", 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}'",
util.displayable_path(path_in)) util.displayable_path(path_in))
return path_in return path_in
def im_resize(maxwidth, path_in, path_out=None): def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using ImageMagick's ``convert`` tool. """Resize using ImageMagick.
Return the output path of resized image.
Use the ``magick`` program or ``convert`` on older versions. Return
the output path of resized image.
""" """
path_out = path_out or temp_file_for(path_in) path_out = path_out or temp_file_for(path_in)
log.debug(u'artresizer: ImageMagick resizing {0} to {1}', log.debug('artresizer: ImageMagick resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out)) util.displayable_path(path_in), util.displayable_path(path_out))
# "-resize widthxheight>" shrinks images with dimension(s) larger # "-resize WIDTHx>" shrinks images with the width larger
# than the corresponding width and/or height dimension(s). The > # than the given width while maintaining the aspect ratio
# "only shrink" flag is prefixed by ^ escape char for Windows # with regards to the height.
# compatibility. # 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))
try: try:
util.command_output([ util.command_output(cmd)
'convert', util.syspath(path_in, prefix=False),
'-resize', '{0}x^>'.format(maxwidth),
util.syspath(path_out, prefix=False),
])
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
log.warning(u'artresizer: IM convert failed for {0}', log.warning('artresizer: IM convert failed for {0}',
util.displayable_path(path_in)) util.displayable_path(path_in))
return path_in return path_in
return path_out return path_out
@@ -113,31 +166,33 @@ BACKEND_FUNCS = {
def pil_getsize(path_in): def pil_getsize(path_in):
from PIL import Image from PIL import Image
try: try:
im = Image.open(util.syspath(path_in)) im = Image.open(util.syspath(path_in))
return im.size return im.size
except IOError as exc: except OSError as exc:
log.error(u"PIL could not read file {}: {}", log.error("PIL could not read file {}: {}",
util.displayable_path(path_in), exc) util.displayable_path(path_in), exc)
def im_getsize(path_in): def im_getsize(path_in):
cmd = ['identify', '-format', '%w %h', cmd = ArtResizer.shared.im_identify_cmd + \
util.syspath(path_in, prefix=False)] ['-format', '%w %h', util.syspath(path_in, prefix=False)]
try: try:
out = util.command_output(cmd) out = util.command_output(cmd).stdout
except subprocess.CalledProcessError as exc: except subprocess.CalledProcessError as exc:
log.warning(u'ImageMagick size query failed') log.warning('ImageMagick size query failed')
log.debug( log.debug(
u'`convert` exited with (status {}) when ' '`convert` exited with (status {}) when '
u'getting size with command {}:\n{}', 'getting size with command {}:\n{}',
exc.returncode, cmd, exc.output.strip() exc.returncode, cmd, exc.output.strip()
) )
return return
try: try:
return tuple(map(int, out.split(b' '))) return tuple(map(int, out.split(b' ')))
except IndexError: except IndexError:
log.warning(u'Could not understand IM output: {0!r}', out) log.warning('Could not understand IM output: {0!r}', out)
BACKEND_GET_SIZE = { BACKEND_GET_SIZE = {
@@ -146,24 +201,125 @@ 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): class Shareable(type):
"""A pseudo-singleton metaclass that allows both shared and """A pseudo-singleton metaclass that allows both shared and
non-shared instances. The ``MyClass.shared`` property holds a non-shared instances. The ``MyClass.shared`` property holds a
lazily-created shared instance of ``MyClass`` while calling lazily-created shared instance of ``MyClass`` while calling
``MyClass()`` to construct a new object works as usual. ``MyClass()`` to construct a new object works as usual.
""" """
def __init__(self, name, bases, dict):
super(Shareable, self).__init__(name, bases, dict) def __init__(cls, name, bases, dict):
self._instance = None super().__init__(name, bases, dict)
cls._instance = None
@property @property
def shared(self): def shared(cls):
if self._instance is None: if cls._instance is None:
self._instance = self() cls._instance = cls()
return self._instance return cls._instance
class ArtResizer(six.with_metaclass(Shareable, object)): class ArtResizer(metaclass=Shareable):
"""A singleton class that performs image resizes. """A singleton class that performs image resizes.
""" """
@@ -171,21 +327,44 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
"""Create a resizer object with an inferred method. """Create a resizer object with an inferred method.
""" """
self.method = self._check_method() self.method = self._check_method()
log.debug(u"artresizer: method is {0}", self.method) log.debug("artresizer: method is {0}", self.method)
self.can_compare = self._can_compare() self.can_compare = self._can_compare()
def resize(self, maxwidth, path_in, path_out=None): # 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
):
"""Manipulate an image file according to the method, returning a """Manipulate an image file according to the method, returning a
new path. For PIL or IMAGEMAGIC methods, resizes the image to a new path. For PIL or IMAGEMAGIC methods, resizes the image to a
temporary file. For WEBPROXY, returns `path_in` unmodified. temporary file and encodes with the specified quality level.
For WEBPROXY, returns `path_in` unmodified.
""" """
if self.local: if self.local:
func = BACKEND_FUNCS[self.method[0]] func = BACKEND_FUNCS[self.method[0]]
return func(maxwidth, path_in, path_out) return func(maxwidth, path_in, path_out,
quality=quality, max_filesize=max_filesize)
else: else:
return path_in return path_in
def proxy_url(self, maxwidth, url): 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):
"""Modifies an image URL according the method, returning a new """Modifies an image URL according the method, returning a new
URL. For WEBPROXY, a URL on the proxy server is returned. URL. For WEBPROXY, a URL on the proxy server is returned.
Otherwise, the URL is returned unmodified. Otherwise, the URL is returned unmodified.
@@ -193,7 +372,7 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
if self.local: if self.local:
return url return url
else: else:
return resize_url(url, maxwidth) return resize_url(url, maxwidth, quality)
@property @property
def local(self): def local(self):
@@ -206,12 +385,50 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
"""Return the size of an image file as an int couple (width, height) """Return the size of an image file as an int couple (width, height)
in pixels. in pixels.
Only available locally Only available locally.
""" """
if self.local: if self.local:
func = BACKEND_GET_SIZE[self.method[0]] func = BACKEND_GET_SIZE[self.method[0]]
return func(path_in) 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): def _can_compare(self):
"""A boolean indicating whether image comparison is available""" """A boolean indicating whether image comparison is available"""
@@ -219,10 +436,20 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
@staticmethod @staticmethod
def _check_method(): def _check_method():
"""Return a tuple indicating an available method and its version.""" """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.)
"""
version = get_im_version() version = get_im_version()
if version: if version:
return IMAGEMAGICK, version version, legacy = version
return IMAGEMAGICK, version, legacy
version = get_pil_version() version = get_pil_version()
if version: if version:
@@ -232,31 +459,34 @@ class ArtResizer(six.with_metaclass(Shareable, object)):
def get_im_version(): def get_im_version():
"""Return Image Magick version or None if it is unavailable """Get the ImageMagick version and legacy flag as a pair. Or return
Try invoking ImageMagick's "convert". None if ImageMagick is not available.
""" """
try: for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
out = util.command_output(['convert', '--version']) cmd = cmd_name + ['--version']
if b'imagemagick' in out.lower(): try:
pattern = br".+ (\d+)\.(\d+)\.(\d+).*" out = util.command_output(cmd).stdout
match = re.search(pattern, out) except (subprocess.CalledProcessError, OSError) as exc:
if match: log.debug('ImageMagick version check failed: {}', exc)
return (int(match.group(1)), else:
int(match.group(2)), if b'imagemagick' in out.lower():
int(match.group(3))) pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
return (0,) match = re.search(pattern, out)
if match:
version = (int(match.group(1)),
int(match.group(2)),
int(match.group(3)))
return version, legacy
except (subprocess.CalledProcessError, OSError) as exc: return None
log.debug(u'ImageMagick check `convert --version` failed: {}', exc)
return None
def get_pil_version(): def get_pil_version():
"""Return Image Magick version or None if it is unavailable """Get the PIL/Pillow version, or None if it is unavailable.
Try importing PIL.""" """
try: try:
__import__('PIL', fromlist=[str('Image')]) __import__('PIL', fromlist=['Image'])
return (0,) return (0,)
except ImportError: except ImportError:
return None return None
Executable → Regular
+14 -14
View File
@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
"""Extremely simple pure-Python implementation of coroutine-style """Extremely simple pure-Python implementation of coroutine-style
asynchronous socket I/O. Inspired by, but inferior to, Eventlet. asynchronous socket I/O. Inspired by, but inferior to, Eventlet.
Bluelet can also be thought of as a less-terrible replacement for Bluelet can also be thought of as a less-terrible replacement for
@@ -7,9 +5,7 @@ asyncore.
Bluelet: easy concurrency without all the messy parallelism. Bluelet: easy concurrency without all the messy parallelism.
""" """
from __future__ import division, absolute_import, print_function
import six
import socket import socket
import select import select
import sys import sys
@@ -22,7 +18,7 @@ import collections
# Basic events used for thread scheduling. # Basic events used for thread scheduling.
class Event(object): class Event:
"""Just a base class identifying Bluelet events. An event is an """Just a base class identifying Bluelet events. An event is an
object yielded from a Bluelet thread coroutine to suspend operation object yielded from a Bluelet thread coroutine to suspend operation
and communicate with the scheduler. and communicate with the scheduler.
@@ -201,7 +197,7 @@ class ThreadException(Exception):
self.exc_info = exc_info self.exc_info = exc_info
def reraise(self): def reraise(self):
six.reraise(self.exc_info[0], self.exc_info[1], self.exc_info[2]) raise self.exc_info[1].with_traceback(self.exc_info[2])
SUSPENDED = Event() # Special sentinel placeholder for suspended threads. SUSPENDED = Event() # Special sentinel placeholder for suspended threads.
@@ -269,7 +265,7 @@ def run(root_coro):
except StopIteration: except StopIteration:
# Thread is done. # Thread is done.
complete_thread(coro, None) complete_thread(coro, None)
except: except BaseException:
# Thread raised some other exception. # Thread raised some other exception.
del threads[coro] del threads[coro]
raise ThreadException(coro, sys.exc_info()) raise ThreadException(coro, sys.exc_info())
@@ -336,16 +332,20 @@ def run(root_coro):
break break
# Wait and fire. # Wait and fire.
event2coro = dict((v, k) for k, v in threads.items()) event2coro = {v: k for k, v in threads.items()}
for event in _event_select(threads.values()): for event in _event_select(threads.values()):
# Run the IO operation, but catch socket errors. # Run the IO operation, but catch socket errors.
try: try:
value = event.fire() value = event.fire()
except socket.error as exc: except OSError as exc:
if isinstance(exc.args, tuple) and \ if isinstance(exc.args, tuple) and \
exc.args[0] == errno.EPIPE: exc.args[0] == errno.EPIPE:
# Broken pipe. Remote host disconnected. # Broken pipe. Remote host disconnected.
pass pass
elif isinstance(exc.args, tuple) and \
exc.args[0] == errno.ECONNRESET:
# Connection was reset by peer.
pass
else: else:
traceback.print_exc() traceback.print_exc()
# Abort the coroutine. # Abort the coroutine.
@@ -366,7 +366,7 @@ def run(root_coro):
exit_te = te exit_te = te
break break
except: except BaseException:
# For instance, KeyboardInterrupt during select(). Raise # For instance, KeyboardInterrupt during select(). Raise
# into root thread and terminate others. # into root thread and terminate others.
threads = {root_coro: ExceptionEvent(sys.exc_info())} threads = {root_coro: ExceptionEvent(sys.exc_info())}
@@ -386,7 +386,7 @@ class SocketClosedError(Exception):
pass pass
class Listener(object): class Listener:
"""A socket wrapper object for listening sockets. """A socket wrapper object for listening sockets.
""" """
def __init__(self, host, port): def __init__(self, host, port):
@@ -416,7 +416,7 @@ class Listener(object):
self.sock.close() self.sock.close()
class Connection(object): class Connection:
"""A socket wrapper object for connected sockets. """A socket wrapper object for connected sockets.
""" """
def __init__(self, sock, addr): def __init__(self, sock, addr):
@@ -541,7 +541,7 @@ def spawn(coro):
and child coroutines run concurrently. and child coroutines run concurrently.
""" """
if not isinstance(coro, types.GeneratorType): if not isinstance(coro, types.GeneratorType):
raise ValueError(u'%s is not a coroutine' % coro) raise ValueError('%s is not a coroutine' % coro)
return SpawnEvent(coro) return SpawnEvent(coro)
@@ -551,7 +551,7 @@ def call(coro):
returns a value using end(), then this event returns that value. returns a value using end(), then this event returns that value.
""" """
if not isinstance(coro, types.GeneratorType): if not isinstance(coro, types.GeneratorType):
raise ValueError(u'%s is not a coroutine' % coro) raise ValueError('%s is not a coroutine' % coro)
return DelegationEvent(coro) return DelegationEvent(coro)
Executable → Regular
+11 -1442
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
-2
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -13,7 +12,6 @@
# The above copyright notice and this permission notice shall be # The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
from __future__ import division, absolute_import, print_function
from enum import Enum from enum import Enum
Executable → Regular
+90 -88
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -27,30 +26,30 @@ This is sort of like a tiny, horrible degeneration of a real templating
engine like Jinja2 or Mustache. engine like Jinja2 or Mustache.
""" """
from __future__ import division, absolute_import, print_function
import re import re
import ast import ast
import dis import dis
import types import types
import sys import sys
import six import functools
SYMBOL_DELIM = u'$' SYMBOL_DELIM = '$'
FUNC_DELIM = u'%' FUNC_DELIM = '%'
GROUP_OPEN = u'{' GROUP_OPEN = '{'
GROUP_CLOSE = u'}' GROUP_CLOSE = '}'
ARG_SEP = u',' ARG_SEP = ','
ESCAPE_CHAR = u'$' ESCAPE_CHAR = '$'
VARIABLE_PREFIX = '__var_' VARIABLE_PREFIX = '__var_'
FUNCTION_PREFIX = '__func_' FUNCTION_PREFIX = '__func_'
class Environment(object): class Environment:
"""Contains the values and functions to be substituted into a """Contains the values and functions to be substituted into a
template. template.
""" """
def __init__(self, values, functions): def __init__(self, values, functions):
self.values = values self.values = values
self.functions = functions self.functions = functions
@@ -72,15 +71,7 @@ def ex_literal(val):
"""An int, float, long, bool, string, or None literal with the given """An int, float, long, bool, string, or None literal with the given
value. value.
""" """
if val is None: return ast.Constant(val)
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): def ex_varassign(name, expr):
@@ -97,7 +88,7 @@ def ex_call(func, args):
function may be an expression or the name of a function. Each 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. argument may be an expression or a value to be used as a literal.
""" """
if isinstance(func, six.string_types): if isinstance(func, str):
func = ex_rvalue(func) func = ex_rvalue(func)
args = list(args) args = list(args)
@@ -105,10 +96,7 @@ def ex_call(func, args):
if not isinstance(args[i], ast.expr): if not isinstance(args[i], ast.expr):
args[i] = ex_literal(args[i]) args[i] = ex_literal(args[i])
if sys.version_info[:2] < (3, 5): return ast.Call(func, args, [])
return ast.Call(func, args, [], None, None)
else:
return ast.Call(func, args, [])
def compile_func(arg_names, statements, name='_the_func', debug=False): def compile_func(arg_names, statements, name='_the_func', debug=False):
@@ -116,32 +104,30 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
the resulting Python function. If `debug`, then print out the the resulting Python function. If `debug`, then print out the
bytecode of the compiled function. bytecode of the compiled function.
""" """
if six.PY2: args_fields = {
func_def = ast.FunctionDef( 'args': [ast.arg(arg=n, annotation=None) for n in arg_names],
name=name.encode('utf-8'), 'kwonlyargs': [],
args=ast.arguments( 'kw_defaults': [],
args=[ast.Name(n, ast.Param()) for n in arg_names], 'defaults': [ex_literal(None) for _ in arg_names],
vararg=None, }
kwarg=None, if 'posonlyargs' in ast.arguments._fields: # Added in Python 3.8.
defaults=[ex_literal(None) for _ in arg_names], args_fields['posonlyargs'] = []
), args = ast.arguments(**args_fields)
body=statements,
decorator_list=[], func_def = ast.FunctionDef(
) name=name,
else: args=args,
func_def = ast.FunctionDef( body=statements,
name=name, decorator_list=[],
args=ast.arguments( )
args=[ast.arg(arg=n, annotation=None) for n in arg_names],
kwonlyargs=[], # The ast.Module signature changed in 3.8 to accept a list of types to
kw_defaults=[], # ignore.
defaults=[ex_literal(None) for _ in arg_names], if sys.version_info >= (3, 8):
), mod = ast.Module([func_def], [])
body=statements, else:
decorator_list=[], mod = ast.Module([func_def])
)
mod = ast.Module([func_def])
ast.fix_missing_locations(mod) ast.fix_missing_locations(mod)
prog = compile(mod, '<generated>', 'exec') prog = compile(mod, '<generated>', 'exec')
@@ -160,14 +146,15 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
# AST nodes for the template language. # AST nodes for the template language.
class Symbol(object): class Symbol:
"""A variable-substitution symbol in a template.""" """A variable-substitution symbol in a template."""
def __init__(self, ident, original): def __init__(self, ident, original):
self.ident = ident self.ident = ident
self.original = original self.original = original
def __repr__(self): def __repr__(self):
return u'Symbol(%s)' % repr(self.ident) return 'Symbol(%s)' % repr(self.ident)
def evaluate(self, env): def evaluate(self, env):
"""Evaluate the symbol in the environment, returning a Unicode """Evaluate the symbol in the environment, returning a Unicode
@@ -182,24 +169,22 @@ class Symbol(object):
def translate(self): def translate(self):
"""Compile the variable lookup.""" """Compile the variable lookup."""
if six.PY2: ident = self.ident
ident = self.ident.encode('utf-8')
else:
ident = self.ident
expr = ex_rvalue(VARIABLE_PREFIX + ident) expr = ex_rvalue(VARIABLE_PREFIX + ident)
return [expr], set([ident]), set() return [expr], {ident}, set()
class Call(object): class Call:
"""A function call in a template.""" """A function call in a template."""
def __init__(self, ident, args, original): def __init__(self, ident, args, original):
self.ident = ident self.ident = ident
self.args = args self.args = args
self.original = original self.original = original
def __repr__(self): def __repr__(self):
return u'Call(%s, %s, %s)' % (repr(self.ident), repr(self.args), return 'Call({}, {}, {})'.format(repr(self.ident), repr(self.args),
repr(self.original)) repr(self.original))
def evaluate(self, env): def evaluate(self, env):
"""Evaluate the function call in the environment, returning a """Evaluate the function call in the environment, returning a
@@ -212,19 +197,15 @@ class Call(object):
except Exception as exc: except Exception as exc:
# Function raised exception! Maybe inlining the name of # Function raised exception! Maybe inlining the name of
# the exception will help debug. # the exception will help debug.
return u'<%s>' % six.text_type(exc) return '<%s>' % str(exc)
return six.text_type(out) return str(out)
else: else:
return self.original return self.original
def translate(self): def translate(self):
"""Compile the function call.""" """Compile the function call."""
varnames = set() varnames = set()
if six.PY2: funcnames = {self.ident}
ident = self.ident.encode('utf-8')
else:
ident = self.ident
funcnames = set([ident])
arg_exprs = [] arg_exprs = []
for arg in self.args: for arg in self.args:
@@ -235,32 +216,33 @@ class Call(object):
# Create a subexpression that joins the result components of # Create a subexpression that joins the result components of
# the arguments. # the arguments.
arg_exprs.append(ex_call( arg_exprs.append(ex_call(
ast.Attribute(ex_literal(u''), 'join', ast.Load()), ast.Attribute(ex_literal(''), 'join', ast.Load()),
[ex_call( [ex_call(
'map', 'map',
[ [
ex_rvalue(six.text_type.__name__), ex_rvalue(str.__name__),
ast.List(subexprs, ast.Load()), ast.List(subexprs, ast.Load()),
] ]
)], )],
)) ))
subexpr_call = ex_call( subexpr_call = ex_call(
FUNCTION_PREFIX + ident, FUNCTION_PREFIX + self.ident,
arg_exprs arg_exprs
) )
return [subexpr_call], varnames, funcnames return [subexpr_call], varnames, funcnames
class Expression(object): class Expression:
"""Top-level template construct: contains a list of text blobs, """Top-level template construct: contains a list of text blobs,
Symbols, and Calls. Symbols, and Calls.
""" """
def __init__(self, parts): def __init__(self, parts):
self.parts = parts self.parts = parts
def __repr__(self): def __repr__(self):
return u'Expression(%s)' % (repr(self.parts)) return 'Expression(%s)' % (repr(self.parts))
def evaluate(self, env): def evaluate(self, env):
"""Evaluate the entire expression in the environment, returning """Evaluate the entire expression in the environment, returning
@@ -268,11 +250,11 @@ class Expression(object):
""" """
out = [] out = []
for part in self.parts: for part in self.parts:
if isinstance(part, six.string_types): if isinstance(part, str):
out.append(part) out.append(part)
else: else:
out.append(part.evaluate(env)) out.append(part.evaluate(env))
return u''.join(map(six.text_type, out)) return ''.join(map(str, out))
def translate(self): def translate(self):
"""Compile the expression to a list of Python AST expressions, a """Compile the expression to a list of Python AST expressions, a
@@ -282,7 +264,7 @@ class Expression(object):
varnames = set() varnames = set()
funcnames = set() funcnames = set()
for part in self.parts: for part in self.parts:
if isinstance(part, six.string_types): if isinstance(part, str):
expressions.append(ex_literal(part)) expressions.append(ex_literal(part))
else: else:
e, v, f = part.translate() e, v, f = part.translate()
@@ -298,7 +280,7 @@ class ParseError(Exception):
pass pass
class Parser(object): class Parser:
"""Parses a template expression string. Instantiate the class with """Parses a template expression string. Instantiate the class with
the template source and call ``parse_expression``. The ``pos`` field the template source and call ``parse_expression``. The ``pos`` field
will indicate the character after the expression finished and will indicate the character after the expression finished and
@@ -311,6 +293,7 @@ class Parser(object):
replaced with a real, accepted parsing technique (PEG, parser replaced with a real, accepted parsing technique (PEG, parser
generator, etc.). generator, etc.).
""" """
def __init__(self, string, in_argument=False): def __init__(self, string, in_argument=False):
""" Create a new parser. """ Create a new parser.
:param in_arguments: boolean that indicates the parser is to be :param in_arguments: boolean that indicates the parser is to be
@@ -325,8 +308,8 @@ class Parser(object):
# Common parsing resources. # Common parsing resources.
special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE, special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE,
ESCAPE_CHAR) ESCAPE_CHAR)
special_char_re = re.compile(r'[%s]|$' % special_char_re = re.compile(r'[%s]|\Z' %
u''.join(re.escape(c) for c in special_chars)) ''.join(re.escape(c) for c in special_chars))
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP) escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
terminator_chars = (GROUP_CLOSE,) terminator_chars = (GROUP_CLOSE,)
@@ -343,8 +326,11 @@ class Parser(object):
if self.in_argument: if self.in_argument:
extra_special_chars = (ARG_SEP,) extra_special_chars = (ARG_SEP,)
special_char_re = re.compile( special_char_re = re.compile(
r'[%s]|$' % u''.join(re.escape(c) for c in r'[%s]|\Z' % ''.join(
self.special_chars + extra_special_chars)) re.escape(c) for c in
self.special_chars + extra_special_chars
)
)
text_parts = [] text_parts = []
@@ -384,7 +370,7 @@ class Parser(object):
# Shift all characters collected so far into a single string. # Shift all characters collected so far into a single string.
if text_parts: if text_parts:
self.parts.append(u''.join(text_parts)) self.parts.append(''.join(text_parts))
text_parts = [] text_parts = []
if char == SYMBOL_DELIM: if char == SYMBOL_DELIM:
@@ -406,7 +392,7 @@ class Parser(object):
# If any parsed characters remain, shift them into a string. # If any parsed characters remain, shift them into a string.
if text_parts: if text_parts:
self.parts.append(u''.join(text_parts)) self.parts.append(''.join(text_parts))
def parse_symbol(self): def parse_symbol(self):
"""Parse a variable reference (like ``$foo`` or ``${foo}``) """Parse a variable reference (like ``$foo`` or ``${foo}``)
@@ -544,11 +530,27 @@ def _parse(template):
return Expression(parts) return Expression(parts)
# External interface. 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
class Template(object):
@cached
def template(fmt):
return Template(fmt)
# External interface.
class Template:
"""A string template, including text, Symbols, and Calls. """A string template, including text, Symbols, and Calls.
""" """
def __init__(self, template): def __init__(self, template):
self.expr = _parse(template) self.expr = _parse(template)
self.original = template self.original = template
@@ -570,7 +572,7 @@ class Template(object):
""" """
try: try:
res = self.compiled(values, functions) res = self.compiled(values, functions)
except: # Handle any exceptions thrown by compiled version. except Exception: # Handle any exceptions thrown by compiled version.
res = self.interpret(values, functions) res = self.interpret(values, functions)
return res return res
@@ -597,7 +599,7 @@ class Template(object):
for funcname in funcnames: for funcname in funcnames:
args[FUNCTION_PREFIX + funcname] = functions[funcname] args[FUNCTION_PREFIX + funcname] = functions[funcname]
parts = func(**args) parts = func(**args)
return u''.join(parts) return ''.join(parts)
return wrapper_func return wrapper_func
@@ -606,9 +608,9 @@ class Template(object):
if __name__ == '__main__': if __name__ == '__main__':
import timeit import timeit
_tmpl = Template(u'foo $bar %baz{foozle $bar barzle} $bar') _tmpl = Template('foo $bar %baz{foozle $bar barzle} $bar')
_vars = {'bar': 'qux'} _vars = {'bar': 'qux'}
_funcs = {'baz': six.text_type.upper} _funcs = {'baz': str.upper}
interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)', interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)',
'from __main__ import _tmpl, _vars, _funcs', 'from __main__ import _tmpl, _vars, _funcs',
number=10000) number=10000)
@@ -617,4 +619,4 @@ if __name__ == '__main__':
'from __main__ import _tmpl, _vars, _funcs', 'from __main__ import _tmpl, _vars, _funcs',
number=10000) number=10000)
print(comp_time) print(comp_time)
print(u'Speedup:', interp_time / comp_time) print('Speedup:', interp_time / comp_time)
Executable → Regular
-2
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -14,7 +13,6 @@
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
"""Simple library to work out if a file is hidden on different platforms.""" """Simple library to work out if a file is hidden on different platforms."""
from __future__ import division, absolute_import, print_function
import os import os
import stat import stat
Executable → Regular
+30 -28
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -32,12 +31,10 @@ To do so, pass an iterable of coroutines to the Pipeline constructor
in place of any single coroutine. in place of any single coroutine.
""" """
from __future__ import division, absolute_import, print_function
from six.moves import queue import queue
from threading import Thread, Lock from threading import Thread, Lock
import sys import sys
import six
BUBBLE = '__PIPELINE_BUBBLE__' BUBBLE = '__PIPELINE_BUBBLE__'
POISON = '__PIPELINE_POISON__' POISON = '__PIPELINE_POISON__'
@@ -91,6 +88,7 @@ class CountedQueue(queue.Queue):
still feeding into it. The queue is poisoned when all threads are still feeding into it. The queue is poisoned when all threads are
finished with the queue. finished with the queue.
""" """
def __init__(self, maxsize=0): def __init__(self, maxsize=0):
queue.Queue.__init__(self, maxsize) queue.Queue.__init__(self, maxsize)
self.nthreads = 0 self.nthreads = 0
@@ -135,10 +133,11 @@ class CountedQueue(queue.Queue):
_invalidate_queue(self, POISON, False) _invalidate_queue(self, POISON, False)
class MultiMessage(object): class MultiMessage:
"""A message yielded by a pipeline stage encapsulating multiple """A message yielded by a pipeline stage encapsulating multiple
values to be sent to the next stage. values to be sent to the next stage.
""" """
def __init__(self, messages): def __init__(self, messages):
self.messages = messages self.messages = messages
@@ -210,8 +209,9 @@ def _allmsgs(obj):
class PipelineThread(Thread): class PipelineThread(Thread):
"""Abstract base class for pipeline-stage threads.""" """Abstract base class for pipeline-stage threads."""
def __init__(self, all_threads): def __init__(self, all_threads):
super(PipelineThread, self).__init__() super().__init__()
self.abort_lock = Lock() self.abort_lock = Lock()
self.abort_flag = False self.abort_flag = False
self.all_threads = all_threads self.all_threads = all_threads
@@ -241,15 +241,13 @@ class FirstPipelineThread(PipelineThread):
"""The thread running the first stage in a parallel pipeline setup. """The thread running the first stage in a parallel pipeline setup.
The coroutine should just be a generator. The coroutine should just be a generator.
""" """
def __init__(self, coro, out_queue, all_threads): def __init__(self, coro, out_queue, all_threads):
super(FirstPipelineThread, self).__init__(all_threads) super().__init__(all_threads)
self.coro = coro self.coro = coro
self.out_queue = out_queue self.out_queue = out_queue
self.out_queue.acquire() self.out_queue.acquire()
self.abort_lock = Lock()
self.abort_flag = False
def run(self): def run(self):
try: try:
while True: while True:
@@ -270,7 +268,7 @@ class FirstPipelineThread(PipelineThread):
return return
self.out_queue.put(msg) self.out_queue.put(msg)
except: except BaseException:
self.abort_all(sys.exc_info()) self.abort_all(sys.exc_info())
return return
@@ -282,8 +280,9 @@ class MiddlePipelineThread(PipelineThread):
"""A thread running any stage in the pipeline except the first or """A thread running any stage in the pipeline except the first or
last. last.
""" """
def __init__(self, coro, in_queue, out_queue, all_threads): def __init__(self, coro, in_queue, out_queue, all_threads):
super(MiddlePipelineThread, self).__init__(all_threads) super().__init__(all_threads)
self.coro = coro self.coro = coro
self.in_queue = in_queue self.in_queue = in_queue
self.out_queue = out_queue self.out_queue = out_queue
@@ -318,7 +317,7 @@ class MiddlePipelineThread(PipelineThread):
return return
self.out_queue.put(msg) self.out_queue.put(msg)
except: except BaseException:
self.abort_all(sys.exc_info()) self.abort_all(sys.exc_info())
return return
@@ -330,8 +329,9 @@ class LastPipelineThread(PipelineThread):
"""A thread running the last stage in a pipeline. The coroutine """A thread running the last stage in a pipeline. The coroutine
should yield nothing. should yield nothing.
""" """
def __init__(self, coro, in_queue, all_threads): def __init__(self, coro, in_queue, all_threads):
super(LastPipelineThread, self).__init__(all_threads) super().__init__(all_threads)
self.coro = coro self.coro = coro
self.in_queue = in_queue self.in_queue = in_queue
@@ -357,22 +357,23 @@ class LastPipelineThread(PipelineThread):
# Send to consumer. # Send to consumer.
self.coro.send(msg) self.coro.send(msg)
except: except BaseException:
self.abort_all(sys.exc_info()) self.abort_all(sys.exc_info())
return return
class Pipeline(object): class Pipeline:
"""Represents a staged pattern of work. Each stage in the pipeline """Represents a staged pattern of work. Each stage in the pipeline
is a coroutine that receives messages from the previous stage and is a coroutine that receives messages from the previous stage and
yields messages to be sent to the next stage. yields messages to be sent to the next stage.
""" """
def __init__(self, stages): def __init__(self, stages):
"""Makes a new pipeline from a list of coroutines. There must """Makes a new pipeline from a list of coroutines. There must
be at least two stages. be at least two stages.
""" """
if len(stages) < 2: if len(stages) < 2:
raise ValueError(u'pipeline must have at least two stages') raise ValueError('pipeline must have at least two stages')
self.stages = [] self.stages = []
for stage in stages: for stage in stages:
if isinstance(stage, (list, tuple)): if isinstance(stage, (list, tuple)):
@@ -425,7 +426,7 @@ class Pipeline(object):
while threads[-1].is_alive(): while threads[-1].is_alive():
threads[-1].join(1) threads[-1].join(1)
except: except BaseException:
# Stop all the threads immediately. # Stop all the threads immediately.
for thread in threads: for thread in threads:
thread.abort() thread.abort()
@@ -442,7 +443,7 @@ class Pipeline(object):
exc_info = thread.exc_info exc_info = thread.exc_info
if exc_info: if exc_info:
# Make the exception appear as it was raised originally. # Make the exception appear as it was raised originally.
six.reraise(exc_info[0], exc_info[1], exc_info[2]) raise exc_info[1].with_traceback(exc_info[2])
def pull(self): def pull(self):
"""Yield elements from the end of the pipeline. Runs the stages """Yield elements from the end of the pipeline. Runs the stages
@@ -469,6 +470,7 @@ class Pipeline(object):
for msg in msgs: for msg in msgs:
yield msg yield msg
# Smoke test. # Smoke test.
if __name__ == '__main__': if __name__ == '__main__':
import time import time
@@ -477,14 +479,14 @@ if __name__ == '__main__':
# in parallel. # in parallel.
def produce(): def produce():
for i in range(5): for i in range(5):
print(u'generating %i' % i) print('generating %i' % i)
time.sleep(1) time.sleep(1)
yield i yield i
def work(): def work():
num = yield num = yield
while True: while True:
print(u'processing %i' % num) print('processing %i' % num)
time.sleep(2) time.sleep(2)
num = yield num * 2 num = yield num * 2
@@ -492,7 +494,7 @@ if __name__ == '__main__':
while True: while True:
num = yield num = yield
time.sleep(1) time.sleep(1)
print(u'received %i' % num) print('received %i' % num)
ts_start = time.time() ts_start = time.time()
Pipeline([produce(), work(), consume()]).run_sequential() Pipeline([produce(), work(), consume()]).run_sequential()
@@ -501,22 +503,22 @@ if __name__ == '__main__':
ts_par = time.time() ts_par = time.time()
Pipeline([produce(), (work(), work()), consume()]).run_parallel() Pipeline([produce(), (work(), work()), consume()]).run_parallel()
ts_end = time.time() ts_end = time.time()
print(u'Sequential time:', ts_seq - ts_start) print('Sequential time:', ts_seq - ts_start)
print(u'Parallel time:', ts_par - ts_seq) print('Parallel time:', ts_par - ts_seq)
print(u'Multiply-parallel time:', ts_end - ts_par) print('Multiply-parallel time:', ts_end - ts_par)
print() print()
# Test a pipeline that raises an exception. # Test a pipeline that raises an exception.
def exc_produce(): def exc_produce():
for i in range(10): for i in range(10):
print(u'generating %i' % i) print('generating %i' % i)
time.sleep(1) time.sleep(1)
yield i yield i
def exc_work(): def exc_work():
num = yield num = yield
while True: while True:
print(u'processing %i' % num) print('processing %i' % num)
time.sleep(3) time.sleep(3)
if num == 3: if num == 3:
raise Exception() raise Exception()
@@ -525,6 +527,6 @@ if __name__ == '__main__':
def exc_consume(): def exc_consume():
while True: while True:
num = yield num = yield
print(u'received %i' % num) print('received %i' % num)
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1) Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
Executable → Regular
-2
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# This file is part of beets. # This file is part of beets.
# Copyright 2016, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
@@ -16,7 +15,6 @@
"""A simple utility for constructing filesystem-like trees from beets """A simple utility for constructing filesystem-like trees from beets
libraries. libraries.
""" """
from __future__ import division, absolute_import, print_function
from collections import namedtuple from collections import namedtuple
from beets import util from beets import util
+2 -1
View File
@@ -1,5 +1,5 @@
# This file is part of beets. # This file is part of beets.
# Copyright 2013, Adrian Sampson. # Copyright 2016, Adrian Sampson.
# #
# Permission is hereby granted, free of charge, to any person obtaining # Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the # a copy of this software and associated documentation files (the
@@ -14,6 +14,7 @@
"""A namespace package for beets plugins.""" """A namespace package for beets plugins."""
# Make this a namespace package. # Make this a namespace package.
from pkgutil import extend_path from pkgutil import extend_path
__path__ = extend_path(__path__, __name__) __path__ = extend_path(__path__, __name__)
+196
View File
@@ -0,0 +1,196 @@
# This file is part of beets.
# Copyright 2016, Pieter Mulder.
#
# 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.
"""Calculate acoustic information and submit to AcousticBrainz.
"""
import errno
import hashlib
import json
import os
import subprocess
import tempfile
from distutils.spawn import find_executable
import requests
from beets import plugins
from beets import util
from beets import ui
# We use this field to check whether AcousticBrainz info is present.
PROBE_FIELD = 'mood_acoustic'
class ABSubmitError(Exception):
"""Raised when failing to analyse file with extractor."""
def call(args):
"""Execute the command and return its output.
Raise a AnalysisABSubmitError on failure.
"""
try:
return util.command_output(args).stdout
except subprocess.CalledProcessError as e:
raise ABSubmitError(
'{} exited with status {}'.format(args[0], e.returncode)
)
class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'extractor': '',
'force': False,
'pretend': False
})
self.extractor = self.config['extractor'].as_str()
if self.extractor:
self.extractor = util.normpath(self.extractor)
# Expicit path to extractor
if not os.path.isfile(self.extractor):
raise ui.UserError(
'Extractor command does not exist: {0}.'.
format(self.extractor)
)
else:
# Implicit path to extractor, search for it in path
self.extractor = 'streaming_extractor_music'
try:
call([self.extractor])
except OSError:
raise ui.UserError(
'No extractor command found: please install the extractor'
' binary from https://acousticbrainz.org/download'
)
except ABSubmitError:
# Extractor found, will exit with an error if not called with
# the correct amount of arguments.
pass
# Get the executable location on the system, which we need
# to calculate the SHA-1 hash.
self.extractor = find_executable(self.extractor)
# Calculate extractor hash.
self.extractor_sha = hashlib.sha1()
with open(self.extractor, 'rb') as extractor:
self.extractor_sha.update(extractor.read())
self.extractor_sha = self.extractor_sha.hexdigest()
base_url = 'https://acousticbrainz.org/api/v1/{mbid}/low-level'
def commands(self):
cmd = ui.Subcommand(
'absubmit',
help='calculate and submit AcousticBrainz analysis'
)
cmd.parser.add_option(
'-f', '--force', dest='force_refetch',
action='store_true', default=False,
help='re-download data when already present'
)
cmd.parser.add_option(
'-p', '--pretend', dest='pretend_fetch',
action='store_true', default=False,
help='pretend to perform action, but show \
only files which would be processed'
)
cmd.func = self.command
return [cmd]
def command(self, lib, opts, args):
# Get items from arguments
items = lib.items(ui.decargs(args))
self.opts = opts
util.par_map(self.analyze_submit, items)
def analyze_submit(self, item):
analysis = self._get_analysis(item)
if analysis:
self._submit_data(item, analysis)
def _get_analysis(self, item):
mbid = item['mb_trackid']
# Avoid re-analyzing files that already have AB data.
if not self.opts.force_refetch and not self.config['force']:
if item.get(PROBE_FIELD):
return None
# If file has no MBID, skip it.
if not mbid:
self._log.info('Not analysing {}, missing '
'musicbrainz track id.', item)
return None
if self.opts.pretend_fetch or self.config['pretend']:
self._log.info('pretend action - extract item: {}', item)
return None
# Temporary file to save extractor output to, extractor only works
# if an output file is given. Here we use a temporary file to copy
# the data into a python object and then remove the file from the
# system.
tmp_file, filename = tempfile.mkstemp(suffix='.json')
try:
# Close the file, so the extractor can overwrite it.
os.close(tmp_file)
try:
call([self.extractor, util.syspath(item.path), filename])
except ABSubmitError as e:
self._log.warning(
'Failed to analyse {item} for AcousticBrainz: {error}',
item=item, error=e
)
return None
with open(filename) as tmp_file:
analysis = json.load(tmp_file)
# Add the hash to the output.
analysis['metadata']['version']['essentia_build_sha'] = \
self.extractor_sha
return analysis
finally:
try:
os.remove(filename)
except OSError as e:
# ENOENT means file does not exist, just ignore this error.
if e.errno != errno.ENOENT:
raise
def _submit_data(self, item, data):
mbid = item['mb_trackid']
headers = {'Content-Type': 'application/json'}
response = requests.post(self.base_url.format(mbid=mbid),
json=data, headers=headers)
# Test that request was successful and raise an error on failure.
if response.status_code != 200:
try:
message = response.json()['message']
except (ValueError, KeyError) as e:
message = f'unable to get error message: {e}'
self._log.error(
'Failed to submit AcousticBrainz analysis of {item}: '
'{message}).', item=item, message=message
)
else:
self._log.debug('Successfully submitted AcousticBrainz analysis '
'for {}.', item)

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