mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-10 00:32:52 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78f38c174 | ||
|
|
14f2a6d22c | ||
|
|
2e4299efa7 | ||
|
|
0610c2fa93 | ||
|
|
9add571886 | ||
|
|
fcf59a9b38 | ||
|
|
74f9e91afc | ||
|
|
83398cb102 | ||
|
|
61c2e1f821 | ||
|
|
3e3047aef2 | ||
|
|
fff44e4631 | ||
|
|
0964371de8 | ||
|
|
654f923a8d | ||
|
|
b91206c64a | ||
|
|
c9ba59ee9a | ||
|
|
b7e35d5ff0 | ||
|
|
9d82143abe | ||
|
|
eaf2db6c59 | ||
|
|
586b9ed3c8 | ||
|
|
d89f4171da | ||
|
|
9f7be5348b | ||
|
|
9c254ff222 | ||
|
|
ba969fd3b8 | ||
|
|
c851d5ed1a | ||
|
|
2223928958 | ||
|
|
164c3cacbc | ||
|
|
16d4ac8895 | ||
|
|
f4d60226b3 | ||
|
|
9ca87e23b2 | ||
|
|
d934c865c6 | ||
|
|
de74cd2502 | ||
|
|
f41db714a9 | ||
|
|
f03b82e5f6 | ||
|
|
e2db680b9e | ||
|
|
a3db89c11d | ||
|
|
517d0eb327 | ||
|
|
2bacd5a0fc | ||
|
|
5a559c526d | ||
|
|
095cee9368 | ||
|
|
79cb133d1d | ||
|
|
3a9b749017 | ||
|
|
0182be2f27 | ||
|
|
b6388f7daa | ||
|
|
0b3d0242fc | ||
|
|
9551e1b04a | ||
|
|
ab4dd18be4 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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']
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,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,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,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,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,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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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,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,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,4 +1,4 @@
|
|||||||
from __future__ import absolute_import
|
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Executable → Regular
+7
-9
@@ -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
File diff suppressed because it is too large
Load Diff
Executable → Regular
+304
-42
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Executable → Regular
+5
-5
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Executable → Regular
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user