From 3f23b5553a47a59f9682fca635ae8a18ad2f79e0 Mon Sep 17 00:00:00 2001 From: VoidVolker Date: Fri, 8 Jul 2016 14:39:36 +0300 Subject: [PATCH 001/137] [+] --host command line option --- Headphones.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Headphones.py b/Headphones.py index dc956a43..e4506797 100755 --- a/Headphones.py +++ b/Headphones.py @@ -81,6 +81,8 @@ def main(): help='Prevent browser from launching on startup') parser.add_argument( '--pidfile', help='Create a pid file (only relevant when running as a daemon)') + parser.add_argument( + '--host', help='Specify a host (default - localhost)') args = parser.parse_args() @@ -170,6 +172,13 @@ def main(): else: http_port = int(headphones.CONFIG.HTTP_PORT) + # Force the http host if neccessary + if args.host: + http_host = args.host + logger.info('Using forced web server host: %s', http_host) + else: + http_host = headphones.CONFIG.HTTP_HOST + # Check if pyOpenSSL is installed. It is required for certificate generation # and for CherryPy. if headphones.CONFIG.ENABLE_HTTPS: @@ -183,7 +192,7 @@ def main(): # Try to start the server. Will exit here is address is already in use. web_config = { 'http_port': http_port, - 'http_host': headphones.CONFIG.HTTP_HOST, + 'http_host': http_host, 'http_root': headphones.CONFIG.HTTP_ROOT, 'http_proxy': headphones.CONFIG.HTTP_PROXY, 'enable_https': headphones.CONFIG.ENABLE_HTTPS, From a7a709df517db57ab2fef71fb52e805b48e0d401 Mon Sep 17 00:00:00 2001 From: VoidVolker Date: Fri, 8 Jul 2016 14:42:19 +0300 Subject: [PATCH 002/137] [+] HP_HOST option --- init-scripts/init.ubuntu | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/init-scripts/init.ubuntu b/init-scripts/init.ubuntu index c4c0b8dc..9a7e094f 100755 --- a/init-scripts/init.ubuntu +++ b/init-scripts/init.ubuntu @@ -105,7 +105,12 @@ load_settings() { PORT_OPTS=" --port=${HP_PORT} " } - DAEMON_OPTS=" Headphones.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS}${EXTRA_DAEMON_OPTS}" + # Host config + [ -n "$HP_HOST" ] && { + HOST_OPTS=" --host=${HP_HOST} " + } + + DAEMON_OPTS=" Headphones.py --quiet --daemon --nolaunch --pidfile=${PID_FILE} --datadir=${DATA_DIR} ${PORT_OPTS} ${HOST_OPTS} ${EXTRA_DAEMON_OPTS}" SETTINGS_LOADED=TRUE fi From 9a681a94226c07ba65c0511508c3c1096f08f426 Mon Sep 17 00:00:00 2001 From: VoidVolker Date: Fri, 8 Jul 2016 14:44:01 +0300 Subject: [PATCH 003/137] [=] HP_HOST description --- init-scripts/init.ubuntu | 1 + 1 file changed, 1 insertion(+) diff --git a/init-scripts/init.ubuntu b/init-scripts/init.ubuntu index 9a7e094f..47f2f2b4 100755 --- a/init-scripts/init.ubuntu +++ b/init-scripts/init.ubuntu @@ -33,6 +33,7 @@ ## PYTHON_BIN= #$DAEMON, the location of the python binary, the default is /usr/bin/python ## HP_OPTS= #$EXTRA_DAEMON_OPTS, extra cli option for headphones, i.e. " --config=/home/headphones/config.ini" ## HP_PORT= #$PORT_OPTS, hardcoded port for the webserver, overrides value in config.ini +## HP_HOST= #$HOST_OPTS, host for the webserver, overrides value in config.ini ## ## EXAMPLE if want to run as different user ## add HP_USER=username to /etc/default/headphones From 323d3645721dbf843743b60c23f8cd27a9deb3f2 Mon Sep 17 00:00:00 2001 From: Bryon Roche Date: Thu, 14 Jul 2016 05:20:39 -0700 Subject: [PATCH 004/137] Added missing support method for current FauxSocket for pypy --- lib/cherrypy/wsgiserver/wsgiserver2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/cherrypy/wsgiserver/wsgiserver2.py b/lib/cherrypy/wsgiserver/wsgiserver2.py index b4919cf5..ee1f8214 100644 --- a/lib/cherrypy/wsgiserver/wsgiserver2.py +++ b/lib/cherrypy/wsgiserver/wsgiserver2.py @@ -99,12 +99,14 @@ DEFAULT_BUFFER_SIZE = -1 class FauxSocket(object): - """Faux socket with the minimal interface required by pypy""" def _reuse(self): pass + def _drop(self): + pass + _fileobject_uses_str_type = isinstance( socket._fileobject(FauxSocket())._rbuf, basestring) del FauxSocket # this class is not longer required for anything. From c0c64621e64db8abcc43408e64aa3f2a48997b85 Mon Sep 17 00:00:00 2001 From: Bryon Roche Date: Thu, 14 Jul 2016 05:22:25 -0700 Subject: [PATCH 005/137] Add a thread-dumping handler Add a 'crier' handler to dump the thread stacks of running threads to the log, for easier debugging. --- headphones/crier.py | 36 ++++++++++++++++++++++++++++++++++++ headphones/webserve.py | 7 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 headphones/crier.py diff --git a/headphones/crier.py b/headphones/crier.py new file mode 100644 index 00000000..2399f6a9 --- /dev/null +++ b/headphones/crier.py @@ -0,0 +1,36 @@ +import pprint +import sys +import threading +import traceback + +from headphones import logger + + +def cry(): + """ + Logs thread traces. + """ + tmap = {} + main_thread = None + # get a map of threads by their ID so we can print their names + # during the traceback dump + for t in threading.enumerate(): + if t.ident: + tmap[t.ident] = t + else: + main_thread = t + + # Loop over each thread's current frame, writing info about it + for tid, frame in sys._current_frames().iteritems(): + thread = tmap.get(tid, main_thread) + + lines = [] + lines.append('%s\n' % thread.getName()) + lines.append('========================================\n') + lines += traceback.format_stack(frame) + lines.append('========================================\n') + lines.append('LOCAL VARIABLES:\n') + lines.append('========================================\n') + lines.append(pprint.pformat(frame.f_locals)) + lines.append('\n\n') + logger.info("".join(lines)) diff --git a/headphones/webserve.py b/headphones/webserve.py index f4a06c2b..249bf9f9 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -28,7 +28,7 @@ import urllib2 import os import re -from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers +from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers, crier from headphones.helpers import checked, radio, today, clean_name from mako.lookup import TemplateLookup from mako import exceptions @@ -69,6 +69,11 @@ class WebInterface(object): artists = myDB.select('SELECT * from artists order by ArtistSortName COLLATE NOCASE') return serve_template(templatename="index.html", title="Home", artists=artists) + @cherrypy.expose + def threads(self): + crier.cry() + raise cherrypy.HTTPRedirect("home") + @cherrypy.expose def artistPage(self, ArtistID): myDB = db.DBConnection() From d3584a5ae9d2e7acc68ac131b9408da9f8bac544 Mon Sep 17 00:00:00 2001 From: clowninasack Date: Mon, 1 Aug 2016 10:30:10 -0700 Subject: [PATCH 006/137] Fixed FreeBSD init script. --- init-scripts/init.freebsd | 53 ++++++++++++--------------------------- 1 file changed, 16 insertions(+), 37 deletions(-) diff --git a/init-scripts/init.freebsd b/init-scripts/init.freebsd index 85d2cf14..bd4cca2a 100755 --- a/init-scripts/init.freebsd +++ b/init-scripts/init.freebsd @@ -1,7 +1,8 @@ #!/bin/sh # # PROVIDE: headphones -# REQUIRE: DAEMON sabnzbd +# REQUIRE: DAEMON +# BEFORE: LOGIN # KEYWORD: shutdown # # Add the following lines to /etc/rc.conf.local or /etc/rc.conf @@ -15,56 +16,34 @@ # as root. # headphones_dir: Directory where Headphones lives. # Default: /usr/local/headphones -# headphones_chdir: Change to this directory before running Headphones. -# Default is same as headphones_dir. # headphones_pid: The name of the pidfile to create. # Default is headphones.pid in headphones_dir. -PATH="/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" . /etc/rc.subr name="headphones" rcvar=${name}_enable -load_rc_config ${name} - : "${headphones_enable:="NO"}" : "${headphones_user:="_sabnzbd"}" : "${headphones_dir:="/usr/local/headphones"}" -: "${headphones_chdir:="${headphones_dir}"}" -: "${headphones_pid:="${headphones_dir}/headphones.pid"}" +: "${headphones_conf:="/usr/local/headphones/config.ini"}" -status_cmd="${name}_status" -stop_cmd="${name}_stop" +command="${headphones_dir}/Headphones.py" +command_interpreter="/usr/bin/python" +pidfile="/var/run/headphones/headphones.pid" +start_precmd="headphones_start_precmd" +headphones_flags="--daemon --nolaunch --pidfile $pidfile --config $headphones_conf $headphones_flags" -command="/usr/sbin/daemon" -command_args="-f -p ${headphones_pid} python ${headphones_dir}/Headphones.py ${headphones_flags} --quiet --nolaunch" +headphones_start_precmd() { + if [ $($ID -u) != 0 ]; then + err 1 "Must be root." + fi -# Ensure user is root when running this script. -if [ "$(id -u)" != "0" ]; then - echo "Oops, you should be root before running this!" - exit 1 -fi - -verify_headphones_pid() { - # Make sure the pid corresponds to the Headphones process. - pid=$(cat "${headphones_pid}" 2>/dev/null) - pgrep -F "${headphones_pid}" -q "python ${headphones_dir}/Headphones.py" - return $? -} - -# Try to stop Headphones cleanly by calling shutdown over http. -headphones_stop() { - echo "Stopping $name" - verify_headphones_pid - if [ -n "${pid}" ]; then - wait_for_pids "${pid}" - echo "Stopped" - fi -} - -headphones_status() { - verify_headphones_pid && echo "$name is running as ${pid}" || echo "$name is not running" + if [ ! -d /var/run/headphones ]; then + install -do $headphones_user /var/run/headphones + fi } +load_rc_config ${name} run_rc_command "$1" From 8eb3ece2f9a1480cebd9c64bb2fd8d6b89527f34 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 17 Sep 2016 20:27:01 +1200 Subject: [PATCH 007/137] rutracker login fix stop redirecting to the same login page --- headphones/rutracker.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/headphones/rutracker.py b/headphones/rutracker.py index af8e947e..a4141c38 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -44,22 +44,18 @@ class Rutracker(object): logger.info("Attempting to log in to rutracker...") try: - r = self.session.post(loginpage, data=post_params, timeout=self.timeout) + r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) # try again if 'bb_data' not in r.cookies.keys(): time.sleep(10) - r = self.session.post(loginpage, data=post_params, timeout=self.timeout) - if r.status_code != 200: - logger.error("rutracker login returned status code %s" % r.status_code) - self.loggedin = False + r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) + if 'bb_data' in r.cookies.keys(): + self.loggedin = True + logger.info("Successfully logged in to rutracker") else: - if 'bb_data' in r.cookies.keys(): - self.loggedin = True - logger.info("Successfully logged in to rutracker") - else: - logger.error( - "Could not login to rutracker, credentials maybe incorrect, site is down or too many attempts. Try again later") - self.loggedin = False + logger.error( + "Could not login to rutracker, credentials maybe incorrect, site is down or too many attempts. Try again later") + self.loggedin = False return self.loggedin except Exception as e: logger.error("Unknown error logging in to rutracker: %s" % e) From 99680e9a0f4f61710fb7fc75e5f094d35b5c62c9 Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 8 Oct 2016 00:46:12 +0200 Subject: [PATCH 008/137] Update searcher.py to add t411 support --- headphones/searcher.py | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/headphones/searcher.py b/headphones/searcher.py index c8f16184..ff7e4736 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -14,6 +14,7 @@ # along with Headphones. If not, see . # NZBGet support added by CurlyMo as a part of XBian - XBMC on the Raspberry Pi +# t411 support added by a1ex, @likeitneverwentaway on github for maintenance from base64 import b16encode, b32decode from hashlib import sha1 @@ -812,6 +813,19 @@ def send_to_downloader(data, bestqual, album): # Get torrent name from .torrent, this is usually used by the torrent client as the folder name torrent_name = helpers.replace_illegal_chars(folder_name) + '.torrent' download_path = os.path.join(headphones.CONFIG.TORRENTBLACKHOLE_DIR, torrent_name) + + # Blackhole for t411 + if bestqual[2].lower().startswith("http://api.t411"): + if headphones.CONFIG.MAGNET_LINKS == 2: + try: + url = bestqual[2].split('TOKEN')[0] + token = bestqual[2].split('TOKEN')[1] + data = request.request_content(url, headers = {'Authorization':token}) + torrent_to_file(download_path, data) + logger.info('Successfully converted magnet to torrent file') + except Exception as e: + logger.error("Error converting magnet link: %s" % str(e)) + return if bestqual[2].lower().startswith("magnet:"): if headphones.CONFIG.MAGNET_LINKS == 1: @@ -1763,6 +1777,78 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, resultlist.append((title, size, url, provider, 'torrent', match)) except Exception as e: logger.exception("Unhandled exception in Mininova Parser") + # t411 + if headphones.CONFIG.TQUATTRECENTONZE: + username = headphones.CONFIG.TQUATTRECENTONZE_USER + password = headphones.CONFIG.TQUATTRECENTONZE_PASSWORD + API_URL = "http://api.t411.ch" + AUTH_URL= API_URL + '/auth' + DL_URL = API_URL + '/torrents/download/' + provider = "t411" + t411_term = term.replace(" ","%20") + SEARCH_URL = API_URL + '/torrents/search/' + t411_term + "?limit=15&cid=395&subcat=623" + headers_login = {'username': username, 'password' : password} + + # Requesting content + logger.info('Parsing results from t411 using search term: %s' % term) + req = request.request_content(AUTH_URL, method='post',data = headers_login) + + if len(req.split('"')) == 9: + token =req.split('"')[7] + headers_auth = {'Authorization':token} + logger.info('t411 - User %s logged in' % username) + else: + logger.info('t411 - Login error : %s' % req.split('"')[3]) + + # Quality + if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly: + providerurl = "&term[16][]=529&term[16][]=1184" + elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: + providerurl = fix_url(SEARCH_URL + "&term[16][]=685&term[16][]=527&term[16][]=1070&term[16][]=528&term[16][]=1167&term[16][]=1166&term[16][]=530&term[16][]=529&term[16][]=1184&term[16][]=532&term[16][]=533&term[16][]=1085&term[16][]=534&term[16][]=535&term[16][]=1069&term[16][]=537&term[16][]=538") + elif headphones.CONFIG.PREFERRED_QUALITY == 0: + providerurl = fix_url(SEARCH_URL + "&term[16][]=685&term[16][]=527&term[16][]=1070&term[16][]=528&term[16][]=1167&term[16][]=1166&term[16][]=530&term[16][]=532&term[16][]=533&term[16][]=1085&term[16][]=534&term[16][]=535&term[16][]=1069&term[16][]=537&term[16][]=538") + else: + providerurl = fix_url(SEARCH_URL) + + # Tracker search + req = request.request_content(providerurl, headers = headers_auth) + req = json.loads(req) + total = req['total'] + + # Process feed + if total == '0': + logger.info("No results found from t411 for %s" % term) + else: + logger.info('Found %s results from t411' % total) + torrents = req['torrents'] + for torrent in torrents: + try: + title = torrent['name'] + if torrent['seeders'] < minimumseeders: + logger.info('Skipping torrent %s : seeders below minimum set' % title) + continue + id = torrent['id'] + size = int(torrent['size']) + data = request.request_content(DL_URL + id, headers = headers_auth) + + #Blackhole + if headphones.CONFIG.TORRENT_DOWNLOADER == 0 and headphones.CONFIG.MAGNET_LINKS == 2: + url = DL_URL + id + 'TOKEN' + token + resultlist.append((title, size, url, provider, 'torrent', True)) + + # Build magnet + else: + metadata = bdecode(data) + hashcontents = bencode(metadata['info']) + digest = sha1(hashcontents).hexdigest() + trackers = [metadata["announce"]][0] + url = 'magnet:?xt=urn:btih:%s&tr=%s' % (digest, trackers) + resultlist.append((title, size, url, provider, 'torrent', True)) + + except Exception as e: + logger.error("Error converting magnet link: %s" % str(e)) + return + # attempt to verify that this isn't a substring result # when looking for "Foo - Foo" we don't want "Foobar" From bd8eb74662d370ac4394876afe962c0d70684ab2 Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 8 Oct 2016 00:50:48 +0200 Subject: [PATCH 009/137] Update config.html to add t411 support --- data/interfaces/default/config.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index a22a0ea7..baf39586 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -743,6 +743,22 @@ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
@@ -2400,6 +2416,7 @@ initConfigCheckbox("#api_enabled"); initConfigCheckbox("#enable_https"); initConfigCheckbox("#customauth"); + initConfigCheckbox("#use_tquattrecentonze"); $('#twitterStep1').click(function () { From d3d48d27df71ab2f67554347c91a6254bfb04d72 Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 8 Oct 2016 01:00:49 +0200 Subject: [PATCH 010/137] Update webserve.py to add t411 support --- headphones/webserve.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/headphones/webserve.py b/headphones/webserve.py index 249bf9f9..1337894c 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1229,6 +1229,9 @@ class WebInterface(object): "whatcd_ratio": headphones.CONFIG.WHATCD_RATIO, "use_strike": checked(headphones.CONFIG.STRIKE), "strike_ratio": headphones.CONFIG.STRIKE_RATIO, + "use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE), + "tquattrecentonze_user": headphones.CONFIG.TQUATTRECENTONZE_USER, + "tquattrecentonze_password": headphones.CONFIG.TQUATTRECENTONZE_PASSWORD, "pref_qual_0": radio(headphones.CONFIG.PREFERRED_QUALITY, 0), "pref_qual_1": radio(headphones.CONFIG.PREFERRED_QUALITY, 1), "pref_qual_2": radio(headphones.CONFIG.PREFERRED_QUALITY, 2), @@ -1425,7 +1428,7 @@ class WebInterface(object): "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay", "use_mininova", "use_waffles", "use_rutracker", - "use_whatcd", "use_strike", "preferred_bitrate_allow_lossless", "detect_bitrate", + "use_whatcd", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", "embed_album_art", "embed_lyrics", From aa503f0fe88a1ff4fd6dee49211500bc62be7a22 Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 8 Oct 2016 01:01:39 +0200 Subject: [PATCH 011/137] Update config.py to add t411 support --- headphones/config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/headphones/config.py b/headphones/config.py index 5b819007..e825cc68 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -274,6 +274,9 @@ _CONFIG_DEFINITIONS = { 'TWITTER_PASSWORD': (str, 'Twitter', ''), 'TWITTER_PREFIX': (str, 'Twitter', 'Headphones'), 'TWITTER_USERNAME': (str, 'Twitter', ''), + 'TQUATTRECENTONZE': (int, 'tquattrecentonze', 0), + 'TQUATTRECENTONZE_PASSWORD': (str, 'tquattrecentonze', ''), + 'TQUATTRECENTONZE_USER': (str, 'tquattrecentonze', ''), 'UPDATE_DB_INTERVAL': (int, 'General', 24), 'USENET_RETENTION': (int, 'General', '1500'), 'UTORRENT_HOST': (str, 'uTorrent', ''), From 31a99a908afa91b8ada2e2d6a5c4c5f9722cba8f Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 8 Oct 2016 01:08:54 +0200 Subject: [PATCH 012/137] Added credit --- headphones/webserve.py | 1 + 1 file changed, 1 insertion(+) diff --git a/headphones/webserve.py b/headphones/webserve.py index 1337894c..9b09feba 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -14,6 +14,7 @@ # along with Headphones. If not, see . # NZBGet support added by CurlyMo as a part of XBian - XBMC on the Raspberry Pi +# t411 support added by a1ex, @likeitneverwentaway on github for maintenance from operator import itemgetter import threading From 42294d261d1cce82ec3e8be593671d00d257daba Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 8 Oct 2016 01:55:55 +0200 Subject: [PATCH 013/137] Fix typo, import loads from json --- headphones/searcher.py | 44 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index ff7e4736..6c7fe8cf 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -25,6 +25,7 @@ import datetime import subprocess import unicodedata import urlparse +from json import loads import os import re @@ -813,14 +814,14 @@ def send_to_downloader(data, bestqual, album): # Get torrent name from .torrent, this is usually used by the torrent client as the folder name torrent_name = helpers.replace_illegal_chars(folder_name) + '.torrent' download_path = os.path.join(headphones.CONFIG.TORRENTBLACKHOLE_DIR, torrent_name) - + # Blackhole for t411 if bestqual[2].lower().startswith("http://api.t411"): if headphones.CONFIG.MAGNET_LINKS == 2: try: url = bestqual[2].split('TOKEN')[0] token = bestqual[2].split('TOKEN')[1] - data = request.request_content(url, headers = {'Authorization':token}) + data = request.request_content(url, headers={'Authorization': token}) torrent_to_file(download_path, data) logger.info('Successfully converted magnet to torrent file') except Exception as e: @@ -1782,24 +1783,24 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, username = headphones.CONFIG.TQUATTRECENTONZE_USER password = headphones.CONFIG.TQUATTRECENTONZE_PASSWORD API_URL = "http://api.t411.ch" - AUTH_URL= API_URL + '/auth' + AUTH_URL = API_URL + '/auth' DL_URL = API_URL + '/torrents/download/' provider = "t411" - t411_term = term.replace(" ","%20") + t411_term = term.replace(" ", "%20") SEARCH_URL = API_URL + '/torrents/search/' + t411_term + "?limit=15&cid=395&subcat=623" - headers_login = {'username': username, 'password' : password} - + headers_login = {'username': username, 'password': password} + # Requesting content logger.info('Parsing results from t411 using search term: %s' % term) - req = request.request_content(AUTH_URL, method='post',data = headers_login) - + req = request.request_content(AUTH_URL, method='post', data=headers_login) + if len(req.split('"')) == 9: - token =req.split('"')[7] - headers_auth = {'Authorization':token} + token = req.split('"')[7] + headers_auth = {'Authorization': token} logger.info('t411 - User %s logged in' % username) else: logger.info('t411 - Login error : %s' % req.split('"')[3]) - + # Quality if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly: providerurl = "&term[16][]=529&term[16][]=1184" @@ -1809,12 +1810,12 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, providerurl = fix_url(SEARCH_URL + "&term[16][]=685&term[16][]=527&term[16][]=1070&term[16][]=528&term[16][]=1167&term[16][]=1166&term[16][]=530&term[16][]=532&term[16][]=533&term[16][]=1085&term[16][]=534&term[16][]=535&term[16][]=1069&term[16][]=537&term[16][]=538") else: providerurl = fix_url(SEARCH_URL) - + # Tracker search - req = request.request_content(providerurl, headers = headers_auth) - req = json.loads(req) + req = request.request_content(providerurl, headers=headers_auth) + req = loads(req) total = req['total'] - + # Process feed if total == '0': logger.info("No results found from t411 for %s" % term) @@ -1829,26 +1830,25 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, continue id = torrent['id'] size = int(torrent['size']) - data = request.request_content(DL_URL + id, headers = headers_auth) - - #Blackhole + data = request.request_content(DL_URL + id, headers=headers_auth) + + # Blackhole if headphones.CONFIG.TORRENT_DOWNLOADER == 0 and headphones.CONFIG.MAGNET_LINKS == 2: url = DL_URL + id + 'TOKEN' + token resultlist.append((title, size, url, provider, 'torrent', True)) - + # Build magnet else: metadata = bdecode(data) hashcontents = bencode(metadata['info']) digest = sha1(hashcontents).hexdigest() trackers = [metadata["announce"]][0] - url = 'magnet:?xt=urn:btih:%s&tr=%s' % (digest, trackers) + url = 'magnet:?xt=urn:btih:%s&tr=%s' % (digest, trackers) resultlist.append((title, size, url, provider, 'torrent', True)) - + except Exception as e: logger.error("Error converting magnet link: %s" % str(e)) return - # attempt to verify that this isn't a substring result # when looking for "Foo - Foo" we don't want "Foobar" From 7428b56dd56ca5cfb258174607fe9578e5e83194 Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 9 Oct 2016 15:56:25 +1300 Subject: [PATCH 014/137] Remove mb apostrophe Fixes #2737 --- headphones/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/headphones/helpers.py b/headphones/helpers.py index ff062d57..af1a13e0 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -251,6 +251,7 @@ _XLATE_SPECIAL = { # Translation table. # Cover additional special characters processing normalization. u"'": '', # replace apostrophe with nothing + u"’": '', # replace musicbrainz style apostrophe with nothing u'&': ' and ', # expand & to ' and ' } From c9a1796e569797f4d7ab9c1d02e4910f90359cbd Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 15 Oct 2016 09:59:11 +1300 Subject: [PATCH 015/137] Allow hyphen in mb search Fixes #2499 --- headphones/mb.py | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/headphones/mb.py b/headphones/mb.py index 397e5008..faba6b8a 100644 --- a/headphones/mb.py +++ b/headphones/mb.py @@ -91,10 +91,6 @@ def findArtist(name, limit=1): artistlist = [] artistResults = None - chars = set('!?*-') - if any((c in chars) for c in name): - name = '"' + name + '"' - criteria = {'artist': name.lower()} with mb_lock: @@ -156,16 +152,13 @@ def findRelease(name, limit=1, artist=None): if not artist and ':' in name: name, artist = name.rsplit(":", 1) - chars = set('!?*-') - if any((c in chars) for c in name): - name = '"' + name + '"' - if artist and any((c in chars) for c in artist): - artist = '"' + artist + '"' + criteria = {'release': name.lower()} + if artist: + criteria['artist'] = artist.lower() with mb_lock: try: - releaseResults = musicbrainzngs.search_releases(query=name, limit=limit, artist=artist)[ - 'release-list'] + releaseResults = musicbrainzngs.search_releases(limit=limit, **criteria)['release-list'] except musicbrainzngs.WebServiceError as e: # need to update exceptions logger.warn('Attempt to query MusicBrainz for "%s" failed: %s' % (name, str(e))) mb_lock.snooze(5) @@ -234,10 +227,6 @@ def findSeries(name, limit=1): serieslist = [] seriesResults = None - chars = set('!?*-') - if any((c in chars) for c in name): - name = '"' + name + '"' - criteria = {'series': name.lower()} with mb_lock: @@ -759,19 +748,12 @@ def findArtistbyAlbum(name): def findAlbumID(artist=None, album=None): results = None - chars = set('!?*-') try: if album and artist: - if any((c in chars) for c in album): - album = '"' + album + '"' - if any((c in chars) for c in artist): - artist = '"' + artist + '"' criteria = {'release': album.lower()} criteria['artist'] = artist.lower() else: - if any((c in chars) for c in album): - album = '"' + album + '"' criteria = {'release': album.lower()} with mb_lock: results = musicbrainzngs.search_release_groups(limit=1, **criteria).get( From b58f670694ad13ba105d4fd0e8de048377bbbff9 Mon Sep 17 00:00:00 2001 From: etomm Date: Sat, 15 Oct 2016 10:08:51 +0200 Subject: [PATCH 016/137] Handling Deluge empty password --- headphones/deluge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index f97063d3..6c105054 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -269,7 +269,8 @@ def _get_auth(): delugeweb_host = headphones.CONFIG.DELUGE_HOST delugeweb_cert = headphones.CONFIG.DELUGE_CERT delugeweb_password = headphones.CONFIG.DELUGE_PASSWORD - logger.debug('Deluge: Using password %s******%s' % (delugeweb_password[0], delugeweb_password[-1])) + if len(delugeweb_password) > 0: + logger.debug('Deluge: Using password %s******%s' % (delugeweb_password[0], delugeweb_password[-1])) if not delugeweb_host.startswith('http'): delugeweb_host = 'http://%s' % delugeweb_host From 89e17939783992309cc52895461455f8b38f2481 Mon Sep 17 00:00:00 2001 From: likeitneverwentaway Date: Sat, 15 Oct 2016 14:39:55 +0200 Subject: [PATCH 017/137] Fixed lossless only for t411 --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 6c7fe8cf..cda9a822 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1803,7 +1803,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, # Quality if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly: - providerurl = "&term[16][]=529&term[16][]=1184" + providerurl = fix_url(SEARCH_URL + "&term[16][]=529&term[16][]=1184") elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: providerurl = fix_url(SEARCH_URL + "&term[16][]=685&term[16][]=527&term[16][]=1070&term[16][]=528&term[16][]=1167&term[16][]=1166&term[16][]=530&term[16][]=529&term[16][]=1184&term[16][]=532&term[16][]=533&term[16][]=1085&term[16][]=534&term[16][]=535&term[16][]=1069&term[16][]=537&term[16][]=538") elif headphones.CONFIG.PREFERRED_QUALITY == 0: From 0086980b1b4a8d5d2e98b8ff081ad66baafc94a6 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Wed, 19 Oct 2016 12:41:46 +1100 Subject: [PATCH 018/137] Fix Rutracker loggin by looking for the cookie in request history --- headphones/rutracker.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/headphones/rutracker.py b/headphones/rutracker.py index a4141c38..b80685cf 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -46,10 +46,10 @@ class Rutracker(object): try: r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) # try again - if 'bb_data' not in r.cookies.keys(): + if not self.has_bb_data_cookie(r): time.sleep(10) r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) - if 'bb_data' in r.cookies.keys(): + if self.has_bb_data_cookie(r): self.loggedin = True logger.info("Successfully logged in to rutracker") else: @@ -62,6 +62,12 @@ class Rutracker(object): self.loggedin = False return self.loggedin + def has_bb_data_cookie(self, response): + if 'bb_data' in response.cookies.keys(): + return True + # Rutracker randomly send a 302 redirect code, cookie may be present in response history + return next(('bb_data' in r.cookies.keys() for r in response.history), False) + def searchurl(self, artist, album, year, format): """ Return the search url From c77f43be5e43a176890fbf1bb5ef3964c3c225b9 Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Tue, 25 Oct 2016 19:21:16 +1100 Subject: [PATCH 019/137] Accept partial release date for search --- headphones/searcher.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index cda9a822..1efb657e 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -201,14 +201,13 @@ def searchforalbum(albumid=None, new=False, losslessOnly=False, continue if headphones.CONFIG.WAIT_UNTIL_RELEASE_DATE and album['ReleaseDate']: - try: - release_date = datetime.datetime.strptime(album['ReleaseDate'], "%Y-%m-%d") - except: - logger.warn( - "No valid date for: %s. Skipping automatic search" % album['AlbumTitle']) + release_date = strptime_musicbrainz(album['ReleaseDate']) + if not release_date: + logger.warn("No valid date for: %s. Skipping automatic search" % + album['AlbumTitle']) continue - if release_date > datetime.datetime.today(): + elif release_date > datetime.datetime.today(): logger.info("Skipping: %s. Waiting for release date of: %s" % ( album['AlbumTitle'], album['ReleaseDate'])) continue @@ -237,6 +236,26 @@ def searchforalbum(albumid=None, new=False, losslessOnly=False, logger.info('Search for wanted albums complete') +def strptime_musicbrainz(date_str): + """ + Release date as returned by Musicbrainz may contain the full date (Year-Month-Day) + but it may as well be just Year-Month or even just the year. + + Args: + date_str: the date as a string (ex: "2003-05-01", "2003-03", "2003") + + Returns: + The more accurate datetime object we can create or None if parse failed + """ + acceptable_formats = ('%Y-%m-%d', '%Y-%m', '%Y') + for date_format in acceptable_formats: + try: + return datetime.datetime.strptime(date_str, date_format) + except: + pass + return None + + def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): NZB_PROVIDERS = (headphones.CONFIG.HEADPHONES_INDEXER or headphones.CONFIG.NEWZNAB or From 579b5a2f2fa7d9926bb3187c5d8ea08f97afd17c Mon Sep 17 00:00:00 2001 From: rembo10 Date: Thu, 10 Nov 2016 20:56:51 +0000 Subject: [PATCH 020/137] Updated changelog for v0.5.17 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1037f8e0..087baab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.5.17 +Released 10 November 2016 + +Highlights: +* Added: t411 support +* Fixed: Rutracker login +* Fixed: Deluge empty password +* Fixed: FreeBSD init script +* Improved: Musicbrainz searching + +The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.16...v0.5.17). + ## v0.5.16 Released 10 June 2016 From 92601e0eb8a9926b24b7718558a1d92e32be824a Mon Sep 17 00:00:00 2001 From: William Friesen Date: Sat, 19 Nov 2016 12:15:22 +1100 Subject: [PATCH 021/137] Use HTML escaping for password fields Fixes rembo10/headphones#2474 --- data/interfaces/default/config.html | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index baf39586..99d1d4ec 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -56,7 +56,7 @@ - +
@@ -173,7 +173,7 @@ - +
- +
Note: With Transmission, you can specify a different download directory for downloads sent from Headphones. @@ -379,7 +379,7 @@
- +
@@ -400,7 +400,7 @@
- +
Note: With Deluge, you can specify a different download directory for downloads sent from Headphones. @@ -466,7 +466,7 @@
- +
Don't have an account? Sign up! @@ -622,7 +622,7 @@
- +
@@ -642,7 +642,7 @@
- +
@@ -755,7 +755,7 @@
- +
@@ -980,7 +980,7 @@
- +
@@ -1006,7 +1006,7 @@
- +
@@ -1028,7 +1028,7 @@
- +
@@ -1142,7 +1142,7 @@ Username of your Plex client API (blank for none)
- + Password of your Plex client API (blank for none)
@@ -1242,7 +1242,7 @@
- +
@@ -1642,7 +1642,7 @@
-
+
@@ -1655,7 +1655,7 @@
-
+
Get an Account!
From 36f75f44e9321ffe50f9a7f5064655e9235bdd40 Mon Sep 17 00:00:00 2001 From: Noam Date: Sat, 19 Nov 2016 22:58:57 +0200 Subject: [PATCH 022/137] Changed some logging to better understand issue #2734 --- headphones/deluge.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 6c105054..6e7feb8b 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -135,7 +135,10 @@ def addTorrent(link, data=None, name=None): # remove '.torrent' suffix if name[-len('.torrent'):] == '.torrent': name = name[:-len('.torrent')] - logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) + try: + logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) + except: + logger.debug('Deluge: Sending Deluge torrent with problematic name and some content') result = {'type': 'torrent', 'name': name, 'content': torrentfile} @@ -445,6 +448,7 @@ def _add_torrent_file(result): 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'] + '.torrent', b64encode(result['content']), {}], "id": 22}) From dff1ac2e41381ccc1b2e6ec7cfa13df32442ad0d Mon Sep 17 00:00:00 2001 From: massiliattak Date: Sun, 20 Nov 2016 20:15:37 +0100 Subject: [PATCH 023/137] Update T411 new domain ( t411.li ) --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 1efb657e..ac938972 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1801,7 +1801,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if headphones.CONFIG.TQUATTRECENTONZE: username = headphones.CONFIG.TQUATTRECENTONZE_USER password = headphones.CONFIG.TQUATTRECENTONZE_PASSWORD - API_URL = "http://api.t411.ch" + API_URL = "http://api.t411.li" AUTH_URL = API_URL + '/auth' DL_URL = API_URL + '/torrents/download/' provider = "t411" From b6b33e1b1e4969e5e359037f9236ce4b57419b17 Mon Sep 17 00:00:00 2001 From: Denzo Date: Tue, 29 Nov 2016 20:49:52 +0100 Subject: [PATCH 024/137] + Added support for PassTheHeadphones.me % Removed hardcoding for What.CD in pygazelle API, making it easy to add other Gazelle-based trackers + Added URL parameter for What.CD and PTH, in case they come back under a new domain name --- data/interfaces/default/config.html | 29 ++++++++ headphones/config.py | 6 ++ headphones/searcher.py | 109 +++++++++++++++++++++++++++- headphones/webserve.py | 6 ++ lib/pygazelle/api.py | 8 +- 5 files changed, 152 insertions(+), 6 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index baf39586..d6018ff0 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -644,6 +644,10 @@ +
+ + +
@@ -651,6 +655,30 @@
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
@@ -2412,6 +2440,7 @@ initConfigCheckbox("#use_waffles"); initConfigCheckbox("#use_rutracker"); initConfigCheckbox("#use_whatcd"); + initConfigCheckbox("#use_pth"); initConfigCheckbox("#use_strike"); initConfigCheckbox("#api_enabled"); initConfigCheckbox("#enable_https"); diff --git a/headphones/config.py b/headphones/config.py index e825cc68..46efed19 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -293,6 +293,12 @@ _CONFIG_DEFINITIONS = { 'WHATCD_PASSWORD': (str, 'What.cd', ''), 'WHATCD_RATIO': (str, 'What.cd', ''), 'WHATCD_USERNAME': (str, 'What.cd', ''), + 'WHATCD_URL': (str, 'What.cd', 'https://what.cd'), + 'PTH': (int, 'PassTheHeadphones.me', 0), + 'PTH_PASSWORD': (str, 'PassTheHeadphones.me', ''), + 'PTH_RATIO': (str, 'PassTheHeadphones.me', ''), + 'PTH_USERNAME': (str, 'PassTheHeadphones.me', ''), + 'PTH_URL': (str, 'PassTheHeadphones.me', 'https://passtheheadphones.me'), 'XBMC_ENABLED': (int, 'XBMC', 0), 'XBMC_HOST': (str, 'XBMC', ''), 'XBMC_NOTIFY': (int, 'XBMC', 0), diff --git a/headphones/searcher.py b/headphones/searcher.py index cda9a822..0f4c7c33 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -38,7 +38,6 @@ from headphones import logger, db, helpers, classes, sab, nzbget, request from headphones import utorrent, transmission, notifiers, rutracker, deluge from bencode import bencode, bdecode - # Magnet to torrent services, for Black hole. Stolen from CouchPotato. TORRENT_TO_MAGNET_SERVICES = [ # 'https://zoink.it/torrent/%s.torrent', @@ -163,6 +162,8 @@ def get_seed_ratio(provider): seed_ratio = headphones.CONFIG.KAT_RATIO elif provider == 'What.cd': seed_ratio = headphones.CONFIG.WHATCD_RATIO + elif provider == 'PassTheHeadphones.Me': + seed_ratio = headphones.CONFIG.PTH_RATIO elif provider == 'The Pirate Bay': seed_ratio = headphones.CONFIG.PIRATEBAY_RATIO elif provider == 'Old Pirate Bay': @@ -255,6 +256,7 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.WAFFLES or headphones.CONFIG.RUTRACKER or headphones.CONFIG.WHATCD or + headphones.CONFIG.PTH or headphones.CONFIG.STRIKE) results = [] @@ -1485,7 +1487,8 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, try: logger.info(u"Attempting to log in to What.cd...") gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.WHATCD_USERNAME, - headphones.CONFIG.WHATCD_PASSWORD) + headphones.CONFIG.WHATCD_PASSWORD, + headphones.CONFIG.WHATCD_URL) gazelle._login() except Exception as e: gazelle = None @@ -1545,6 +1548,106 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, provider, 'torrent', True)) + # PassTheHeadphones.me - Using same logic as What.CD as it's also Gazelle, so should really make this into something reusable + if headphones.CONFIG.PTH: + provider = "PassTheHeadphones.me" + providerurl = "https://passtheheadphones.me/" + + bitrate = None + bitrate_string = bitrate + + if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly: # Lossless Only mode + search_formats = [gazelleformat.FLAC] + maxsize = 10000000000 + elif headphones.CONFIG.PREFERRED_QUALITY == 2: # Preferred quality mode + search_formats = [None] # should return all + bitrate = headphones.CONFIG.PREFERRED_BITRATE + if bitrate: + if 225 <= int(bitrate) < 256: + bitrate = 'V0' + elif 200 <= int(bitrate) < 225: + bitrate = 'V1' + elif 175 <= int(bitrate) < 200: + bitrate = 'V2' + for encoding_string in gazelleencoding.ALL_ENCODINGS: + if re.search(bitrate, encoding_string, flags=re.I): + bitrate_string = encoding_string + if bitrate_string not in gazelleencoding.ALL_ENCODINGS: + logger.info( + u"Your preferred bitrate is not one of the available What.cd filters, so not using it as a search parameter.") + maxsize = 10000000000 + elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless + search_formats = [gazelleformat.FLAC, gazelleformat.MP3] + maxsize = 10000000000 + else: # Highest quality excluding lossless + search_formats = [gazelleformat.MP3] + maxsize = 300000000 + + if not gazelle or not gazelle.logged_in(): + try: + logger.info(u"Attempting to log in to PassTheHeadphones.me...") + gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.PTH_USERNAME, + headphones.CONFIG.PTH_PASSWORD, + headphones.CONFIG.PTH_URL) + gazelle._login() + except Exception as e: + gazelle = None + logger.error(u"PassTheHeadphones credentials incorrect or site is down. Error: %s %s" % ( + e.__class__.__name__, str(e))) + + if gazelle and gazelle.logged_in(): + logger.info(u"Searching %s..." % provider) + all_torrents = [] + for search_format in search_formats: + if usersearchterm: + all_torrents.extend( + gazelle.search_torrents(searchstr=usersearchterm, format=search_format, + encoding=bitrate_string)['results']) + else: + all_torrents.extend(gazelle.search_torrents(artistname=semi_clean_artist_term, + groupname=semi_clean_album_term, + format=search_format, + encoding=bitrate_string)['results']) + + # filter on format, size, and num seeders + logger.info(u"Filtering torrents by format, maximum size, and minimum seeders...") + match_torrents = [t for t in all_torrents if + t.size <= maxsize and t.seeders >= minimumseeders] + + logger.info( + u"Remaining torrents: %s" % ", ".join(repr(torrent) for torrent in match_torrents)) + + # sort by times d/l'd + if not len(match_torrents): + logger.info(u"No results found from %s for %s after filtering" % (provider, term)) + elif len(match_torrents) > 1: + logger.info(u"Found %d matching releases from %s for %s - %s after filtering" % + (len(match_torrents), provider, artistterm, albumterm)) + logger.info( + "Sorting torrents by times snatched and preferred bitrate %s..." % bitrate_string) + match_torrents.sort(key=lambda x: int(x.snatched), reverse=True) + if gazelleformat.MP3 in search_formats: + # sort by size after rounding to nearest 10MB...hacky, but will favor highest quality + match_torrents.sort(key=lambda x: int(10 * round(x.size / 1024. / 1024. / 10.)), + reverse=True) + if search_formats and None not in search_formats: + match_torrents.sort( + key=lambda x: int(search_formats.index(x.format))) # prefer lossless + # if bitrate: + # match_torrents.sort(key=lambda x: re.match("mp3", x.getTorrentDetails(), flags=re.I), reverse=True) + # match_torrents.sort(key=lambda x: str(bitrate) in x.getTorrentFolderName(), reverse=True) + logger.info( + u"New order: %s" % ", ".join(repr(torrent) for torrent in match_torrents)) + + for torrent in match_torrents: + if not torrent.file_path: + torrent.group.update_group_data() # will load the file_path for the individual torrents + resultlist.append((torrent.file_path, + torrent.size, + gazelle.generate_torrent_link(torrent.id), + provider, + 'torrent', True)) + # Pirate Bay if headphones.CONFIG.PIRATEBAY: provider = "The Pirate Bay" @@ -1888,6 +1991,8 @@ def preprocess(resultlist): headers['User-Agent'] = USER_AGENT elif result[3] == 'What.cd': headers['User-Agent'] = 'Headphones' + elif result[3] == 'PassTheHeadphones.me': + headers['User-Agent'] = 'Headphones' elif result[3] == "The Pirate Bay" or result[3] == "Old Pirate Bay": 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' diff --git a/headphones/webserve.py b/headphones/webserve.py index 9b09feba..368a5327 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1228,6 +1228,12 @@ class WebInterface(object): "whatcd_username": headphones.CONFIG.WHATCD_USERNAME, "whatcd_password": headphones.CONFIG.WHATCD_PASSWORD, "whatcd_ratio": headphones.CONFIG.WHATCD_RATIO, + "whatcd_url": headphones.CONFIG.WHATCD_URL, + "use_pth": checked(headphones.CONFIG.PTH), + "pth_username": headphones.CONFIG.PTH_USERNAME, + "pth_password": headphones.CONFIG.PTH_PASSWORD, + "pth_ratio": headphones.CONFIG.PTH_RATIO, + "pth_url": headphones.CONFIG.PTH_URL, "use_strike": checked(headphones.CONFIG.STRIKE), "strike_ratio": headphones.CONFIG.STRIKE_RATIO, "use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE), diff --git a/lib/pygazelle/api.py b/lib/pygazelle/api.py index b3dba2bb..fae8f418 100644 --- a/lib/pygazelle/api.py +++ b/lib/pygazelle/api.py @@ -42,7 +42,7 @@ class GazelleAPI(object): 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.3'} - def __init__(self, username=None, password=None): + def __init__(self, username=None, password=None, url=None): self.session = requests.session() self.session.headers = self.default_headers self.username = username @@ -59,7 +59,7 @@ class GazelleAPI(object): self.cached_torrents = {} self.cached_requests = {} self.cached_categories = {} - self.site = "https://what.cd/" + self.site = url + "/" self.past_request_timestamps = [] def wait_for_rate_limit(self): @@ -95,7 +95,7 @@ class GazelleAPI(object): self.wait_for_rate_limit() - loginpage = 'https://what.cd/login.php' + loginpage = self.site + 'login.php' data = {'username': self.username, 'password': self.password, 'keeplogged': '1'} @@ -122,7 +122,7 @@ class GazelleAPI(object): Pass an action and relevant arguments for that action. """ def make_request(action, **kwargs): - ajaxpage = 'ajax.php' + ajaxpage = '/ajax.php' content = self.unparsed_request(ajaxpage, action, **kwargs) try: if not isinstance(content, text_type): From 352b009e9128726a59e94020e3a88c1a7acad6d9 Mon Sep 17 00:00:00 2001 From: Denzo Date: Tue, 29 Nov 2016 20:54:20 +0100 Subject: [PATCH 025/137] # Removed slash from ajax.php --- lib/pygazelle/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pygazelle/api.py b/lib/pygazelle/api.py index fae8f418..92aac04e 100644 --- a/lib/pygazelle/api.py +++ b/lib/pygazelle/api.py @@ -122,7 +122,7 @@ class GazelleAPI(object): Pass an action and relevant arguments for that action. """ def make_request(action, **kwargs): - ajaxpage = '/ajax.php' + ajaxpage = 'ajax.php' content = self.unparsed_request(ajaxpage, action, **kwargs) try: if not isinstance(content, text_type): From 0ecfe499fcdbcdcfdc2647ee2eb6b869857d1fce Mon Sep 17 00:00:00 2001 From: rembo10 Date: Thu, 1 Dec 2016 15:59:11 +0000 Subject: [PATCH 026/137] Updated changelog for v0.5.18 --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 087baab7..80846884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.5.18 +Released 01 December 2016 + +Highlights: +* Added: PassTheHeadphones support +* Fixed: Special characters in password fields breaking on config page +* Improved: Updated t411 url + +The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.17...v0.5.18). + + ## v0.5.17 Released 10 November 2016 From edc243a245a751b9e816a634d5ce061d977aa8c1 Mon Sep 17 00:00:00 2001 From: Noam Date: Sat, 19 Nov 2016 22:58:57 +0200 Subject: [PATCH 027/137] Changed some logging to better understand issue #2734 --- headphones/deluge.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 6c105054..6e7feb8b 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -135,7 +135,10 @@ def addTorrent(link, data=None, name=None): # remove '.torrent' suffix if name[-len('.torrent'):] == '.torrent': name = name[:-len('.torrent')] - logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) + try: + logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) + except: + logger.debug('Deluge: Sending Deluge torrent with problematic name and some content') result = {'type': 'torrent', 'name': name, 'content': torrentfile} @@ -445,6 +448,7 @@ def _add_torrent_file(result): 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'] + '.torrent', b64encode(result['content']), {}], "id": 22}) From b8e8e752f0817d5ac8d6b39c959a61605cc8ed04 Mon Sep 17 00:00:00 2001 From: Noam Date: Fri, 2 Dec 2016 12:13:07 +0200 Subject: [PATCH 028/137] RIP WCD --- headphones/deluge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 6e7feb8b..5f16a2ba 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -80,7 +80,7 @@ def addTorrent(link, data=None, name=None): result = {} retid = False - url_what = ['https://what.cd/', 'http://what.cd/'] + url_apollo = ['https://apollo.rip/', 'http://apollo.rip/'] url_waffles = ['https://waffles.ch/', 'http://waffles.ch/'] if link.lower().startswith('magnet:'): @@ -94,7 +94,7 @@ def addTorrent(link, data=None, name=None): if link.lower().startswith(tuple(url_waffles)): if 'rss=' not in link: link = link + '&rss=1' - if link.lower().startswith(tuple(url_what)): + if link.lower().startswith(tuple(url_apollo)): logger.debug('Deluge: Using different User-Agent for this site') user_agent = 'Headphones' # This method will make Deluge download the file From f8900795da195712cf65891d33f9f278d6657fde Mon Sep 17 00:00:00 2001 From: Noam Date: Fri, 2 Dec 2016 13:08:03 +0200 Subject: [PATCH 029/137] Replaced all instances of WCD with Apollo --- data/interfaces/default/config.html | 12 +++++------ headphones/config.py | 10 ++++----- headphones/searcher.py | 32 ++++++++++++++--------------- headphones/webserve.py | 12 +++++------ 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 5bc2edea..5dc6ab58 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -633,24 +633,24 @@
- +
- +
- +
- +
- +
@@ -2439,7 +2439,7 @@ initConfigCheckbox("#use_mininova"); initConfigCheckbox("#use_waffles"); initConfigCheckbox("#use_rutracker"); - initConfigCheckbox("#use_whatcd"); + initConfigCheckbox("#use_apollo"); initConfigCheckbox("#use_pth"); initConfigCheckbox("#use_strike"); initConfigCheckbox("#api_enabled"); diff --git a/headphones/config.py b/headphones/config.py index 46efed19..20129cb7 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -39,6 +39,11 @@ _CONFIG_DEFINITIONS = { 'ALBUM_COMPLETION_PCT': (int, 'Advanced', 80), 'API_ENABLED': (int, 'General', 0), 'API_KEY': (str, 'General', ''), + 'APOLLO': (int, 'Apollo.rip', 0), + 'APOLLO_PASSWORD': (str, 'Apollo.rip', ''), + 'APOLLO_RATIO': (str, 'Apollo.rip', ''), + 'APOLLO_USERNAME': (str, 'Apollo.rip', ''), + 'APOLLO_URL': (str, 'Apollo.rip', 'https://apollo.rip'), 'AUTOWANT_ALL': (int, 'General', 0), 'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1), 'AUTOWANT_UPCOMING': (int, 'General', 1), @@ -289,11 +294,6 @@ _CONFIG_DEFINITIONS = { 'WAFFLES_PASSKEY': (str, 'Waffles', ''), 'WAFFLES_RATIO': (str, 'Waffles', ''), 'WAFFLES_UID': (str, 'Waffles', ''), - 'WHATCD': (int, 'What.cd', 0), - 'WHATCD_PASSWORD': (str, 'What.cd', ''), - 'WHATCD_RATIO': (str, 'What.cd', ''), - 'WHATCD_USERNAME': (str, 'What.cd', ''), - 'WHATCD_URL': (str, 'What.cd', 'https://what.cd'), 'PTH': (int, 'PassTheHeadphones.me', 0), 'PTH_PASSWORD': (str, 'PassTheHeadphones.me', ''), 'PTH_RATIO': (str, 'PassTheHeadphones.me', ''), diff --git a/headphones/searcher.py b/headphones/searcher.py index 7b19eb26..2c8ee2e6 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -45,7 +45,7 @@ TORRENT_TO_MAGNET_SERVICES = [ 'https://torcache.net/torrent/%s.torrent', ] -# Persistent What.cd API object +# Persistent Apollo.rip API object gazelle = None ruobj = None @@ -160,8 +160,8 @@ def get_seed_ratio(provider): seed_ratio = headphones.CONFIG.RUTRACKER_RATIO elif provider == 'Kick Ass Torrents': seed_ratio = headphones.CONFIG.KAT_RATIO - elif provider == 'What.cd': - seed_ratio = headphones.CONFIG.WHATCD_RATIO + elif provider == 'Apollo.rip': + seed_ratio = headphones.CONFIG.APOLLO_RATIO elif provider == 'PassTheHeadphones.Me': seed_ratio = headphones.CONFIG.PTH_RATIO elif provider == 'The Pirate Bay': @@ -274,7 +274,7 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.MININOVA or headphones.CONFIG.WAFFLES or headphones.CONFIG.RUTRACKER or - headphones.CONFIG.WHATCD or + headphones.CONFIG.APOLLO or headphones.CONFIG.PTH or headphones.CONFIG.STRIKE) @@ -1176,7 +1176,7 @@ def verifyresult(title, artistterm, term, lossless): def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): - global gazelle # persistent what.cd api object to reduce number of login attempts + global gazelle # persistent apollo.rip api object to reduce number of login attempts global ruobj # and rutracker reldate = album['ReleaseDate'] @@ -1468,9 +1468,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if rulist: resultlist.extend(rulist) - if headphones.CONFIG.WHATCD: - provider = "What.cd" - providerurl = "http://what.cd/" + if headphones.CONFIG.APOLLO: + provider = "Apollo.rip" + providerurl = "http://apollo.rip/" bitrate = None bitrate_string = bitrate @@ -1493,7 +1493,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, bitrate_string = encoding_string if bitrate_string not in gazelleencoding.ALL_ENCODINGS: logger.info( - u"Your preferred bitrate is not one of the available What.cd filters, so not using it as a search parameter.") + u"Your preferred bitrate is not one of the available Apollo.rip filters, so not using it as a search parameter.") maxsize = 10000000000 elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless search_formats = [gazelleformat.FLAC, gazelleformat.MP3] @@ -1504,14 +1504,14 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if not gazelle or not gazelle.logged_in(): try: - logger.info(u"Attempting to log in to What.cd...") - gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.WHATCD_USERNAME, - headphones.CONFIG.WHATCD_PASSWORD, - headphones.CONFIG.WHATCD_URL) + logger.info(u"Attempting to log in to Apollo.rip...") + gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.APOLLO_USERNAME, + headphones.CONFIG.APOLLO_PASSWORD, + headphones.CONFIG.APOLLO_URL) gazelle._login() except Exception as e: gazelle = None - logger.error(u"What.cd credentials incorrect or site is down. Error: %s %s" % ( + logger.error(u"Apollo.rip credentials incorrect or site is down. Error: %s %s" % ( e.__class__.__name__, str(e))) if gazelle and gazelle.logged_in(): @@ -1593,7 +1593,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, bitrate_string = encoding_string if bitrate_string not in gazelleencoding.ALL_ENCODINGS: logger.info( - u"Your preferred bitrate is not one of the available What.cd filters, so not using it as a search parameter.") + u"Your preferred bitrate is not one of the available PTH filters, so not using it as a search parameter.") maxsize = 10000000000 elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: # Highest quality including lossless search_formats = [gazelleformat.FLAC, gazelleformat.MP3] @@ -2008,7 +2008,7 @@ def preprocess(resultlist): if result[3] == 'Kick Ass Torrents': headers['Referer'] = 'https://torcache.net/' headers['User-Agent'] = USER_AGENT - elif result[3] == 'What.cd': + elif result[3] == 'Apollo.rip': headers['User-Agent'] = 'Headphones' elif result[3] == 'PassTheHeadphones.me': headers['User-Agent'] = 'Headphones' diff --git a/headphones/webserve.py b/headphones/webserve.py index 368a5327..278e512b 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1224,11 +1224,11 @@ class WebInterface(object): "rutracker_user": headphones.CONFIG.RUTRACKER_USER, "rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD, "rutracker_ratio": headphones.CONFIG.RUTRACKER_RATIO, - "use_whatcd": checked(headphones.CONFIG.WHATCD), - "whatcd_username": headphones.CONFIG.WHATCD_USERNAME, - "whatcd_password": headphones.CONFIG.WHATCD_PASSWORD, - "whatcd_ratio": headphones.CONFIG.WHATCD_RATIO, - "whatcd_url": headphones.CONFIG.WHATCD_URL, + "use_apollo": checked(headphones.CONFIG.APOLLO), + "apollo_username": headphones.CONFIG.APOLLO_USERNAME, + "apollo_password": headphones.CONFIG.APOLLO_PASSWORD, + "apollo_ratio": headphones.CONFIG.APOLLO_RATIO, + "apollo_url": headphones.CONFIG.APOLLO_URL, "use_pth": checked(headphones.CONFIG.PTH), "pth_username": headphones.CONFIG.PTH_USERNAME, "pth_password": headphones.CONFIG.PTH_PASSWORD, @@ -1435,7 +1435,7 @@ class WebInterface(object): "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay", "use_mininova", "use_waffles", "use_rutracker", - "use_whatcd", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", + "use_apollo", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", "embed_album_art", "embed_lyrics", From 8fd9958d70b0fe1147329c90d49487b63c7759a2 Mon Sep 17 00:00:00 2001 From: Noam Date: Fri, 2 Dec 2016 14:20:28 +0200 Subject: [PATCH 030/137] Separated use of Gazelle objects for Apollo and PTH so that objects don't collide, renamed objects to make this clear --- headphones/searcher.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 2c8ee2e6..4b52216b 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -46,8 +46,10 @@ TORRENT_TO_MAGNET_SERVICES = [ ] # Persistent Apollo.rip API object -gazelle = None +apolloobj = None +# Persistent PTH API object ruobj = None +pthobj = None def fix_url(s, charset="utf-8"): @@ -1176,7 +1178,8 @@ def verifyresult(title, artistterm, term, lossless): def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): - global gazelle # persistent apollo.rip api object to reduce number of login attempts + global apolloobj # persistent apollo.rip api object to reduce number of login attempts + global pthobj # persistent pth api object to reduce number of login attempts global ruobj # and rutracker reldate = album['ReleaseDate'] @@ -1502,28 +1505,28 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, search_formats = [gazelleformat.MP3] maxsize = 300000000 - if not gazelle or not gazelle.logged_in(): + if not apolloobj or not apolloobj.logged_in(): try: logger.info(u"Attempting to log in to Apollo.rip...") - gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.APOLLO_USERNAME, + apolloobj = gazelleapi.GazelleAPI(headphones.CONFIG.APOLLO_USERNAME, headphones.CONFIG.APOLLO_PASSWORD, headphones.CONFIG.APOLLO_URL) - gazelle._login() + apolloobj._login() except Exception as e: - gazelle = None + apolloobj = None logger.error(u"Apollo.rip credentials incorrect or site is down. Error: %s %s" % ( e.__class__.__name__, str(e))) - if gazelle and gazelle.logged_in(): + if apolloobj and apolloobj.logged_in(): logger.info(u"Searching %s..." % provider) all_torrents = [] for search_format in search_formats: if usersearchterm: all_torrents.extend( - gazelle.search_torrents(searchstr=usersearchterm, format=search_format, + apolloobj.search_torrents(searchstr=usersearchterm, format=search_format, encoding=bitrate_string)['results']) else: - all_torrents.extend(gazelle.search_torrents(artistname=semi_clean_artist_term, + all_torrents.extend(apolloobj.search_torrents(artistname=semi_clean_artist_term, groupname=semi_clean_album_term, format=search_format, encoding=bitrate_string)['results']) @@ -1563,7 +1566,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, torrent.group.update_group_data() # will load the file_path for the individual torrents resultlist.append((torrent.file_path, torrent.size, - gazelle.generate_torrent_link(torrent.id), + apolloobj.generate_torrent_link(torrent.id), provider, 'torrent', True)) @@ -1602,28 +1605,28 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, search_formats = [gazelleformat.MP3] maxsize = 300000000 - if not gazelle or not gazelle.logged_in(): + if not pthobj or not pthobj.logged_in(): try: logger.info(u"Attempting to log in to PassTheHeadphones.me...") - gazelle = gazelleapi.GazelleAPI(headphones.CONFIG.PTH_USERNAME, + pthobj = gazelleapi.GazelleAPI(headphones.CONFIG.PTH_USERNAME, headphones.CONFIG.PTH_PASSWORD, headphones.CONFIG.PTH_URL) - gazelle._login() + pthobj._login() except Exception as e: - gazelle = None + pthobj = None logger.error(u"PassTheHeadphones credentials incorrect or site is down. Error: %s %s" % ( e.__class__.__name__, str(e))) - if gazelle and gazelle.logged_in(): + if pthobj and pthobj.logged_in(): logger.info(u"Searching %s..." % provider) all_torrents = [] for search_format in search_formats: if usersearchterm: all_torrents.extend( - gazelle.search_torrents(searchstr=usersearchterm, format=search_format, + pthobj.search_torrents(searchstr=usersearchterm, format=search_format, encoding=bitrate_string)['results']) else: - all_torrents.extend(gazelle.search_torrents(artistname=semi_clean_artist_term, + all_torrents.extend(pthobj.search_torrents(artistname=semi_clean_artist_term, groupname=semi_clean_album_term, format=search_format, encoding=bitrate_string)['results']) @@ -1663,7 +1666,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, torrent.group.update_group_data() # will load the file_path for the individual torrents resultlist.append((torrent.file_path, torrent.size, - gazelle.generate_torrent_link(torrent.id), + pthobj.generate_torrent_link(torrent.id), provider, 'torrent', True)) From 4d1107ee5455e02885e846d597d357d9a782352d Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 11 Dec 2016 17:11:42 +1300 Subject: [PATCH 031/137] cue sheet + temp dir fix ups - cue not processing correctly if using temp dir - allow temp dir to be specified from new config option - attempt to stop multiple temp dirs created for the same albumpath --- headphones/config.py | 1 + headphones/helpers.py | 60 ++++++++++++++++++++++++--------- headphones/postprocessor.py | 67 +++++++++++++++++++++++-------------- 3 files changed, 87 insertions(+), 41 deletions(-) diff --git a/headphones/config.py b/headphones/config.py index 46efed19..9ef59ea5 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -144,6 +144,7 @@ _CONFIG_DEFINITIONS = { 'KAT_RATIO': (str, 'Kat', ''), 'KEEP_NFO': (int, 'General', 0), 'KEEP_TORRENT_FILES': (int, 'General', 0), + 'KEEP_TORRENT_FILES_DIR': (path, 'General', ''), 'LASTFM_USERNAME': (str, 'General', ''), 'LAUNCH_BROWSER': (int, 'General', 1), 'LIBRARYSCAN': (int, 'General', 1), diff --git a/headphones/helpers.py b/headphones/helpers.py index af1a13e0..d6fabdbf 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -20,6 +20,8 @@ import datetime import shutil import time import sys +import tempfile +import glob import fnmatch import re @@ -623,24 +625,41 @@ def get_downloaded_track_list(albumpath): def preserve_torrent_directory(albumpath): """ - Copy torrent directory to headphones-modified to keep files for seeding. + Copy torrent directory to temp headphones_ directory to keep files for seeding. """ from headphones import logger - new_folder = os.path.join(albumpath, - 'headphones-modified'.encode(headphones.SYS_ENCODING, 'replace')) - logger.info( - "Copying files to 'headphones-modified' subfolder to preserve downloaded files for seeding") - try: - shutil.copytree(albumpath, new_folder) - return new_folder - except Exception as e: - logger.warn("Cannot copy/move files to temp folder: " + - new_folder.decode(headphones.SYS_ENCODING, 'replace') + - ". Not continuing. Error: " + str(e)) + + # Create temp dir + if headphones.CONFIG.KEEP_TORRENT_FILES_DIR: + tempdir = headphones.CONFIG.KEEP_TORRENT_FILES_DIR + else: + tempdir = tempfile.gettempdir() + prefix = "headphones_" + os.path.basename(os.path.normpath(albumpath)) + "_" + new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir) + + # Copy to temp dir + subdir = os.path.join(new_folder, "headphones") + logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace') + + " subfolder to preserve downloaded files for seeding") + + # Attempt to stop multiple temp dirs being created for the same albumpath + tempdir = os.path.join(tempdir, prefix) + if len (glob.glob(tempdir + '*/')) >= 3: + logger.error("Looks like a temp subfolder has previously been created for this albumpath, not continuing " + + tempdir.decode(headphones.SYS_ENCODING, 'replace')) return None + try: + shutil.copytree(albumpath, subdir) + # Update the album path with the new location + return subdir + except Exception as e: + logger.warn("Cannot copy/move files to temp folder: " + + new_folder.decode(headphones.SYS_ENCODING, 'replace') + ". Not continuing. Error: " + str(e)) + shutil.rmtree(new_folder) + return None -def cue_split(albumpath): +def cue_split(albumpath,keep_original_folder=False): """ Attempts to check and split audio files by a cue for the given directory. """ @@ -662,6 +681,15 @@ def cue_split(albumpath): # Split cue if cue_count and cue_count >= count and cue_dirs: + # Copy to temp directory + if keep_original_folder: + temppath = preserve_torrent_directory(albumpath) + if temppath: + cue_dirs = [cue_dir.replace(albumpath, temppath) for cue_dir in cue_dirs] + albumpath = temppath + else: + return None + from headphones import logger, cuesplit logger.info("Attempting to split audio files by cue") @@ -672,12 +700,12 @@ def cue_split(albumpath): except Exception as e: os.chdir(cwd) logger.warn("Cue not split: " + str(e)) - return False + return None os.chdir(cwd) - return True + return albumpath - return False + return None def extract_logline(s): diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index ae38bdfb..9a188660 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -17,7 +17,6 @@ import shutil import uuid import threading import itertools -import tempfile import os import re @@ -207,13 +206,23 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal 'replace') + " isn't complete yet. Will try again on the next run") return - # Split cue + # Check to see if we're preserving the torrent dir + if (headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent") or headphones.CONFIG.KEEP_ORIGINAL_FOLDER: + keep_original_folder = True + + # Split cue before metadata check if headphones.CONFIG.CUE_SPLIT and downloaded_cuecount and downloaded_cuecount >= len( downloaded_track_list): - if headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent": + if keep_original_folder: + keep_original_folder = False albumpath = helpers.preserve_torrent_directory(albumpath) - if albumpath and helpers.cue_split(albumpath): - downloaded_track_list = helpers.get_downloaded_track_list(albumpath) + if not albumpath: + return + Kind = "cue_split" + albumpath = helpers.cue_split(albumpath) + if not albumpath: + return + downloaded_track_list = helpers.get_downloaded_track_list(albumpath) # test #1: metadata - usually works logger.debug('Verifying metadata...') @@ -316,19 +325,16 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, keep_original_folder=False): logger.info('Starting post-processing for: %s - %s' % (release['ArtistName'], release['AlbumTitle'])) new_folder = None - # Check to see if we're preserving the torrent dir - if (headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent" and 'headphones-modified' not in albumpath) or headphones.CONFIG.KEEP_ORIGINAL_FOLDER or keep_original_folder: - new_folder = tempfile.mkdtemp(prefix="headphones_") - subdir = os.path.join(new_folder, "headphones") - logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace') + " subfolder to preserve downloaded files for seeding") - try: - shutil.copytree(albumpath, subdir) - # Update the album path with the new location - albumpath = subdir - except Exception as e: - logger.warn("Cannot copy/move files to temp folder: " + new_folder.decode(headphones.SYS_ENCODING, 'replace') + ". Not continuing. Error: " + str(e)) - shutil.rmtree(new_folder) + + # Preserve the torrent dir + if keep_original_folder: + albumpath = helpers.preserve_torrent_directory(albumpath) + if not albumpath: return + else: + new_folder = os.path.split(albumpath)[0] + elif Kind == "cue_split": + new_folder = os.path.split(albumpath)[0] # Need to update the downloaded track list with the new location. # Could probably just throw in the "headphones-modified" folder, @@ -1257,12 +1263,23 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig except Exception: name = album = None - # Check if there's a cue to split - if headphones.CONFIG.CUE_SPLIT and not name and not album and helpers.cue_split(folder): - try: - name, album, year = helpers.extract_metadata(folder) - except Exception: - name = album = None + # Not found from meta data, check if there's a cue to split and try meta data again + kind = None + if headphones.CONFIG.CUE_SPLIT and not name and not album: + cue_folder = helpers.cue_split(folder,keep_original_folder=keep_original_folder) + if cue_folder: + try: + name, album, year = helpers.extract_metadata(cue_folder) + except Exception: + name = album = None + if name: + folder = cue_folder + if keep_original_folder: + keep_original_folder = False + kind = "cue_split" + elif folder != cue_folder: + cue_folder = os.path.split(cue_folder)[0] + shutil.rmtree(cue_folder) if name and album: release = myDB.action( @@ -1272,7 +1289,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig logger.info( 'Found a match in the database: %s - %s. Verifying to make sure it is the correct album', release['ArtistName'], release['AlbumTitle']) - verify(release['AlbumID'], folder, keep_original_folder=keep_original_folder) + verify(release['AlbumID'], folder, Kind=kind, keep_original_folder=keep_original_folder) continue else: logger.info('Querying MusicBrainz for the release group id for: %s - %s', name, @@ -1284,7 +1301,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig rgid = None if rgid: - verify(rgid, folder, keep_original_folder=keep_original_folder) + verify(rgid, folder, Kind=kind, keep_original_folder=keep_original_folder) continue else: logger.info('No match found on MusicBrainz for: %s - %s', name, album) From 82695311c4f5c0688ab4b2f87994fd883cab881b Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 11 Dec 2016 19:12:29 +1300 Subject: [PATCH 032/137] PEP8 --- headphones/helpers.py | 14 ++++++++------ headphones/postprocessor.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index d6fabdbf..7e4520b0 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -639,14 +639,15 @@ def preserve_torrent_directory(albumpath): # Copy to temp dir subdir = os.path.join(new_folder, "headphones") - logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace') - + " subfolder to preserve downloaded files for seeding") + logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, + 'replace') + " subfolder to preserve downloaded files for seeding") # Attempt to stop multiple temp dirs being created for the same albumpath tempdir = os.path.join(tempdir, prefix) - if len (glob.glob(tempdir + '*/')) >= 3: - logger.error("Looks like a temp subfolder has previously been created for this albumpath, not continuing " - + tempdir.decode(headphones.SYS_ENCODING, 'replace')) + if len(glob.glob(tempdir + '*/')) >= 3: + logger.error( + "Looks like a temp subfolder has previously been created for this albumpath, not continuing " + tempdir.decode( + headphones.SYS_ENCODING, 'replace')) return None try: @@ -659,7 +660,8 @@ def preserve_torrent_directory(albumpath): shutil.rmtree(new_folder) return None -def cue_split(albumpath,keep_original_folder=False): + +def cue_split(albumpath, keep_original_folder=False): """ Attempts to check and split audio files by a cue for the given directory. """ diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 9a188660..2ddd247c 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -1266,7 +1266,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig # Not found from meta data, check if there's a cue to split and try meta data again kind = None if headphones.CONFIG.CUE_SPLIT and not name and not album: - cue_folder = helpers.cue_split(folder,keep_original_folder=keep_original_folder) + cue_folder = helpers.cue_split(folder, keep_original_folder=keep_original_folder) if cue_folder: try: name, album, year = helpers.extract_metadata(cue_folder) From decc5480860340bf0a51652bc9b1f26b73c1c395 Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 11 Dec 2016 19:23:38 +1300 Subject: [PATCH 033/137] PEP8 --- headphones/helpers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index 7e4520b0..b01cec69 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -655,8 +655,9 @@ def preserve_torrent_directory(albumpath): # Update the album path with the new location return subdir except Exception as e: - logger.warn("Cannot copy/move files to temp folder: " - + new_folder.decode(headphones.SYS_ENCODING, 'replace') + ". Not continuing. Error: " + str(e)) + logger.warn("Cannot copy/move files to temp folder: " + new_folder.decode(headphones.SYS_ENCODING, + 'replace') + ". Not continuing. Error: " + str( + e)) shutil.rmtree(new_folder) return None From b538e5b321a7e6b60df3b57eaaddb8b54e66cb15 Mon Sep 17 00:00:00 2001 From: Nicolas Le Gall Date: Sun, 18 Dec 2016 10:32:39 +0100 Subject: [PATCH 034/137] Add Slack notification --- data/interfaces/default/config.html | 41 +++++++++++++++++++++++++++++ headphones/config.py | 5 ++++ headphones/notifiers.py | 27 +++++++++++++++++++ headphones/searcher.py | 4 +++ headphones/webserve.py | 7 ++++- 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 5dc6ab58..1388de17 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -1302,6 +1302,26 @@
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
@@ -2099,6 +2119,27 @@ } }); + if ($("#slack").is(":checked")) + { + $("#slackoptions").show(); + } + else + { + $("#slackoptions").hide(); + } + + $("#slack").click(function(){ + if ($("#slack").is(":checked")) + { + $("#slackoptions").slideDown(); + } + else + { + $("#slackoptions").slideUp(); + } + }); + + if ($("#telegram").is(":checked")) { $("#telegramoptions").show(); diff --git a/headphones/config.py b/headphones/config.py index 41f50ca9..3f75c30a 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -249,6 +249,11 @@ _CONFIG_DEFINITIONS = { 'SAB_USERNAME': (str, 'SABnzbd', ''), 'SAMPLINGFREQUENCY': (int, 'General', 44100), 'SEARCH_INTERVAL': (int, 'General', 1440), + 'SLACK_ENABLED': (int, 'Slack', 0), + 'SLACK_URL': (str, 'Slack', ''), + 'SLACK_CHANNEL': (str, 'Slack', ''), + 'SLACK_EMOJI': (str, 'Slack', ''), + 'SLACK_ONSNATCH': (int, 'Slack', 0), 'SOFT_CHROOT': (path, 'General', ''), 'SONGKICK_APIKEY': (str, 'Songkick', 'nd1We7dFW2RqxPw8'), 'SONGKICK_ENABLED': (int, 'Songkick', 1), diff --git a/headphones/notifiers.py b/headphones/notifiers.py index 1b885335..79923eb3 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -888,3 +888,30 @@ class TELEGRAM(object): logger.info(u"Telegram notifications sent.") return sent_successfuly + +class SLACK(object): + + def notify(self, message, status): + if not headphones.CONFIG.SLACK_ENABLED: + return + + import requests + + SLACK_URL = headphones.CONFIG.SLACK_URL + channel = headphones.CONFIG.SLACK_CHANNEL + emoji = headphones.CONFIG.SLACK_EMOJI + + payload = { 'channel': channel, 'text': status + ': ' + message, 'icon_emoji': emoji} + + try: + response = requests.post(SLACK_URL, json=payload) + except Exception, e: + logger.info(u'Slack notify failed: ' + str(e)) + + sent_successfuly = True + if not response.status_code == 200: + logger.info(u'Could not send notification to Slack. Response: [%s]', (response.text)) + sent_successfuly = False + + logger.info(u"Slack notifications sent.") + return sent_successfuly \ No newline at end of file diff --git a/headphones/searcher.py b/headphones/searcher.py index 4b52216b..4c155810 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1052,6 +1052,10 @@ def send_to_downloader(data, bestqual, album): logger.info(u"Sending PushBullet notification") pushbullet = notifiers.PUSHBULLET() pushbullet.notify(name, "Download started") + if headphones.CONFIG.SLACK_ENABLED and headphones.CONFIG.SLACK_ONSNATCH: + logger.info(u"Sending Slack notification") + slack = notifiers.SLACK() + slack.notify(name, "Download started") if headphones.CONFIG.TELEGRAM_ENABLED and headphones.CONFIG.TELEGRAM_ONSNATCH: logger.info(u"Sending Telegram notification") telegram = notifiers.TELEGRAM() diff --git a/headphones/webserve.py b/headphones/webserve.py index 278e512b..b282642f 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1388,7 +1388,12 @@ class WebInterface(object): "email_ssl": checked(headphones.CONFIG.EMAIL_SSL), "email_tls": checked(headphones.CONFIG.EMAIL_TLS), "email_onsnatch": checked(headphones.CONFIG.EMAIL_ONSNATCH), - "idtag": checked(headphones.CONFIG.IDTAG) + "idtag": checked(headphones.CONFIG.IDTAG), + "slack_enabled": checked(headphones.CONFIG.SLACK_ENABLED), + "slack_url": headphones.CONFIG.SLACK_URL, + "slack_channel": headphones.CONFIG.SLACK_CHANNEL, + "slack_emoji": headphones.CONFIG.SLACK_EMOJI, + "slack_onsnatch": checked(headphones.CONFIG.SLACK_ONSNATCH) } for k, v in config.iteritems(): From 772fb524c73661852e3a3330f4e0642051a18c83 Mon Sep 17 00:00:00 2001 From: Nicolas Le Gall Date: Sun, 18 Dec 2016 10:39:26 +0100 Subject: [PATCH 035/137] Fix for pep8 --- headphones/notifiers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/headphones/notifiers.py b/headphones/notifiers.py index 79923eb3..f8f1b3f8 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -889,6 +889,7 @@ class TELEGRAM(object): logger.info(u"Telegram notifications sent.") return sent_successfuly + class SLACK(object): def notify(self, message, status): @@ -901,7 +902,7 @@ class SLACK(object): channel = headphones.CONFIG.SLACK_CHANNEL emoji = headphones.CONFIG.SLACK_EMOJI - payload = { 'channel': channel, 'text': status + ': ' + message, 'icon_emoji': emoji} + payload = {'channel': channel, 'text': status + ': ' + message, 'icon_emoji': emoji} try: response = requests.post(SLACK_URL, json=payload) @@ -914,4 +915,4 @@ class SLACK(object): sent_successfuly = False logger.info(u"Slack notifications sent.") - return sent_successfuly \ No newline at end of file + return sent_successfuly From e8009c00d24dbe8e51978fddeb4c1c618f1fad57 Mon Sep 17 00:00:00 2001 From: Gadzy Date: Thu, 22 Dec 2016 07:19:20 +0100 Subject: [PATCH 036/137] T411 specific dowload bugfix Fixed a bug where T411 was not checked when choosing specific download --- headphones/searcher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 4b52216b..ded12002 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -278,7 +278,8 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.RUTRACKER or headphones.CONFIG.APOLLO or headphones.CONFIG.PTH or - headphones.CONFIG.STRIKE) + headphones.CONFIG.STRIKE or + headphones.CONFIG.TQUATTRECENTONZE) results = [] myDB = db.DBConnection() From 7f48b021ecf46556c40b2dc53d30e181be62a888 Mon Sep 17 00:00:00 2001 From: Fritsbenik Date: Wed, 4 Jan 2017 19:08:48 +0100 Subject: [PATCH 037/137] update init.freebsd Allow to set variables in /etc/rc.conf --- init-scripts/init.freebsd | 1 + 1 file changed, 1 insertion(+) diff --git a/init-scripts/init.freebsd b/init-scripts/init.freebsd index bd4cca2a..78decdcc 100755 --- a/init-scripts/init.freebsd +++ b/init-scripts/init.freebsd @@ -23,6 +23,7 @@ name="headphones" rcvar=${name}_enable +load_rc_config ${name} : "${headphones_enable:="NO"}" : "${headphones_user:="_sabnzbd"}" From 7e915c59024e1ec662dc6a4347450a0d7c397481 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Jan 2017 00:49:55 -0500 Subject: [PATCH 038/137] Add qbittorrent downloader support --- data/interfaces/default/config.html | 47 ++++- headphones/config.py | 4 + headphones/postprocessor.py | 8 +- headphones/qbittorrent.py | 277 ++++++++++++++++++++++++++++ headphones/searcher.py | 26 ++- headphones/webserve.py | 5 + 6 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 headphones/qbittorrent.py diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 5dc6ab58..cd0bb379 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -312,6 +312,7 @@ Transmission uTorrent (Beta) Deluge (Beta) + QBitTorrent
@@ -386,6 +387,26 @@
+
+ Note: Works with WebAPI Rev 6 and later (QBitTorrent 3.4.0 and later) +
+ + + usually http://localhost:8081 +
+
+ + +
+
+ + +
+
+ + +
+
@@ -2275,26 +2296,30 @@ if ($("#torrent_downloader_blackhole").is(":checked")) { - $("#transmission_options,#utorrent_options,#deluge_options").hide(); + $("#transmission_options,#utorrent_options,#deluge_options,#qbittorrent_options").hide(); $("#torrent_blackhole_options").show(); } if ($("#torrent_downloader_transmission").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options,#deluge_options").hide(); + $("#torrent_blackhole_options,#utorrent_options,#deluge_options,#qbittorrent_options").hide(); $("#transmission_options").show(); } if ($("#torrent_downloader_utorrent").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options,#deluge_options").hide(); + $("#torrent_blackhole_options,#transmission_options,#deluge_options,#qbittorrent_options").hide(); $("#utorrent_options").show(); } + if ($("#torrent_downloader_qbittorrent").is(":checked")) + { + $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#deluge_options").hide(); + $("#qbittorrent_options").show(); + } if ($("#torrent_downloader_deluge").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options,#utorrent_options").hide(); + $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#qbittorent_options").hide(); $("#deluge_options").show(); } - $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) { @@ -2330,19 +2355,23 @@ } if ($("#torrent_downloader_blackhole").is(":checked")) { - $("#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); + $("#transmission_options,#utorrent_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); } if ($("#torrent_downloader_transmission").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); + $("#torrent_blackhole_options,#utorrent_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); } if ($("#torrent_downloader_utorrent").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options,#deluge_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); + $("#torrent_blackhole_options,#transmission_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); } + if ($("#torrent_downloader_qbittorrent").is(":checked")) + { + $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#qbittorrent_options").fadeIn() }); + } if ($("#torrent_downloader_deluge").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options,#transmission_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); + $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } }); diff --git a/headphones/config.py b/headphones/config.py index 41f50ca9..4b37c2a1 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -232,6 +232,10 @@ _CONFIG_DEFINITIONS = { 'PUSHOVER_KEYS': (str, 'Pushover', ''), 'PUSHOVER_ONSNATCH': (int, 'Pushover', 0), 'PUSHOVER_PRIORITY': (int, 'Pushover', 0), + 'QBITTORRENT_HOST': (str, 'QBitTorrent', ''), + 'QBITTORRENT_LABEL': (str, 'QBitTorrent', ''), + 'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''), + 'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''), 'RENAME_FILES': (int, 'General', 0), 'RENAME_UNPROCESSED': (bool_int, 'General', 1), 'RENAME_FROZEN': (bool_int, 'General', 1), diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 2ddd247c..84116abc 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -26,7 +26,7 @@ from beets import autotag from beets import config as beetsconfig from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from beetsplug import lyrics as beetslyrics -from headphones import notifiers, utorrent, transmission, deluge +from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync from headphones import logger, helpers, request, mb, music_encoder from headphones import metadata @@ -452,7 +452,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, [albumid]) # Check if torrent has finished seeding - if headphones.CONFIG.TORRENT_DOWNLOADER == 1 or headphones.CONFIG.TORRENT_DOWNLOADER == 2: + if headphones.CONFIG.TORRENT_DOWNLOADER != 0: seed_snatched = myDB.action( 'SELECT * from snatched WHERE Status="Seed_Snatched" and AlbumID=?', [albumid]).fetchone() @@ -465,8 +465,10 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, torrent_removed = transmission.removeTorrent(hash, True) elif headphones.CONFIG.TORRENT_DOWNLOADER == 3: # Deluge torrent_removed = deluge.removeTorrent(hash, True) - else: + elif headphones.CONFIG.TORRENT_DOWNLOADER == 2: torrent_removed = utorrent.removeTorrent(hash, True) + else: + torrent_removed = qbittorrent.removeTorrent(hash, True) # Torrent removed, delete the snatched record, else update Status for scheduled job to check if torrent_removed: diff --git a/headphones/qbittorrent.py b/headphones/qbittorrent.py new file mode 100644 index 00000000..8ffebdbf --- /dev/null +++ b/headphones/qbittorrent.py @@ -0,0 +1,277 @@ +# This file is part of Headphones. +# +# Headphones is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Headphones is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Headphones. If not, see . + +import urllib +import urllib2 +import cookielib +import json +import os +import time +import mimetypes +import random +import string + +import headphones + +from headphones import logger +from collections import namedtuple + + +class qbittorrentclient(object): + + TOKEN_REGEX = "" + UTSetting = namedtuple("UTSetting", ["name", "int", "str", "access"]) + + def __init__(self, base_url=None, username=None, password=None,): + + host = headphones.CONFIG.QBITTORRENT_HOST + if not host.startswith('http'): + host = 'http://' + host + + if host.endswith('/'): + host = host[:-1] + + if host.endswith('/gui'): + host = host[:-4] + + self.base_url = host + self.username = headphones.CONFIG.QBITTORRENT_USERNAME + self.password = headphones.CONFIG.QBITTORRENT_PASSWORD + self.cookiejar = cookielib.CookieJar() + self.opener = self._make_opener() + self._get_sid(self.base_url, self.username, self.password) + + def _make_opener(self): + # create opener with cookie handler to carry QBitTorrent SID cookie + cookie_handler = urllib2.HTTPCookieProcessor(self.cookiejar) + handlers = [cookie_handler] + return urllib2.build_opener(*handlers) + + def _get_sid(self, base_url, username, password): + # login so we can capture SID cookie + login_data = urllib.urlencode({'username': username, 'password': password}) + try: + self.opener.open(base_url+'/login', login_data) + except urllib2.URLError as err: + logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason)) + return + for cookie in self.cookiejar: + logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value) + return + + def _command(self, command, args=None, content_type=None, files=None): + logger.debug('QBittorrent WebAPI Command: %s' % command) + + url = self.base_url + '/' + command + + data = None + headers = dict() + if content_type == 'multipart/form-data': + data, headers = encode_multipart( args, files ) + else: + if args: + data = urllib.urlencode(args) + if content_type: + headers['Content-Type'] = content_type + + logger.debug('%s' % json.dumps( headers, indent = 4 )) + logger.debug('%s' % data) + + request = urllib2.Request(url, data, headers) + try: + response = self.opener.open(request) + info = response.info() + if info: + if info.getheader('content-type'): + if info.getheader('content-type') == 'application/json': + resp = '' + for line in response: + resp = resp + line + logger.debug('response code: %s' % str(response.code) ) + logger.debug('response: %s' % resp) + return response.code, json.loads(resp) + logger.debug('response code: %s' % str(response.code) ) + return response.code, None + except urllib2.URLError as err: + logger.debug('Failed URL: %s' % url) + logger.debug('QBitTorrent webUI raised the following error: %s' % str(err)) + return None, None + + def _get_list(self, **args): + return self._command('query/torrents', args) + + def _get_settings(self): + status, value = self._command('query/preferences') + logger.debug('get_settings() returned %d items' % len(value)) + return value + + def get_savepath(self, hash): + logger.debug('qb.get_savepath(%s)' % hash) + status, torrentList = self._get_list() + for torrent in torrentList: + if torrent['hash']: + if torrent['hash'].upper() == hash.upper(): + return torrent['save_path'] + return None + + def start(self, hash): + logger.debug('qb.start(%s)' % hash) + args = {'hash': hash} + return self._command('command/resume', args, 'application/x-www-form-urlencoded') + + def pause(self, hash): + logger.debug('qb.pause(%s)' % hash) + args = {'hash': hash} + return self._command('command/pause', args,'application/x-www-form-urlencoded') + + def getfiles(self, hash): + logger.debug('qb.getfiles(%s)' % hash) + return self._command('query/propertiesFiles/'+hash) + + def getprops(self, hash): + logger.debug('qb.getprops(%s)' % hash) + return self._command('query/propertiesGeneral/'+hash) + + def setprio(self, hash, priority): + logger.debug('qb.setprio(%s,%d)' % (hash, priority)) + args = {'hash': hash, 'priority': priority} + return self._command('command/setFilePrio', args,'application/x-www-form-urlencoded') + + def remove(self, hash, remove_data=False): + logger.debug('qb.remove(%s,%s)' % (hash, remove_data)) + + args = {'hashes': hash} + if remove_data: + command = 'command/deletePerm' + else: + command = 'command/delete' + return self._command(command, args, 'application/x-www-form-urlencoded') + +def removeTorrent(hash, remove_data=False): + logger.debug('removeTorrent(%s,%s)' % (hash, remove_data)) + + qbclient = qbittorrentclient() + status, torrentList = qbclient._get_list() + for torrent in torrentList: + if torrent['hash'].upper() == hash.upper(): + if torrent['state'] == 'uploading' or torrent['state'] == 'stalledUP': + logger.info('%s has finished seeding, removing torrent and data' % torrent['name']) + qbclient.remove(hash, remove_data) + return True + else: + logger.info('%s has not finished seeding yet, torrent will not be removed, will try again on next run' % torrent['name']) + return False + return False + +def addTorrent(link): + logger.debug('addTorrent(%s)' % link) + + qbclient = qbittorrentclient() + args = {'urls': link, 'savepath': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} + if headphones.CONFIG.QBITTORRENT_LABEL: + args['category'] = headphones.CONFIG.QBITTORRENT_LABEL + return qbclient._command('command/download', args, 'multipart/form-data' ) + +def addFile(data): + logger.debug('addFile(data)') + + qbclient = qbittorrentclient() + files = {'torrents': {'filename': '', 'content': data}} + return qbclient._command('command/upload', filelist=files) + +def getFolder(hash): + logger.debug('getFolder(%s)' % hash) + + qbclient = qbittorrentclient() + + # Get Active Directory from settings + settings = qbclient._get_settings() + active_dir = settings['temp_path'] + + if not active_dir: + logger.error('Could not get "Keep incomplete torrents in:" directory from QBitTorrent settings, please ensure it is set') + return None + + # Get Torrent Folder Name + torrent_folder = qbclient.get_savepath(hash) + + # If there's no folder yet then it's probably a magnet, try until folder is populated + if torrent_folder == active_dir or not torrent_folder: + tries = 1 + while (torrent_folder == active_dir or torrent_folder is None) and tries <= 10: + tries += 1 + time.sleep(6) + torrent_folder = qbclient.get_savepath(hash) + + if torrent_folder == active_dir or not torrent_folder: + torrent_folder = qbclient.get_savepath(hash) + return torrent_folder + else: + if headphones.SYS_PLATFORM != "win32": + torrent_folder = torrent_folder.replace('\\', '/') + return os.path.basename(os.path.normpath(torrent_folder)) + +_BOUNDARY_CHARS = string.digits + string.ascii_letters + +# Taken from http://code.activestate.com/recipes/578668-encode-multipart-form-data-for-uploading-files-via/ +# "MIT License" which is compatible with GPL +def encode_multipart(args, files, boundary=None): + logger.debug('encode_multipart()') + + def escape_quote(s): + return s.replace('"', '\\"') + + if boundary is None: + boundary = ''.join(random.choice(_BOUNDARY_CHARS) for i in range(30)) + lines = [] + + if args: + for name, value in args.items(): + lines.extend(( + '--{0}'.format(boundary), + 'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)), + '', + str(value), + )) + logger.debug(''.join( lines )) + + if files: + for name, value in files.items(): + filename = value['filename'] + if 'mimetype' in value: + mimetype = value['mimetype'] + else: + mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + lines.extend(( + '--{0}'.format(boundary), + 'Content-Disposition: form-data; name="{0}"; filename="{1}"'.format( + escape_quote(name), escape_quote(filename)), + 'Content-Type: {0}'.format(mimetype), + '', + value['content'], + )) + + lines.extend(( + '--{0}--'.format(boundary), + '', + )) + body = '\r\n'.join(lines) + + headers = { + 'Content-Type': 'multipart/form-data; boundary={0}'.format(boundary), + 'Content-Length': str(len(body)), + } + + return (body, headers) diff --git a/headphones/searcher.py b/headphones/searcher.py index 4b52216b..32e44a85 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -35,7 +35,7 @@ from pygazelle import format as gazelleformat import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request -from headphones import utorrent, transmission, notifiers, rutracker, deluge +from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent from bencode import bencode, bdecode # Magnet to torrent services, for Black hole. Stolen from CouchPotato. @@ -981,7 +981,7 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error('Error sending torrent to Deluge: %s' % str(e)) - else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 2: + elif headphones.CONFIG.TORRENT_DOWNLOADER == 2: logger.info("Sending torrent to uTorrent") # Add torrent @@ -1012,6 +1012,28 @@ def send_to_downloader(data, bestqual, album): seed_ratio = get_seed_ratio(bestqual[3]) if seed_ratio is not None: utorrent.setSeedRatio(torrentid, seed_ratio) + else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 4: + logger.info("Sending torrent to QBiTorrent") + + # Add torrent + if bestqual[3] == 'rutracker.org': + qbittorrent.addFile(data) + else: + qbittorrent.addTorrent(bestqual[2]) + + # Get hash + torrentid = calculate_torrent_hash(bestqual[2], data) + if not torrentid: + logger.error('Torrent id could not be determined') + return + + # Get folder + folder_name = qbittorrent.getFolder(torrentid) + if folder_name: + logger.info('Torrent folder name: %s' % folder_name) + else: + logger.error('Torrent folder name could not be determined') + return myDB = db.DBConnection() myDB.action('UPDATE albums SET status = "Snatched" WHERE AlbumID=?', [album['AlbumID']]) diff --git a/headphones/webserve.py b/headphones/webserve.py index 278e512b..d10d6a3d 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1157,6 +1157,10 @@ class WebInterface(object): "nzbget_password": headphones.CONFIG.NZBGET_PASSWORD, "nzbget_category": headphones.CONFIG.NZBGET_CATEGORY, "nzbget_priority": headphones.CONFIG.NZBGET_PRIORITY, + "qbittorrent_host": headphones.CONFIG.QBITTORRENT_HOST, + "qbittorrent_username": headphones.CONFIG.QBITTORRENT_USERNAME, + "qbittorrent_password": headphones.CONFIG.QBITTORRENT_PASSWORD, + "qbittorrent_label": headphones.CONFIG.QBITTORRENT_LABEL, "transmission_host": headphones.CONFIG.TRANSMISSION_HOST, "transmission_username": headphones.CONFIG.TRANSMISSION_USERNAME, "transmission_password": headphones.CONFIG.TRANSMISSION_PASSWORD, @@ -1177,6 +1181,7 @@ class WebInterface(object): "torrent_downloader_transmission": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 1), "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), + "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, From fa1cb27e04fb66751528d90111b3c5c5f4b6c7e8 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Jan 2017 01:13:04 -0500 Subject: [PATCH 039/137] fix pep8 errors --- headphones/qbittorrent.py | 31 ++++++++++++++++++++----------- headphones/searcher.py | 2 +- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/headphones/qbittorrent.py b/headphones/qbittorrent.py index 8ffebdbf..fe82fa30 100644 --- a/headphones/qbittorrent.py +++ b/headphones/qbittorrent.py @@ -63,7 +63,7 @@ class qbittorrentclient(object): # login so we can capture SID cookie login_data = urllib.urlencode({'username': username, 'password': password}) try: - self.opener.open(base_url+'/login', login_data) + self.opener.open(base_url + '/login', login_data) except urllib2.URLError as err: logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason)) return @@ -79,15 +79,15 @@ class qbittorrentclient(object): data = None headers = dict() if content_type == 'multipart/form-data': - data, headers = encode_multipart( args, files ) + data, headers = encode_multipart(args, files) else: if args: data = urllib.urlencode(args) if content_type: headers['Content-Type'] = content_type - logger.debug('%s' % json.dumps( headers, indent = 4 )) - logger.debug('%s' % data) + logger.debug('%s' % json.dumps(headers, indent=4)) + logger.debug('%s' % data) request = urllib2.Request(url, data, headers) try: @@ -99,10 +99,10 @@ class qbittorrentclient(object): resp = '' for line in response: resp = resp + line - logger.debug('response code: %s' % str(response.code) ) - logger.debug('response: %s' % resp) + logger.debug('response code: %s' % str(response.code)) + logger.debug('response: %s' % 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 except urllib2.URLError as err: logger.debug('Failed URL: %s' % url) @@ -134,7 +134,7 @@ class qbittorrentclient(object): def pause(self, hash): logger.debug('qb.pause(%s)' % hash) args = {'hash': hash} - return self._command('command/pause', args,'application/x-www-form-urlencoded') + return self._command('command/pause', args, 'application/x-www-form-urlencoded') def getfiles(self, hash): logger.debug('qb.getfiles(%s)' % hash) @@ -147,7 +147,7 @@ class qbittorrentclient(object): def setprio(self, hash, priority): logger.debug('qb.setprio(%s,%d)' % (hash, priority)) args = {'hash': hash, 'priority': priority} - return self._command('command/setFilePrio', args,'application/x-www-form-urlencoded') + return self._command('command/setFilePrio', args, 'application/x-www-form-urlencoded') def remove(self, hash, remove_data=False): logger.debug('qb.remove(%s,%s)' % (hash, remove_data)) @@ -159,7 +159,9 @@ class qbittorrentclient(object): command = 'command/delete' return self._command(command, args, 'application/x-www-form-urlencoded') + def removeTorrent(hash, remove_data=False): + logger.debug('removeTorrent(%s,%s)' % (hash, remove_data)) qbclient = qbittorrentclient() @@ -175,6 +177,7 @@ def removeTorrent(hash, remove_data=False): return False return False + def addTorrent(link): logger.debug('addTorrent(%s)' % link) @@ -182,15 +185,19 @@ def addTorrent(link): args = {'urls': link, 'savepath': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} if headphones.CONFIG.QBITTORRENT_LABEL: args['category'] = headphones.CONFIG.QBITTORRENT_LABEL - return qbclient._command('command/download', args, 'multipart/form-data' ) + + return qbclient._command('command/download', args, 'multipart/form-data') + def addFile(data): logger.debug('addFile(data)') qbclient = qbittorrentclient() files = {'torrents': {'filename': '', 'content': data}} + return qbclient._command('command/upload', filelist=files) + def getFolder(hash): logger.debug('getFolder(%s)' % hash) @@ -223,8 +230,10 @@ def getFolder(hash): torrent_folder = torrent_folder.replace('\\', '/') return os.path.basename(os.path.normpath(torrent_folder)) + _BOUNDARY_CHARS = string.digits + string.ascii_letters + # Taken from http://code.activestate.com/recipes/578668-encode-multipart-form-data-for-uploading-files-via/ # "MIT License" which is compatible with GPL def encode_multipart(args, files, boundary=None): @@ -245,7 +254,7 @@ def encode_multipart(args, files, boundary=None): '', str(value), )) - logger.debug(''.join( lines )) + logger.debug(''.join(lines)) if files: for name, value in files.items(): diff --git a/headphones/searcher.py b/headphones/searcher.py index 32e44a85..ba7040ae 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1012,7 +1012,7 @@ def send_to_downloader(data, bestqual, album): seed_ratio = get_seed_ratio(bestqual[3]) if seed_ratio is not None: utorrent.setSeedRatio(torrentid, seed_ratio) - else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 4: + else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 4: logger.info("Sending torrent to QBiTorrent") # Add torrent From 784ec2927e2318a2432307863b83a44264aa27af Mon Sep 17 00:00:00 2001 From: dsm1212 Date: Sat, 14 Jan 2017 01:20:42 -0500 Subject: [PATCH 040/137] more pep8 fixes and a typo --- data/interfaces/default/config.html | 2 +- headphones/qbittorrent.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index cd0bb379..39a962ed 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -388,7 +388,7 @@
- Note: Works with WebAPI Rev 6 and later (QBitTorrent 3.4.0 and later) + Note: Works with WebAPI Rev 11 and later (QBitTorrent 3.4.0 and later)
diff --git a/headphones/qbittorrent.py b/headphones/qbittorrent.py index fe82fa30..6fee8d71 100644 --- a/headphones/qbittorrent.py +++ b/headphones/qbittorrent.py @@ -138,11 +138,11 @@ class qbittorrentclient(object): def getfiles(self, hash): logger.debug('qb.getfiles(%s)' % hash) - return self._command('query/propertiesFiles/'+hash) + return self._command('query/propertiesFiles/' + hash) def getprops(self, hash): logger.debug('qb.getprops(%s)' % hash) - return self._command('query/propertiesGeneral/'+hash) + return self._command('query/propertiesGeneral/' + hash) def setprio(self, hash, priority): logger.debug('qb.setprio(%s,%d)' % (hash, priority)) From 5501d84611f4de00d67552a1b339b1ddb3a7fb19 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 21 Jan 2017 09:52:12 +1300 Subject: [PATCH 041/137] omgwtfnzbs domain change Fixes #2820 --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 10c0ddf0..d163cc60 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -740,7 +740,7 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None, } data = request.request_json( - url='http://api.omgwtfnzbs.org/json/', + url='http://api.omgwtfnzbs.me/json/', params=params, headers=headers ) From 9f378ec79c096a276a1b8de1d8e3bebcb406167b Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 21 Jan 2017 09:55:38 +1300 Subject: [PATCH 042/137] kat domain change --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index d163cc60..4e1caa57 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1345,7 +1345,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if headphones.CONFIG.KAT_PROXY_URL: providerurl = fix_url(set_proxy(headphones.CONFIG.KAT_PROXY_URL)) else: - providerurl = fix_url("https://kat.cr") + providerurl = fix_url("https://katcr.co/new/") # Build URL providerurl = providerurl + "/usearch/" + ka_term From 44bb1bea08d422c91403c8501dc8cb22056ef72c Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 21 Jan 2017 19:45:00 +1300 Subject: [PATCH 043/137] rutracker links and cookie changes --- headphones/rutracker.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/headphones/rutracker.py b/headphones/rutracker.py index b80685cf..8d7d6b76 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -24,7 +24,7 @@ class Rutracker(object): return self.loggedin def still_logged_in(self, html): - if not html or "action=\"http://login.rutracker.org/forum/login.php\">" in html: + if not html or "action=\"http://rutracker.org/forum/login.php\">" in html: return False else: return True @@ -34,7 +34,7 @@ class Rutracker(object): Logs in user """ - loginpage = 'http://login.rutracker.org/forum/login.php' + loginpage = 'http://rutracker.org/forum/login.php' post_params = { 'login_username': headphones.CONFIG.RUTRACKER_USER, 'login_password': headphones.CONFIG.RUTRACKER_PASSWORD, @@ -46,10 +46,10 @@ class Rutracker(object): try: r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) # try again - if not self.has_bb_data_cookie(r): + if not self.has_bb_session_cookie(r): time.sleep(10) r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) - if self.has_bb_data_cookie(r): + if self.has_bb_session_cookie(r): self.loggedin = True logger.info("Successfully logged in to rutracker") else: @@ -62,11 +62,11 @@ class Rutracker(object): self.loggedin = False return self.loggedin - def has_bb_data_cookie(self, response): - if 'bb_data' in response.cookies.keys(): + def has_bb_session_cookie(self, response): + if 'bb_session' in response.cookies.keys(): return True # Rutracker randomly send a 302 redirect code, cookie may be present in response history - return next(('bb_data' in r.cookies.keys() for r in response.history), False) + return next(('bb_session' in r.cookies.keys() for r in response.history), False) def searchurl(self, artist, album, year, format): """ @@ -169,7 +169,7 @@ class Rutracker(object): return the .torrent data """ torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])['t'] - downloadurl = 'http://dl.rutracker.org/forum/dl.php?t=' + torrent_id + downloadurl = 'http://rutracker.org/forum/dl.php?t=' + torrent_id cookie = {'bb_dl': torrent_id} try: headers = {'Referer': url} From 335586893cfcfd5edd043e2308a298fd224e92d4 Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 22 Jan 2017 10:17:24 +1300 Subject: [PATCH 044/137] Keep original folder - Added config option for directory to use when keeping folder for seeding - cue processing fixup --- data/interfaces/default/config.html | 4 +++ headphones/postprocessor.py | 43 ++++++++++++++++++++--------- headphones/webserve.py | 1 + 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 844b5fe3..40f572f5 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -1655,6 +1655,10 @@
+
+ + +
diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 84116abc..eabe107a 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -213,16 +213,27 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal # Split cue before metadata check if headphones.CONFIG.CUE_SPLIT and downloaded_cuecount and downloaded_cuecount >= len( downloaded_track_list): + new_folder = None + new_albumpath = albumpath if keep_original_folder: - keep_original_folder = False - albumpath = helpers.preserve_torrent_directory(albumpath) - if not albumpath: + temp_path = helpers.preserve_torrent_directory(new_albumpath) + if not temp_path: + markAsUnprocessed(albumid, new_albumpath, keep_original_folder) return - Kind = "cue_split" - albumpath = helpers.cue_split(albumpath) - if not albumpath: + else: + new_albumpath = temp_path + new_folder = os.path.split(new_albumpath)[0] + Kind = "cue_split" + cuepath = helpers.cue_split(new_albumpath) + if not cuepath: + if new_folder: + shutil.rmtree(new_folder) + markAsUnprocessed(albumid, albumpath, keep_original_folder) return - downloaded_track_list = helpers.get_downloaded_track_list(albumpath) + else: + albumpath = cuepath + downloaded_track_list = helpers.get_downloaded_track_list(albumpath) + keep_original_folder = False # test #1: metadata - usually works logger.debug('Verifying metadata...') @@ -309,15 +320,19 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal logger.warn(u'Could not identify album: %s. It may not be the intended album.', albumpath.decode(headphones.SYS_ENCODING, 'replace')) + markAsUnprocessed(albumid, albumpath, keep_original_folder) + + +def markAsUnprocessed(albumid, albumpath, keep_original_folder=False): + myDB = db.DBConnection() myDB.action( - 'UPDATE snatched SET status = "Unprocessed" WHERE status NOT LIKE "Seed%" and AlbumID=?', - [albumid]) + 'UPDATE snatched SET status = "Unprocessed" WHERE status NOT LIKE "Seed%" and AlbumID=?', [albumid]) processed = re.search(r' \(Unprocessed\)(?:\[\d+\])?', albumpath) if not processed: - if headphones.CONFIG.RENAME_UNPROCESSED: + if headphones.CONFIG.RENAME_UNPROCESSED and not keep_original_folder: renameUnprocessedFolder(albumpath, tag="Unprocessed") else: - logger.warn(u"Won't rename %s to mark as 'Unprocessed', because it is disabled.", + logger.warn(u"Won't rename %s to mark as 'Unprocessed', because it is disabled or folder is being kept.", albumpath.decode(headphones.SYS_ENCODING, 'replace')) @@ -328,10 +343,12 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, # Preserve the torrent dir if keep_original_folder: - albumpath = helpers.preserve_torrent_directory(albumpath) - if not albumpath: + temp_path = helpers.preserve_torrent_directory(albumpath) + if not temp_path: + markAsUnprocessed(albumid, albumpath, keep_original_folder) return else: + albumpath = temp_path new_folder = os.path.split(albumpath)[0] elif Kind == "cue_split": new_folder = os.path.split(albumpath)[0] diff --git a/headphones/webserve.py b/headphones/webserve.py index 11519c0c..fbad5daf 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1293,6 +1293,7 @@ class WebInterface(object): "magnet_links_3": radio(headphones.CONFIG.MAGNET_LINKS, 3), "log_dir": headphones.CONFIG.LOG_DIR, "cache_dir": headphones.CONFIG.CACHE_DIR, + "keep_torrent_files_dir": headphones.CONFIG.KEEP_TORRENT_FILES_DIR, "interface_list": interface_list, "music_encoder": checked(headphones.CONFIG.MUSIC_ENCODER), "encoder": headphones.CONFIG.ENCODER, From c29dda3af6a99f6b27a1948ab980b9f23fb0aebe Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 22 Jan 2017 10:22:19 +1300 Subject: [PATCH 045/137] Torznab - Use 3050 for music/other - Does not return size for Torrentech, hack our own --- headphones/searcher.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 4e1caa57..1068a3fe 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1287,9 +1287,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if headphones.CONFIG.PREFERRED_QUALITY == 3 or losslessOnly: categories = "3040" elif headphones.CONFIG.PREFERRED_QUALITY == 1 or allow_lossless: - categories = "3040,3010" + categories = "3040,3010,3050" else: - categories = "3010" + categories = "3010,3050" if album['Type'] == 'Other': categories = "3030" @@ -1325,7 +1325,22 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, try: url = item.link title = item.title - size = int(item.links[1]['length']) + + # Torrentech hack - size currently not returned, make it up + if 'torrentech' in torznab_host[0]: + if albumlength: + if 'Lossless' in title: + size = albumlength / 1000 * 800 * 128 + elif 'MP3' in title: + size = albumlength / 1000 * 320 * 128 + else: + size = albumlength / 1000 * 256 * 128 + else: + logger.info('Skipping %s, could not determine size' % title) + continue + else: + size = int(item.links[1]['length']) + if all(word.lower() in title.lower() for word in term.split()): logger.info( 'Found %s. Size: %s' % (title, helpers.bytes_to_mb(size))) From e6b74be1f648421e4834cbcc9b5247a9c0bf3403 Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 2 Feb 2017 19:46:31 +1300 Subject: [PATCH 046/137] Replace Piratebay apostrophe Fixes #2839 --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 1068a3fe..e12d7f95 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1715,7 +1715,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, # Pirate Bay if headphones.CONFIG.PIRATEBAY: provider = "The Pirate Bay" - tpb_term = term.replace("!", "") + tpb_term = term.replace("!", "").replace("'", " ") # Use proxy if specified if headphones.CONFIG.PIRATEBAY_PROXY_URL: From 709073358ba29f56523f2e1eccc509b1721f1eff Mon Sep 17 00:00:00 2001 From: hypsometric Date: Tue, 14 Feb 2017 15:37:59 +0100 Subject: [PATCH 047/137] HTML escape pth_password --- data/interfaces/default/config.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 5bc2edea..2b64da4f 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -666,7 +666,7 @@
- +
From e7017305d8c871617040e67a904134c2d472fead Mon Sep 17 00:00:00 2001 From: MrClayPole Date: Tue, 21 Feb 2017 08:54:24 +0000 Subject: [PATCH 048/137] Update init.freebsd This path is incorrect for freebsd the path to python bin is /usr/local/bin/python rather than /usr/bin/python --- init-scripts/init.freebsd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/init-scripts/init.freebsd b/init-scripts/init.freebsd index 78decdcc..e250a7f1 100755 --- a/init-scripts/init.freebsd +++ b/init-scripts/init.freebsd @@ -31,7 +31,7 @@ load_rc_config ${name} : "${headphones_conf:="/usr/local/headphones/config.ini"}" command="${headphones_dir}/Headphones.py" -command_interpreter="/usr/bin/python" +command_interpreter="/usr/local/bin/python" pidfile="/var/run/headphones/headphones.pid" start_precmd="headphones_start_precmd" headphones_flags="--daemon --nolaunch --pidfile $pidfile --config $headphones_conf $headphones_flags" From 3cfc434e8a589f119ca1827da2797e125bc43104 Mon Sep 17 00:00:00 2001 From: widewing Date: Fri, 24 Feb 2017 16:32:54 +0800 Subject: [PATCH 049/137] change ajax call method to POST --- data/interfaces/default/js/script.js | 1 + 1 file changed, 1 insertion(+) diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js index 25e194a8..3f926f90 100644 --- a/data/interfaces/default/js/script.js +++ b/data/interfaces/default/js/script.js @@ -312,6 +312,7 @@ function doAjaxCall(url,elem,reload,form) { $.ajax({ url: url, data: dataString, + type: 'POST', beforeSend: function(jqXHR, settings) { // Start loader etc. feedback.prepend(loader); From 3e4c5cb5e23acdc858348641198cb719c80a51e5 Mon Sep 17 00:00:00 2001 From: Fritsbenik Date: Wed, 4 Jan 2017 19:08:48 +0100 Subject: [PATCH 050/137] update init.freebsd Allow to set variables in /etc/rc.conf --- init-scripts/init.freebsd | 1 + 1 file changed, 1 insertion(+) diff --git a/init-scripts/init.freebsd b/init-scripts/init.freebsd index bd4cca2a..78decdcc 100755 --- a/init-scripts/init.freebsd +++ b/init-scripts/init.freebsd @@ -23,6 +23,7 @@ name="headphones" rcvar=${name}_enable +load_rc_config ${name} : "${headphones_enable:="NO"}" : "${headphones_user:="_sabnzbd"}" From 83c50ccd4f58b1e8d37f48d7d98835755fd0967b Mon Sep 17 00:00:00 2001 From: hypsometric Date: Tue, 14 Feb 2017 15:37:59 +0100 Subject: [PATCH 051/137] HTML escape pth_password --- data/interfaces/default/config.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 40f572f5..7b7254a6 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -687,7 +687,7 @@
- +
From ef6a203cdab393370fa6b07e5bc0c990185c0c01 Mon Sep 17 00:00:00 2001 From: MrClayPole Date: Tue, 21 Feb 2017 08:54:24 +0000 Subject: [PATCH 052/137] Update init.freebsd This path is incorrect for freebsd the path to python bin is /usr/local/bin/python rather than /usr/bin/python --- init-scripts/init.freebsd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/init-scripts/init.freebsd b/init-scripts/init.freebsd index 78decdcc..e250a7f1 100755 --- a/init-scripts/init.freebsd +++ b/init-scripts/init.freebsd @@ -31,7 +31,7 @@ load_rc_config ${name} : "${headphones_conf:="/usr/local/headphones/config.ini"}" command="${headphones_dir}/Headphones.py" -command_interpreter="/usr/bin/python" +command_interpreter="/usr/local/bin/python" pidfile="/var/run/headphones/headphones.pid" start_precmd="headphones_start_precmd" headphones_flags="--daemon --nolaunch --pidfile $pidfile --config $headphones_conf $headphones_flags" From 6a1aca66b10b62baddd86324feccd3f81dfda2e9 Mon Sep 17 00:00:00 2001 From: widewing Date: Fri, 24 Feb 2017 16:32:54 +0800 Subject: [PATCH 053/137] change ajax call method to POST --- data/interfaces/default/js/script.js | 1 + 1 file changed, 1 insertion(+) diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js index 25e194a8..3f926f90 100644 --- a/data/interfaces/default/js/script.js +++ b/data/interfaces/default/js/script.js @@ -312,6 +312,7 @@ function doAjaxCall(url,elem,reload,form) { $.ajax({ url: url, data: dataString, + type: 'POST', beforeSend: function(jqXHR, settings) { // Start loader etc. feedback.prepend(loader); From 88366443fd24025afff55556a537bb11dc5225b7 Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 9 Mar 2017 11:10:06 +1300 Subject: [PATCH 054/137] Post processing temp dir fixup Hopefully fixes #2859 --- headphones/helpers.py | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index b01cec69..fd774ec4 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -634,28 +634,40 @@ def preserve_torrent_directory(albumpath): tempdir = headphones.CONFIG.KEEP_TORRENT_FILES_DIR else: tempdir = tempfile.gettempdir() - prefix = "headphones_" + os.path.basename(os.path.normpath(albumpath)) + "_" - new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir) - # Copy to temp dir - subdir = os.path.join(new_folder, "headphones") - logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, - 'replace') + " subfolder to preserve downloaded files for seeding") - - # Attempt to stop multiple temp dirs being created for the same albumpath - tempdir = os.path.join(tempdir, prefix) - if len(glob.glob(tempdir + '*/')) >= 3: - logger.error( - "Looks like a temp subfolder has previously been created for this albumpath, not continuing " + tempdir.decode( - headphones.SYS_ENCODING, 'replace')) - return None + logger.info("Preparing to copy to a temporary directory for post processing: " + albumpath.decode( + headphones.SYS_ENCODING, 'replace')) try: + prefix = "headphones_" + os.path.basename(os.path.normpath(albumpath)) + "_" + new_folder = tempfile.mkdtemp(prefix=prefix, dir=tempdir) + except Exception as e: + logger.error("Cannot create temp directory: " + tempdir.decode( + headphones.SYS_ENCODING, 'replace') + ". Error: " + str(e)) + return None + + # Attempt to stop multiple temp dirs being created for the same albumpath + try: + workdir = os.path.join(tempdir, prefix) + workdir = re.sub(r'\[', '[[]', workdir) + workdir = re.sub(r'(?= 3: + logger.error( + "Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode( + headphones.SYS_ENCODING, 'replace')) + return None + except Exception as e: + logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e)) + + # Copy to temp dir + try: + subdir = os.path.join(new_folder, "headphones") + logger.info("Copying files to " + subdir.decode(headphones.SYS_ENCODING, 'replace')) shutil.copytree(albumpath, subdir) # Update the album path with the new location return subdir except Exception as e: - logger.warn("Cannot copy/move files to temp folder: " + new_folder.decode(headphones.SYS_ENCODING, + logger.warn("Cannot copy/move files to temp directory: " + new_folder.decode(headphones.SYS_ENCODING, 'replace') + ". Not continuing. Error: " + str( e)) shutil.rmtree(new_folder) From dd1e4bfe3b339a80d1daf346c5f922d3d8f7731d Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 9 Mar 2017 11:35:06 +1300 Subject: [PATCH 055/137] Label change --- data/interfaces/default/config.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 7b7254a6..a69e582d 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -1656,7 +1656,7 @@
- +
From 36ad37f005f473667b2c726603492da1a6b8f20f Mon Sep 17 00:00:00 2001 From: Ade Date: Fri, 10 Mar 2017 11:47:36 +1300 Subject: [PATCH 056/137] Don't check temp pp dir if forced --- headphones/helpers.py | 25 +++++++++++++------------ headphones/postprocessor.py | 29 +++++++++++++++-------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index fd774ec4..9bf8659c 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -623,7 +623,7 @@ def get_downloaded_track_list(albumpath): return downloaded_track_list -def preserve_torrent_directory(albumpath): +def preserve_torrent_directory(albumpath, forced=False): """ Copy torrent directory to temp headphones_ directory to keep files for seeding. """ @@ -647,17 +647,18 @@ def preserve_torrent_directory(albumpath): return None # Attempt to stop multiple temp dirs being created for the same albumpath - try: - workdir = os.path.join(tempdir, prefix) - workdir = re.sub(r'\[', '[[]', workdir) - workdir = re.sub(r'(?= 3: - logger.error( - "Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode( - headphones.SYS_ENCODING, 'replace')) - return None - except Exception as e: - logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e)) + if not forced: + try: + workdir = os.path.join(tempdir, prefix) + workdir = re.sub(r'\[', '[[]', workdir) + workdir = re.sub(r'(?= 3: + logger.error( + "Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode( + headphones.SYS_ENCODING, 'replace')) + return None + except Exception as e: + logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e)) # Copy to temp dir try: diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index eabe107a..9a7a4e9c 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -216,7 +216,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal new_folder = None new_albumpath = albumpath if keep_original_folder: - temp_path = helpers.preserve_torrent_directory(new_albumpath) + temp_path = helpers.preserve_torrent_directory(new_albumpath, forced) if not temp_path: markAsUnprocessed(albumid, new_albumpath, keep_original_folder) return @@ -263,7 +263,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal if metaartist == dbartist and metaalbum == dbalbum: doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind, - keep_original_folder) + keep_original_folder, forced) return # test #2: filenames @@ -282,7 +282,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal if dbtrack in filetrack: doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind, - keep_original_folder) + keep_original_folder, forced) return # test #3: number of songs and duration @@ -315,7 +315,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal delta = abs(downloaded_track_duration - db_track_duration) if delta < 240: doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind, - keep_original_folder) + keep_original_folder, forced) return logger.warn(u'Could not identify album: %s. It may not be the intended album.', @@ -337,13 +337,13 @@ def markAsUnprocessed(albumid, albumpath, keep_original_folder=False): def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind=None, - keep_original_folder=False): + keep_original_folder=False, forced=False): logger.info('Starting post-processing for: %s - %s' % (release['ArtistName'], release['AlbumTitle'])) new_folder = None # Preserve the torrent dir if keep_original_folder: - temp_path = helpers.preserve_torrent_directory(albumpath) + temp_path = helpers.preserve_torrent_directory(albumpath, forced) if not temp_path: markAsUnprocessed(albumid, albumpath, keep_original_folder) return @@ -1211,7 +1211,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig 'Found a match in the database: %s. Verifying to make sure it is the correct album', snatched['Title']) verify(snatched['AlbumID'], folder, snatched['Kind'], - keep_original_folder=keep_original_folder) + forced=True, keep_original_folder=keep_original_folder) continue # Attempt 2: strip release group id from filename @@ -1238,7 +1238,8 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig else: logger.info( 'Found a (possibly) valid Musicbrainz release group id in album folder name.') - verify(rgid, folder, forced=True) + verify(rgid, folder, forced=True, + keep_original_folder=keep_original_folder) continue # Attempt 3a: parse the folder name into a valid format @@ -1257,7 +1258,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig logger.info( 'Found a match in the database: %s - %s. Verifying to make sure it is the correct album', release['ArtistName'], release['AlbumTitle']) - verify(release['AlbumID'], folder, keep_original_folder=keep_original_folder) + verify(release['AlbumID'], folder, forced=True, keep_original_folder=keep_original_folder) continue else: logger.info('Querying MusicBrainz for the release group id for: %s - %s', name, @@ -1269,7 +1270,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig rgid = None if rgid: - verify(rgid, folder, keep_original_folder=keep_original_folder) + verify(rgid, folder, forced=True, keep_original_folder=keep_original_folder) continue else: logger.info('No match found on MusicBrainz for: %s - %s', name, album) @@ -1308,7 +1309,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig logger.info( 'Found a match in the database: %s - %s. Verifying to make sure it is the correct album', release['ArtistName'], release['AlbumTitle']) - verify(release['AlbumID'], folder, Kind=kind, keep_original_folder=keep_original_folder) + verify(release['AlbumID'], folder, Kind=kind, forced=True, keep_original_folder=keep_original_folder) continue else: logger.info('Querying MusicBrainz for the release group id for: %s - %s', name, @@ -1320,7 +1321,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig rgid = None if rgid: - verify(rgid, folder, Kind=kind, keep_original_folder=keep_original_folder) + verify(rgid, folder, Kind=kind, forced=True, keep_original_folder=keep_original_folder) continue else: logger.info('No match found on MusicBrainz for: %s - %s', name, album) @@ -1337,7 +1338,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig logger.info( 'Found a match in the database: %s - %s. Verifying to make sure it is the correct album', release['ArtistName'], release['AlbumTitle']) - verify(release['AlbumID'], folder, keep_original_folder=keep_original_folder) + verify(release['AlbumID'], folder, forced=True, keep_original_folder=keep_original_folder) continue else: logger.info('Querying MusicBrainz for the release group id for: %s', @@ -1349,7 +1350,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig rgid = None if rgid: - verify(rgid, folder, keep_original_folder=keep_original_folder) + verify(rgid, folder, forced=True, keep_original_folder=keep_original_folder) continue else: logger.info('No match found on MusicBrainz for: %s - %s', name, album) From 697e695f7d2e4c7ee7fd15c6c2f0ee763c9a9712 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 11 Mar 2017 08:38:42 +1300 Subject: [PATCH 057/137] More temp dir stuff --- headphones/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/headphones/helpers.py b/headphones/helpers.py index 9bf8659c..11bc68b8 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -656,6 +656,7 @@ def preserve_torrent_directory(albumpath, forced=False): logger.error( "Looks like a temp directory has previously been created for this albumpath, not continuing " + workdir.decode( headphones.SYS_ENCODING, 'replace')) + shutil.rmtree(new_folder) return None except Exception as e: logger.warn("Cannot determine if already copied/processed, will copy anyway: Warning: " + str(e)) From 9ccb0f85dc109cbb105690dd9cccd296aa29f9ba Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 11 Mar 2017 12:19:53 +1300 Subject: [PATCH 058/137] Use CAA artwork for post processing Preference is: CAA Amazon last.fm --- headphones/albumart.py | 56 +++++++++++++++++++++++++++++++++++-- headphones/postprocessor.py | 20 +++++-------- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/headphones/albumart.py b/headphones/albumart.py index 8bbbd425..803d469a 100644 --- a/headphones/albumart.py +++ b/headphones/albumart.py @@ -17,12 +17,64 @@ from headphones import request, db, logger def getAlbumArt(albumid): + + artwork_path = None + artwork = None + + # CAA + artwork_path = 'http://coverartarchive.org/release-group/%s/front' % albumid + artwork = request.request_content(artwork_path, timeout=20) + if artwork and len(artwork) >= 100: + logger.info("Artwork found at CAA") + return artwork_path, artwork + + # Amazon myDB = db.DBConnection() asin = myDB.action( 'SELECT AlbumASIN from albums WHERE AlbumID=?', [albumid]).fetchone()[0] - if asin: - return 'http://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % asin + artwork_path = 'http://ec1.images-amazon.com/images/P/%s.01.LZZZZZZZ.jpg' % asin + artwork = request.request_content(artwork_path, timeout=20) + if artwork and len(artwork) >= 100: + logger.info("Artwork found at Amazon") + return artwork_path, artwork + + # last.fm + from headphones import lastfm + + myDB = db.DBConnection() + dbalbum = myDB.action( + 'SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?', + [albumid]).fetchone() + if dbalbum['ReleaseID'] != albumid: + data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID']) + if not data: + data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], + album=dbalbum['AlbumTitle']) + else: + data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], + album=dbalbum['AlbumTitle']) + + if data: + try: + images = data['album']['image'] + for image in images: + if image['size'] == 'extralarge': + artwork_path = image['#text'] + elif image['size'] == 'mega': + artwork_path = image['#text'] + break + except KeyError: + artwork_path = None + + if artwork_path: + artwork = request.request_content(artwork_path, timeout=20) + if artwork and len(artwork) >= 100: + logger.info("Artwork found at last.fm") + return artwork_path, artwork + + logger.info("No suitable album art found.") + return None, None def getCachedArt(albumid): diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 9a7a4e9c..39393e58 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -410,21 +410,15 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, shutil.rmtree(new_folder) return + # get artwork and path + album_art_path = None artwork = None - album_art_path = albumart.getAlbumArt(albumid) - if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART: + if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART or \ + (headphones.CONFIG.PLEX_ENABLED and headphones.CONFIG.PLEX_NOTIFY) or \ + (headphones.CONFIG.XBMC_ENABLED and CONFIG.XBMC_NOTIFY): - if album_art_path: - artwork = request.request_content(album_art_path) - else: - artwork = None - - if not album_art_path or not artwork or len(artwork) < 100: - logger.info("No suitable album art found from Amazon. Checking Last.FM....") - artwork = albumart.getCachedArt(albumid) - if not artwork or len(artwork) < 100: - artwork = False - logger.info("No suitable album art found from Last.FM. Not adding album art") + logger.info('Searching for artwork') + album_art_path, artwork = albumart.getAlbumArt(albumid) if headphones.CONFIG.EMBED_ALBUM_ART and artwork: embedAlbumArt(artwork, downloaded_track_list) From 7797cd0681757672888f6808f7d6a5fb0265002c Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 11 Mar 2017 12:37:13 +1300 Subject: [PATCH 059/137] pep8 --- headphones/postprocessor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 39393e58..7727fd82 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -414,9 +414,8 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, album_art_path = None artwork = None if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART or \ - (headphones.CONFIG.PLEX_ENABLED and headphones.CONFIG.PLEX_NOTIFY) or \ - (headphones.CONFIG.XBMC_ENABLED and CONFIG.XBMC_NOTIFY): - + (headphones.CONFIG.PLEX_ENABLED and headphones.CONFIG.PLEX_NOTIFY) or \ + (headphones.CONFIG.XBMC_ENABLED and CONFIG.XBMC_NOTIFY): logger.info('Searching for artwork') album_art_path, artwork = albumart.getAlbumArt(albumid) From a9bbd34a52d31a34f707e24050c355321c313df0 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 11 Mar 2017 12:44:13 +1300 Subject: [PATCH 060/137] more pep8 --- headphones/postprocessor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 7727fd82..620ff748 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -28,7 +28,7 @@ from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from beetsplug import lyrics as beetslyrics from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync -from headphones import logger, helpers, request, mb, music_encoder +from headphones import logger, helpers, mb, music_encoder from headphones import metadata postprocessor_lock = threading.Lock() @@ -415,7 +415,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, artwork = None if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART or \ (headphones.CONFIG.PLEX_ENABLED and headphones.CONFIG.PLEX_NOTIFY) or \ - (headphones.CONFIG.XBMC_ENABLED and CONFIG.XBMC_NOTIFY): + (headphones.CONFIG.XBMC_ENABLED and headphones.CONFIG.XBMC_NOTIFY): logger.info('Searching for artwork') album_art_path, artwork = albumart.getAlbumArt(albumid) From e415de769849b1414e8011827b8deeab73aece09 Mon Sep 17 00:00:00 2001 From: Noam Date: Sat, 11 Mar 2017 15:38:12 +0200 Subject: [PATCH 061/137] Added JSON-related HTTP headers --- headphones/deluge.py | 49 ++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 5f16a2ba..cfab6355 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -49,6 +49,7 @@ delugeweb_auth = {} delugeweb_url = '' deluge_verify_cert = False scrub_logs = True +headers = {'Accept': 'application/json', 'Content-Type': 'application/json'} def _scrubber(text): @@ -104,11 +105,11 @@ def addTorrent(link, data=None, name=None): # return addTorrent(local_torrent_path) else: 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' - headers = {'User-Agent': user_agent} + get_headers = {'User-Agent': user_agent} torrentfile = '' logger.debug('Deluge: Trying to download (GET)') try: - r = requests.get(link, headers=headers) + r = requests.get(link, headers=get_headers) if r.status_code == 200: logger.debug('Deluge: 200 OK') # .text will ruin the encoding for some torrents @@ -202,7 +203,7 @@ def getTorrentFolder(result): ], "id": 21}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) result['total_done'] = json.loads(response.text)['result']['total_done'] tries = 0 @@ -210,7 +211,7 @@ def getTorrentFolder(result): tries += 1 time.sleep(5) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) result['total_done'] = json.loads(response.text)['result']['total_done'] post_data = json.dumps({"method": "web.get_torrent_status", @@ -229,7 +230,7 @@ def getTorrentFolder(result): "id": 23}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) result['save_path'] = json.loads(response.text)['result']['save_path'] result['name'] = json.loads(response.text)['result']['name'] @@ -253,7 +254,7 @@ def removeTorrent(torrentid, remove_data=False): ], "id": 25}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) result = json.loads(response.text)['result'] return result @@ -296,12 +297,12 @@ def _get_auth(): "id": 1}) try: response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) except requests.ConnectionError: try: logger.debug('Deluge: Connection failed, let\'s try HTTPS just in case') response = requests.post(delugeweb_url.replace('http:', 'https:'), data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) # If the previous line didn't fail, change delugeweb_url for the rest of this session logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!') delugeweb_url = delugeweb_url.replace('http:', 'https:') @@ -326,7 +327,7 @@ def _get_auth(): "id": 10}) try: response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) except Exception as e: logger.error('Deluge: Authentication failed: %s' % str(e)) formatted_lines = traceback.format_exc().splitlines() @@ -343,7 +344,7 @@ def _get_auth(): "id": 11}) try: response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) except Exception as e: logger.error('Deluge: Authentication failed: %s' % str(e)) formatted_lines = traceback.format_exc().splitlines() @@ -361,7 +362,7 @@ def _get_auth(): try: response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) except Exception as e: logger.error('Deluge: Authentication failed: %s' % str(e)) formatted_lines = traceback.format_exc().splitlines() @@ -374,7 +375,7 @@ def _get_auth(): try: response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) except Exception as e: logger.error('Deluge: Authentication failed: %s' % str(e)) formatted_lines = traceback.format_exc().splitlines() @@ -399,7 +400,7 @@ def _add_torrent_magnet(result): "params": [result['url'], {}], "id": 2}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + 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'] @@ -419,7 +420,7 @@ def _add_torrent_url(result): "params": [result['url'], {}], "id": 32}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) result['location'] = json.loads(response.text)['result'] logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) return json.loads(response.text)['result'] @@ -440,7 +441,7 @@ def _add_torrent_file(result): "params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}], "id": 2}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + 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'] @@ -453,7 +454,7 @@ def _add_torrent_file(result): "params": [result['name'] + '.torrent', b64encode(result['content']), {}], "id": 22}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + 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'] @@ -485,7 +486,7 @@ def setTorrentLabel(result): "params": [], "id": 3}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) labels = json.loads(response.text)['result'] if labels is not None: @@ -496,7 +497,7 @@ def setTorrentLabel(result): "params": [label], "id": 4}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) logger.debug('Deluge: %s label added to Deluge' % label) except Exception as e: logger.error('Deluge: Setting label failed: %s' % str(e)) @@ -508,7 +509,7 @@ def setTorrentLabel(result): "params": [result['hash'], label], "id": 5}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) logger.debug('Deluge: %s label added to torrent' % label) else: logger.debug('Deluge: Label plugin not detected') @@ -532,12 +533,12 @@ def setSeedRatio(result): "params": [result['hash'], True], "id": 5}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) post_data = json.dumps({"method": "core.set_torrent_stop_ratio", "params": [result['hash'], float(ratio)], "id": 6}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) return not json.loads(response.text)['error'] @@ -560,7 +561,7 @@ def setTorrentPath(result): "params": [result['hash'], True], "id": 7}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) if headphones.CONFIG.DELUGE_DONE_DIRECTORY: move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY @@ -574,7 +575,7 @@ def setTorrentPath(result): "params": [result['hash'], move_to], "id": 8}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) return not json.loads(response.text)['error'] @@ -597,7 +598,7 @@ def setTorrentPause(result): "params": [[result['hash']]], "id": 9}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, - verify=deluge_verify_cert) + verify=deluge_verify_cert, headers=headers) return not json.loads(response.text)['error'] From 578ae2c2d8246e20498e05310144e58287f153e1 Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 12 Mar 2017 12:47:12 +1300 Subject: [PATCH 062/137] More post processing artwork changes - Added min/max pixel width config options - Artwork < min width is ignored - Artwork > max width is downsized to max width using a proxy web service --- data/interfaces/default/config.html | 66 +++++++++++--- headphones/albumart.py | 129 ++++++++++++++++++++++++++-- headphones/config.py | 2 + headphones/postprocessor.py | 1 - headphones/webserve.py | 2 + 5 files changed, 181 insertions(+), 19 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index a69e582d..a1f8ba64 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -949,30 +949,42 @@ +
+ +
+ + +
+ +
+
+
as .jpg
Use $Artist/$artist, $Album/$album, $Year/$year, put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').
-
- -
-
- + +
+
+ Album art min width \ + Album art max width +
+
+ +
+ Direct Download + Aria2 +
+
+
+ + + usually http://localhost:6800/jsonrpc +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Full path where ddl client downloads your music, e.g. /Users/name/Downloads/music +
+
@@ -461,6 +500,7 @@ NZBs Torrents + Direct Download No Preference
@@ -573,6 +613,17 @@ + +
+ Direct Download + +
+
+ +
+
+ +
@@ -804,7 +855,6 @@
- @@ -2405,6 +2455,11 @@ $("#deluge_options").show(); } + if ($("#ddl_downloader_aria").is(":checked")) + { + $("#ddl_aria_options").show(); + } + $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) { @@ -2458,6 +2513,9 @@ { $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } + if ($("#ddl_downloader_aria").is(":checked")) + { + } }); $("#mirror").change(handleNewServerSelection); @@ -2560,6 +2618,7 @@ initConfigCheckbox("#enable_https"); initConfigCheckbox("#customauth"); initConfigCheckbox("#use_tquattrecentonze"); + initConfigCheckbox("#use_deezloader"); $('#twitterStep1').click(function () { diff --git a/headphones/aria2.py b/headphones/aria2.py new file mode 100644 index 00000000..68c4cb0a --- /dev/null +++ b/headphones/aria2.py @@ -0,0 +1,1095 @@ +# -*- coding: utf8 -*- +# Copyright (C) 2012-2016 Xyne +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# (version 2) as published by the Free Software Foundation. +# +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +from __future__ import with_statement +import base64 +import json +import math +import os +import ssl +import string +import time +import httplib +import urllib2 + +from headphones import logger + +################################## Constants ################################### + +DEFAULT_PORT = 6800 +SERVER_URI_FORMAT = '{}://{}:{:d}/jsonrpc' + +# Status values for unfinished downloads. +TEMPORARY_STATUS = ('active', 'waiting', 'paused') +# Status values for finished downloads. +FINAL_STATUS = ('complete', 'error') + +ARIA2_CONTROL_FILE_EXT = '.aria2' + +############################ Convenience Functions ############################# + +def to_json_list(objs): + ''' + Wrap strings in lists. Other iterables are converted to lists directly. + ''' + if isinstance(objs, str): + return [objs] + elif not isinstance(objs, list): + return list(objs) + else: + return objs + + + +def add_options_and_position(params, options=None, position=None): + ''' + Convenience method for adding options and position to parameters. + ''' + if options: + params.append(options) + if position: + if not isinstance(position, int): + try: + position = int(position) + except ValueError: + position = -1 + if position >= 0: + params.append(position) + return params + + + +def get_status(response): + ''' + Process a status response. + ''' + if response: + try: + return response['status'] + except KeyError: + logger.error('no status returned from Aria2 RPC server') + return 'error' + else: + logger.error('no response from server') + return 'error' + + + +def random_token(length, valid_chars=None): + ''' + Get a random secret token for the Aria2 RPC server. + + length: + The length of the token + + valid_chars: + A list or other ordered and indexable iterable of valid characters. If not + given of None, asciinumberic characters with some punctuation characters + will be used. + ''' + if not valid_chars: + valid_chars = string.ascii_letters + string.digits + '!@#$%^&*()-_=+' + number_of_chars = len(valid_chars) + bytes_to_read = math.ceil(math.log(number_of_chars) / math.log(0x100)) + max_value = 0x100**bytes_to_read + max_index = number_of_chars - 1 + token = '' + for _ in range(length): + value = int.from_bytes(os.urandom(bytes_to_read), byteorder='little') + index = round((value * max_index)/max_value) + token += valid_chars[index] + return token + + +################## From python3-aur's ThreadedServers.common ################### + +def format_bytes(size): + '''Format bytes for inferior humans.''' + if size < 0x400: + return '{:d} B'.format(size) + else: + size = float(size) / 0x400 + for prefix in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB'): + if size < 0x400: + return '{:0.02f} {}'.format(size, prefix) + else: + size /= 0x400 + return '{:0.02f} YiB'.format(size) + + + +def format_seconds(s): + '''Format seconds for inferior humans.''' + string = '' + for base, char in ( + (60, 's'), + (60, 'm'), + (24, 'h') + ): + s, r = divmod(s, base) + if s == 0: + return '{:d}{}{}'.format(r, char, string) + elif r != 0: + string = '{:02d}{}{}'.format(r, char, string) + else: + return '{:d}d{}'.format(s, string) + +############################## Aria2JsonRpcError ############################### + +class Aria2JsonRpcError(Exception): + def __init__(self, msg, connection_error=False): + super(self.__class__, self).__init__(self, msg) + self.connection_error = connection_error + +############################## Aria2JsonRpc Class ############################## + +class Aria2JsonRpc(object): + ''' + Interface class for interacting with an Aria2 RPC server. + ''' + # TODO: certificate options, etc. + def __init__( + self, ID, uri, + mode='normal', + token=None, + http_user=None, http_passwd=None, + server_cert=None, client_cert=None, client_cert_password=None, + ssl_protocol=None, + setup_function=None + ): + ''' + ID: the ID to send to the RPC interface + + uri: the URI of the RPC interface + + mode: + normal - process requests immediately + batch - queue requests (run with "process_queue") + format - return RPC request objects + + token: + RPC method-level authorization token (set using `--rpc-secret`) + + http_user, http_password: + HTTP Basic authentication credentials (deprecated) + + server_cert: + server certificate for HTTPS connections + + client_cert: + client certificate for HTTPS connections + + client_cert_password: + prompt for client certificate password + + ssl_protocol: + SSL protocol from the ssl module + + setup_function: + A function to invoke prior to the first server call. This could be the + launch() method of an Aria2RpcServer instance, for example. This attribute + is set automatically in instances returned from Aria2RpcServer.get_a2jr() + ''' + self.id = ID + self.uri = uri + self.mode = mode + self.queue = [] + self.handlers = dict() + self.token = token + self.setup_function = setup_function + + if None not in (http_user, http_passwd): + self.add_HTTPBasicAuthHandler(http_user, http_passwd) + + if server_cert or client_cert: + self.add_HTTPSHandler( + server_cert=server_cert, + client_cert=client_cert, + client_cert_password=client_cert_password, + protocol=ssl_protocol + ) + + self.update_opener() + + + + def iter_handlers(self): + ''' + Iterate over handlers. + ''' + for name in ('HTTPS', 'HTTPBasicAuth'): + try: + yield self.handlers[name] + except KeyError: + pass + + + + def update_opener(self): + ''' + Build an opener from the current handlers. + ''' + self.opener = urllib2.build_opener(*self.iter_handlers()) + + + + def remove_handler(self, name): + ''' + Remove a handler. + ''' + try: + del self.handlers[name] + except KeyError: + pass + + + + def add_HTTPBasicAuthHandler(self, user, passwd): + ''' + Add a handler for HTTP Basic authentication. + + If either user or passwd are None, the handler is removed. + ''' + handler = urllib2.HTTPBasicAuthHandler() + handler.add_password( + realm='aria2', + uri=self.uri, + user=user, + passwd=passwd, + ) + self.handlers['HTTPBasicAuth'] = handler + + + + def remove_HTTPBasicAuthHandler(self): + self.remove_handler('HTTPBasicAuth') + + + + def add_HTTPSHandler( + self, + server_cert=None, + client_cert=None, + client_cert_password=None, + protocol=None, + ): + ''' + Add a handler for HTTPS connections with optional server and client + certificates. + ''' + if not protocol: + protocol = ssl.PROTOCOL_TLSv1 +# protocol = ssl.PROTOCOL_TLSv1_1 # openssl 1.0.1+ +# protocol = ssl.PROTOCOL_TLSv1_2 # Python 3.4+ + context = ssl.SSLContext(protocol) + + if server_cert: + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(cafile=server_cert) + else: + context.verify_mode = ssl.CERT_OPTIONAL + + if client_cert: + context.load_cert_chain(client_cert, password=client_cert_password) + + self.handlers['HTTPS'] = urllib2.HTTPSHandler( + context=context, + check_hostname=False + ) + + + + def remove_HTTPSHandler(self): + self.remove_handler('HTTPS') + + + + def send_request(self, req_obj): + ''' + Send the request and return the response. + ''' + if self.setup_function: + self.setup_function() + self.setup_function = None + logger.debug("Aria req_obj: %s" % json.dumps(req_obj, indent=2, sort_keys=True)) + req = json.dumps(req_obj).encode('UTF-8') + try: + f = self.opener.open(self.uri, req) + obj = json.loads(f.read()) + try: + return obj['result'] + except KeyError: + raise Aria2JsonRpcError('unexpected result: {}'.format(obj)) + except (urllib2.URLError) as e: + # This should work but URLError does not set the errno attribute: + # e.errno == errno.ECONNREFUSED + raise Aria2JsonRpcError( + str(e), + connection_error=( + '111' in str(e) + ) + ) + except httplib.BadStatusLine as e: + raise Aria2JsonRpcError('{}: BadStatusLine: {} (HTTPS error?)'.format( + self.__class__.__name__, e + )) + + + + def jsonrpc(self, method, params=None, prefix='aria2.'): + ''' + POST a request to the RPC interface. + ''' + if not params: + params = [] + + if self.token is not None: + token_str = 'token:{}'.format(self.token) + if method == 'multicall': + for p in params[0]: + try: + p['params'].insert(0, token_str) + except KeyError: + p['params'] = [token_str] + else: + params.insert(0, token_str) + + req_obj = { + 'jsonrpc' : '2.0', + 'id' : self.id, + 'method' : prefix + method, + 'params' : params, + } + if self.mode == 'batch': + self.queue.append(req_obj) + return None + elif self.mode == 'format': + return req_obj + else: + return self.send_request(req_obj) + + + + def process_queue(self): + ''' + Processed queued requests. + ''' + req_obj = self.queue + self.queue = [] + return self.send_request(req_obj) + + + +############################### Standard Methods ############################### + + def addUri(self, uris, options=None, position=None): + ''' + aria2.addUri method + + uris: list of URIs + + options: dictionary of additional options + + position: position in queue + + Returns a GID + ''' + params = [uris] + params = add_options_and_position(params, options, position) + return self.jsonrpc('addUri', params) + + + + def addTorrent(self, torrent, uris=None, options=None, position=None): + ''' + aria2.addTorrent method + + torrent: base64-encoded torrent file + + uris: list of webseed URIs + + options: dictionary of additional options + + position: position in queue + + Returns a GID. + ''' + params = [torrent] + if uris: + params.append(uris) + params = add_options_and_position(params, options, position) + return self.jsonrpc('addTorrent', params) + + + + def addMetalink(self, metalink, options=None, position=None): + ''' + aria2.addMetalink method + + metalink: base64-encoded metalink file + + options: dictionary of additional options + + position: position in queue + + Returns an array of GIDs. + ''' + params = [metalink] + params = add_options_and_position(params, options, position) + return self.jsonrpc('addTorrent', params) + + + + def remove(self, gid): + ''' + aria2.remove method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('remove', params) + + + + def forceRemove(self, gid): + ''' + aria2.forceRemove method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('forceRemove', params) + + + + def pause(self, gid): + ''' + aria2.pause method + + gid: GID to pause + ''' + params = [gid] + return self.jsonrpc('pause', params) + + + + def pauseAll(self): + ''' + aria2.pauseAll method + ''' + return self.jsonrpc('pauseAll') + + + + def forcePause(self, gid): + ''' + aria2.forcePause method + + gid: GID to pause + ''' + params = [gid] + return self.jsonrpc('forcePause', params) + + + + def forcePauseAll(self): + ''' + aria2.forcePauseAll method + ''' + return self.jsonrpc('forcePauseAll') + + + + def unpause(self, gid): + ''' + aria2.unpause method + + gid: GID to unpause + ''' + params = [gid] + return self.jsonrpc('unpause', params) + + + + def unpauseAll(self): + ''' + aria2.unpauseAll method + ''' + return self.jsonrpc('unpauseAll') + + + + def tellStatus(self, gid, keys=None): + ''' + aria2.tellStatus method + + gid: GID to query + + keys: subset of status keys to return (all keys are returned otherwise) + + Returns a dictionary. + ''' + params = [gid] + if keys: + params.append(keys) + return self.jsonrpc('tellStatus', params) + + + + def getUris(self, gid): + ''' + aria2.getUris method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getUris', params) + + + + def getFiles(self, gid): + ''' + aria2.getFiles method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getFiles', params) + + + + def getPeers(self, gid): + ''' + aria2.getPeers method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getPeers', params) + + + + def getServers(self, gid): + ''' + aria2.getServers method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getServers', params) + + + + def tellActive(self, keys=None): + ''' + aria2.tellActive method + + keys: same as tellStatus + + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + if keys: + params = [keys] + else: + params = None + return self.jsonrpc('tellActive', params) + + + + def tellWaiting(self, offset, num, keys=None): + ''' + aria2.tellWaiting method + + offset: offset from start of waiting download queue + (negative values are counted from the end of the queue) + + num: number of downloads to return + + keys: same as tellStatus + + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + params = [offset, num] + if keys: + params.append(keys) + return self.jsonrpc('tellWaiting', params) + + + + def tellStopped(self, offset, num, keys=None): + ''' + aria2.tellStopped method + + offset: offset from oldest download (same semantics as tellWaiting) + + num: same as tellWaiting + + keys: same as tellStatus + + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + params = [offset, num] + if keys: + params.append(keys) + return self.jsonrpc('tellStopped', params) + + + + def changePosition(self, gid, pos, how): + ''' + aria2.changePosition method + + gid: GID to change + + pos: the position + + how: "POS_SET", "POS_CUR" or "POS_END" + ''' + params = [gid, pos, how] + return self.jsonrpc('changePosition', params) + + + + def changeUri(self, gid, fileIndex, delUris, addUris, position=None): + ''' + aria2.changePosition method + + gid: GID to change + + fileIndex: file to affect (1-based) + + delUris: URIs to remove + + addUris: URIs to add + + position: where URIs are inserted, after URIs have been removed + ''' + params = [gid, fileIndex, delUris, addUris] + if position: + params.append(position) + return self.jsonrpc('changePosition', params) + + + + def getOption(self, gid): + ''' + aria2.getOption method + + gid: GID to query + + Returns a dictionary of options. + ''' + params = [gid] + return self.jsonrpc('getOption', params) + + + + def changeOption(self, gid, options): + ''' + aria2.changeOption method + + gid: GID to change + + options: dictionary of new options + (not all options can be changed for active downloads) + ''' + params = [gid, options] + return self.jsonrpc('changeOption', params) + + + + def getGlobalOption(self): + ''' + aria2.getGlobalOption method + + Returns a dictionary. + ''' + return self.jsonrpc('getGlobalOption') + + + + def changeGlobalOption(self, options): + ''' + aria2.changeGlobalOption method + + options: dictionary of new options + ''' + params = [options] + return self.jsonrpc('changeGlobalOption', params) + + + + def getGlobalStat(self): + ''' + aria2.getGlobalStat method + + Returns a dictionary. + ''' + return self.jsonrpc('getGlobalStat') + + + + def purgeDownloadResult(self): + ''' + aria2.purgeDownloadResult method + ''' + self.jsonrpc('purgeDownloadResult') + + + + def removeDownloadResult(self, gid): + ''' + aria2.removeDownloadResult method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('removeDownloadResult', params) + + + + def getVersion(self): + ''' + aria2.getVersion method + + Returns a dictionary. + ''' + return self.jsonrpc('getVersion') + + + + def getSessionInfo(self): + ''' + aria2.getSessionInfo method + + Returns a dictionary. + ''' + return self.jsonrpc('getSessionInfo') + + + + def shutdown(self): + ''' + aria2.shutdown method + ''' + return self.jsonrpc('shutdown') + + + + def forceShutdown(self): + ''' + aria2.forceShutdown method + ''' + return self.jsonrpc('forceShutdown') + + + + def multicall(self, methods): + ''' + aria2.multicall method + + methods: list of dictionaries (keys: methodName, params) + + The method names must be those used by Aria2c, e.g. "aria2.tellStatus". + ''' + return self.jsonrpc('multicall', [methods], prefix='system.') + + + + +############################# Convenience Methods ############################## + + def add_torrent(self, path, uris=None, options=None, position=None): + ''' + A wrapper around addTorrent for loading files. + ''' + with open(path, 'r') as f: + torrent = base64.encode(f.read()) + return self.addTorrent(torrent, uris, options, position) + + + + def add_metalink(self, path, uris=None, options=None, position=None): + ''' + A wrapper around addMetalink for loading files. + ''' + with open(path, 'r') as f: + metalink = base64.encode(f.read()) + return self.addMetalink(metalink, uris, options, position) + + + + def get_status(self, gid): + ''' + Get the status of a single GID. + ''' + response = self.tellStatus(gid, ['status']) + return get_status(response) + + + + def wait_for_final_status(self, gid, interval=1): + ''' + Wait for a GID to complete or fail and return its status. + ''' + if not interval or interval < 0: + interval = 1 + while True: + status = self.get_status(gid) + if status in TEMPORARY_STATUS: + time.sleep(interval) + else: + return status + + + + def get_statuses(self, gids): + ''' + Get the status of multiple GIDs. The status of each is yielded in order. + ''' + methods = [ + { + 'methodName' : 'aria2.tellStatus', + 'params' : [gid, ['gid', 'status']] + } + for gid in gids + ] + results = self.multicall(methods) + if results: + status = dict((r[0]['gid'], r[0]['status']) for r in results) + for gid in gids: + try: + yield status[gid] + except KeyError: + logger.error('Aria2 RPC server returned no status for GID {}'.format(gid)) + yield 'error' + else: + logger.error('no response from Aria2 RPC server') + for gid in gids: + yield 'error' + + + + def wait_for_final_statuses(self, gids, interval=1): + ''' + Wait for multiple GIDs to complete or fail and return their statuses in + order. + + gids: + A flat list of GIDs. + ''' + if not interval or interval < 0: + interval = 1 + statusmap = dict((g, None) for g in gids) + remaining = list( + g for g,s in statusmap.items() if s is None + ) + while remaining: + for g, s in zip(remaining, self.get_statuses(remaining)): + if s in TEMPORARY_STATUS: + continue + else: + statusmap[g] = s + remaining = list( + g for g,s in statusmap.items() if s is None + ) + if remaining: + time.sleep(interval) + for g in gids: + yield statusmap[g] + + + + def print_global_status(self): + ''' + Print global status of the RPC server. + ''' + status = self.getGlobalStat() + if status: + numWaiting = int(status['numWaiting']) + numStopped = int(status['numStopped']) + keys = ['totalLength', 'completedLength'] + total = self.tellActive(keys) + waiting = self.tellWaiting(0, numWaiting, keys) + if waiting: + total += waiting + stopped = self.tellStopped(0, numStopped, keys) + if stopped: + total += stopped + + downloadSpeed = int(status['downloadSpeed']) + uploadSpeed = int(status['uploadSpeed']) + totalLength = sum(int(x['totalLength']) for x in total) + completedLength = sum(int(x['completedLength']) for x in total) + remaining = totalLength - completedLength + + status['downloadSpeed'] = format_bytes(downloadSpeed) + '/s' + status['uploadSpeed'] = format_bytes(uploadSpeed) + '/s' + + preordered = ('downloadSpeed', 'uploadSpeed') + + rows = list() + for k in sorted(status): + if k in preordered: + continue + rows.append((k, status[k])) + + rows.extend((x, status[x]) for x in preordered) + + if totalLength > 0: + rows.append(('total', format(format_bytes(totalLength)))) + rows.append(('completed', format(format_bytes(completedLength)))) + rows.append(('remaining', format(format_bytes(remaining)))) + if completedLength == totalLength: + eta = 'finished' + else: + try: + eta = format_seconds(remaining // downloadSpeed) + except ZeroDivisionError: + eta = 'never' + rows.append(('ETA', eta)) + + l = max(len(r[0]) for r in rows) + r = max(len(r[1]) for r in rows) + r = max(r, len(self.uri) - (l + 2)) + fmt = '{:<' + str(l) + 's} {:>' + str(r) + 's}' + + print(self.uri) + for k, v in rows: + print(fmt.format(k, v)) + + + + def queue_uris(self, uris, options, interval=None): + ''' + Enqueue URIs and wait for download to finish while printing status at + regular intervals. + ''' + gid = self.addUri(uris, options) + print('GID: {}'.format(gid)) + + if gid and interval is not None: + blanker = '' + while True: + response = self.tellStatus(gid, ['status']) + if response: + try: + status = response['status'] + except KeyError: + print('error: no status returned from Aria2 RPC server') + break + print('{}\rstatus: {}'.format(blanker, status)), + blanker = ' ' * len(status) + if status in TEMPORARY_STATUS: + time.sleep(interval) + else: + break + else: + print('error: no response from server') + break + + + +######################### Polymethod download handlers ######################### + + def polymethod_enqueue_many(self, downloads): + ''' + Enqueue downloads. + + downloads: Same as polymethod_download(). + ''' + methods = list( + { + 'methodName': 'aria2.{}'.format(d[0]), + 'params': list(d[1:]) + } for d in downloads + ) + return self.multicall(methods) + + + + def polymethod_wait_many(self, gids, interval=1): + ''' + Wait for the GIDs to complete or fail and return their statuses. + + gids: + A list of lists of GIDs. + ''' + # The flattened list of GIDs + gs = list(g for gs in gids for g in gs) + statusmap = dict(tuple(zip( + gs, + self.wait_for_final_statuses(gs, interval=interval) + ))) + for gs in gids: + yield list(statusmap.get(g, 'error') for g in gs) + + + + + def polymethod_enqueue_one(self, download): + ''' + Same as polymethod_enqueue_many but for one element. + ''' + return getattr(self, download[0])(*download[1:]) + + + + def polymethod_download(self, downloads, interval=1): + ''' + Enqueue a series of downloads and wait for them to finish. Iterate over the + status of each, in order. + + downloads: + An iterable over (, , ...) where indicates the "add" + method to use ('addUri', 'addTorrent', 'addMetalink') and everything that + follows are arguments to pass to that method. + + interval: + The status check interval while waiting. + + Iterates over the download status of finished downloads. "complete" + indicates success. Lists of statuses will be returned for downloads that + create multiple GIDs (e.g. metalinks). + ''' + gids = self.polymethod_enqueue_many(downloads) + return self.polymethod_wait_many(gids, interval=interval) + + + + def polymethod_download_bool(self, *args, **kwargs): + ''' + A wrapper around polymethod_download() which returns a boolean for each + download to indicate success (True) or failure (False). + ''' +# for status in self.polymethod_download(*args, **kwargs): +# yield all(s == 'complete' for s in status) + return list( + all(s == 'complete' for s in status) + for status in self.polymethod_download(*args, **kwargs) + ) + \ No newline at end of file diff --git a/headphones/config.py b/headphones/config.py index 57af4c16..86aa9216 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -46,6 +46,10 @@ _CONFIG_DEFINITIONS = { 'APOLLO_RATIO': (str, 'Apollo.rip', ''), 'APOLLO_USERNAME': (str, 'Apollo.rip', ''), 'APOLLO_URL': (str, 'Apollo.rip', 'https://apollo.rip'), + 'ARIA_HOST': (str, 'Aria2', ''), + 'ARIA_PASSWORD': (str, 'Aria2', ''), + 'ARIA_TOKEN': (str, 'Aria2', ''), + 'ARIA_USERNAME': (str, 'Aria2', ''), 'AUTOWANT_ALL': (int, 'General', 0), 'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1), 'AUTOWANT_UPCOMING': (int, 'General', 1), @@ -73,6 +77,8 @@ _CONFIG_DEFINITIONS = { 'CUSTOMPORT': (int, 'General', 5000), 'CUSTOMSLEEP': (int, 'General', 1), 'CUSTOMUSER': (str, 'General', ''), + 'DDL_DOWNLOADER': (int, 'General', 0), + 'DEEZLOADER': (int, 'DeezLoader', 0), 'DELETE_LOSSLESS_FILES': (int, 'General', 1), 'DELUGE_HOST': (str, 'Deluge', ''), 'DELUGE_CERT': (str, 'Deluge', ''), @@ -86,6 +92,7 @@ _CONFIG_DEFINITIONS = { 'DOWNLOAD_DIR': (path, 'General', ''), 'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5), 'DOWNLOAD_TORRENT_DIR': (path, 'General', ''), + 'DOWNLOAD_DDL_DIR': (path, 'General', ''), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'EMAIL_ENABLED': (int, 'Email', 0), 'EMAIL_FROM': (str, 'Email', ''), diff --git a/headphones/deezloader.py b/headphones/deezloader.py new file mode 100644 index 00000000..db14ae5d --- /dev/null +++ b/headphones/deezloader.py @@ -0,0 +1,415 @@ +# -*- coding: utf-8 -*- +# Deezloader (c) 2016 by ParadoxalManiak +# +# Deezloader is licensed under a +# Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. +# +# You should have received a copy of the license along with this +# work. If not, see . +# +# Version 2.1.0 +# Maintained by ParadoxalManiak +# Original work by ZzMTV +# +# Author's disclaimer: +# I am not responsible for the usage of this program by other people. +# I do not recommend you doing this illegally or against Deezer's terms of service. +# This project is licensed under CC BY-NC-SA 4.0 + +import re +import os +from datetime import datetime +from Crypto.Cipher import AES, Blowfish +from hashlib import md5 +import binascii + +from beets.mediafile import MediaFile +from headphones import logger, request, helpers +import headphones +from twisted.conch.insults import helper + +# Public constants +PROVIDER_NAME = 'Deezer' + +# Internal constants +__API_URL = "http://www.deezer.com/ajax/gw-light.php" +__API_INFO_URL = "http://api.deezer.com/" +__HTTP_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36", + "Content-Language": "en-US", + "Cache-Control": "max-age=0", + "Accept": "*/*", + "Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3", + "Accept-Language": "de-DE,de;q=0.8,en-US;q=0.6,en;q=0.4" +} + +# Internal variables +__api_queries = { + 'api_version': "1.0", + 'api_token': "None", + 'input': "3" +} +__cookies = None +__tracks_cache = {} +__albums_cache = {} + +def __getApiToken(): + global __cookies + response = request.request_response(url="http://www.deezer.com/", headers=__HTTP_HEADERS) + __cookies = response.cookies + data = response.content + if data: + matches = re.search(r"checkForm\s*=\s*['|\"](.*)[\"|'];", data) + if matches: + token = matches.group(1) + __api_queries['api_token'] = token + logger.debug(u"Deezloader : api token loeaded ('%s')" % token) + + if not token: + logger.error(u"Deezloader: Unable to get api token") + +def getAlbumByLink(album_link): + """Returns deezer album infos using album link url + + :param str album_link: deezer album link url (eg: 'http://www.deezer.com/album/1234567/') + """ + matches = re.search(r"album\/([0-9]+)\/?$", album_link) + if matches: + return getAlbum(matches.group(1)) + +def getAlbum(album_id): + """Returns deezer album infos + + :param int album_id: deezer album id + """ + global __albums_cache + + if str(album_id) in __albums_cache: + return __albums_cache[str(album_id)] + + url = __API_INFO_URL + "album/" + str(album_id) + data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) + + if data and 'error' not in data: + __albums_cache[str(album_id)] = data + return data + else: + logger.debug("Deezloader: Can't load album infos") + return None + +def searchAlbums(search_term): + """Search for deezer albums using search term + + :param str search_term: search term to search album for + """ + logger.info(u'Searching Deezer using term: "%s"' % search_term) + + url = __API_INFO_URL + "search/album?q=" + search_term + data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) + + albums = [] + + # Process content + if data and 'total' in data and data['total'] > 0 and 'data' in data: + for item in data['data']: + try: + albums.append(getAlbum(item['id'])) + except Exception as e: + logger.error(u"An unknown error occurred in the Deezer parser: %s" % e) + else: + logger.info(u'No results found from Deezer using term: "%s"' % search_term) + + return albums + +def __matchAlbums(albums, artist_name, album_title, album_length): + resultlist = [] + + for album in albums: + total_size = 0 + tracks_found = 0 + + for track in album['tracks']['data']: + t = getTrack(track['id']) + if t: + if t["FILESIZE_MP3_320"] > 0: + size = t["FILESIZE_MP3_320"] + elif t["FILESIZE_MP3_256"] > 0: + size = t["FILESIZE_MP3_256"] + elif t["FILESIZE_MP3_128"] > 0: + size = t["FILESIZE_MP3_128"] + else: + size = t["FILESIZE_MP3_64"] + + size = int(size) + total_size += size + tracks_found += 1 + logger.debug(u'Found song "%s". Size: %s' % (t['SNG_TITLE'], helpers.bytes_to_mb(size))) + + matched = True + mismatch_reason = 'matched!' + + if album_length > 0 and abs(int(album['duration']) - album_length) > 240: + matched = False + mismatch_reason = (u'duration mismatch: %i not in [%i, %i]' % (int(album['duration']), (album_length - 240), (album_length + 240))) + + elif (helpers.latinToAscii(re.sub(r"\W", "", album_title, 0, re.UNICODE)).lower() != + helpers.latinToAscii(re.sub(r"\W", "", album['title'], 0, re.UNICODE)).lower()): + matched = False + mismatch_reason = (u'album name mismatch: %s != %s' % (album['title'], album_title)) + + elif (helpers.latinToAscii(re.sub(r"\W", "", artist_name, 0, re.UNICODE)).lower() != + helpers.latinToAscii(re.sub(r"\W", "", album['artist']['name'], 0, re.UNICODE)).lower()): + matched = False + mismatch_reason = (u'artist name mismatch: %s != %s' % (album['artist']['name'], artist_name)) + + resultlist.append( + (album['artist']['name'] + ' - ' + album['title'] + ' [' + album['release_date'][:4] + '] (' + str(tracks_found) + '/' + str(album['nb_tracks']) + ')', + total_size, album['link'], PROVIDER_NAME, "ddl", matched) + ) + logger.info(u'Found "%s". Tracks %i/%i. Size: %s (%s)' % (album['title'], tracks_found, album['nb_tracks'], helpers.bytes_to_mb(total_size), mismatch_reason)) + + return resultlist + +def searchAlbum(artist_name, album_title, user_search_term=None, album_length=None): + """Search for deezer specific album. + This will iterate over deezer albums and try to find best matches + + :param str artist_name: album artist name + :param str album_title: album title + :param str user_search_term: search terms provided by user + :param int album_length: targeted album duration in seconds + """ + # User search term by-pass normal search + if user_search_term: + return __matchAlbums(searchAlbums(user_search_term), artist_name, album_title, album_length) + + resultlist = __matchAlbums(searchAlbums((artist_name + ' ' + album_title).strip()), artist_name, album_title, album_length) + if resultlist: + return resultlist + + # Deezer API supports unicode, so just remove non alphanumeric characters + clean_artist_name = re.sub(r"[^\w\s]", " ", artist_name, 0, re.UNICODE).strip() + clean_album_name = re.sub(r"[^\w\s]", " ", album_title, 0, re.UNICODE).strip() + + resultlist = __matchAlbums(searchAlbums((clean_artist_name + ' ' + clean_album_name).strip()), artist_name, album_title, album_length) + if resultlist: + return resultlist + + resultlist = __matchAlbums(searchAlbums(clean_artist_name), artist_name, album_title, album_length) + if resultlist: + return resultlist + + return resultlist + +def getTrack(sng_id, try_reload_api=True): + """Returns deezer track infos + + :param int sng_id: deezer song id + :param bool try_reload_api: whether or not try reloading API if session expired + """ + global __tracks_cache + + if str(sng_id) in __tracks_cache: + return __tracks_cache[str(sng_id)] + + data = "[{\"method\":\"song.getListData\",\"params\":{\"sng_ids\":[" + str(sng_id) + "]}}]" + json = request.request_json(url=__API_URL, headers=__HTTP_HEADERS, method='post', params=__api_queries, data=data, cookies=__cookies) + + results = [] + error = None + invalid_token = False + + if json: + # Check for errors + if 'error' in json: + error = json['error'] + if 'GATEWAY_ERROR' in json['error'] and json['error']['GATEWAY_ERROR'] == u"invalid api token": + invalid_token = True + + elif 'error' in json[0] and json[0]['error']: + error = json[0]['error'] + if 'VALID_TOKEN_REQUIRED' in json[0]['error'] and json[0]['error']['VALID_TOKEN_REQUIRED'] == u"Invalid CSRF token": + invalid_token = True + + # Got invalid token error + if error: + if invalid_token and try_reload_api: + __getApiToken() + return getTrack(sng_id, False) + else: + logger.error(u"An unknown error occurred in the Deezer parser: %s" % error) + else: + try: + results = json[0]['results'] + item = results['data'][0] + if 'token' in item: + logger.error(u"An unknown error occurred in the Deezer parser: Uploaded Files are currently not supported") + return + + sng_id = item["SNG_ID"] + md5Origin = item["MD5_ORIGIN"] + sng_format = 3 + + if item["FILESIZE_MP3_320"] <= 0: + if item["FILESIZE_MP3_256"] > 0: + sng_format = 5 + else: + sng_format = 1 + + mediaVersion = int(item["MEDIA_VERSION"]) + item['downloadUrl'] = __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion) + + __tracks_cache[sng_id] = item + return item + + except Exception as e: + logger.error(u"An unknown error occurred in the Deezer parser: %s" % e) + +def __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion): + urlPart = md5Origin.encode('utf-8') + b'\xa4' + str(sng_format) + b'\xa4' + str(sng_id) + b'\xa4' + str(mediaVersion) + md5val = md5(urlPart).hexdigest() + urlPart = md5val + b'\xa4' + urlPart + b'\xa4' + cipher = AES.new('jo6aey6haid2Teih', AES.MODE_ECB) + ciphertext = cipher.encrypt(__pad(urlPart, AES.block_size)) + return "http://e-cdn-proxy-" + md5Origin[:1] + ".deezer.com/mobile/1/" + binascii.hexlify(ciphertext).lower() + +def __pad(raw, block_size): + if (len(raw) % block_size == 0): + return raw + padding_required = block_size - (len(raw) % block_size) + padChar = b'\x00' + data = raw + padding_required * padChar + return data + +def __tagTrack(path, track): + try: + album = getAlbum(track['ALB_ID']) + + f = MediaFile(path) + f.artist = track['ART_NAME'] + f.album = track['ALB_TITLE'] + f.title = track['SNG_TITLE'] + f.track = track['TRACK_NUMBER'] + f.tracktotal = album['nb_tracks'] + f.disc = track['DISK_NUMBER'] + f.bpm = track['BPM'] + f.date = datetime.strptime(album['release_date'], '%Y-%m-%d').date() + f.albumartist = album['artist']['name'] + if u'genres' in album and u'data' in album['genres']: + f.genres = [genre['name'] for genre in album['genres']['data']] + + f.save() + + except Exception as e: + logger.error(u'Unable to tag deezer track "%s": %s' % (path, e)) + +def decryptTracks(paths): + """Decrypt downloaded deezer tracks. + + :param paths: list of path to deezer tracks (*.dzr files). + """ + # Note: tracks can be from different albums + decrypted_tracks = {} + + # First pass: load tracks data + for path in paths: + try: + album_folder = os.path.dirname(path) + sng_id = os.path.splitext(os.path.basename(path))[0] + track = getTrack(sng_id) + track_number = int(track['TRACK_NUMBER']) + disk_number = int(track['DISK_NUMBER']) + + if album_folder not in decrypted_tracks: + decrypted_tracks[album_folder] = {} + + if disk_number not in decrypted_tracks[album_folder]: + decrypted_tracks[album_folder][disk_number] = {} + + decrypted_tracks[album_folder][disk_number][track_number] = track + + except Exception as e: + logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) + + # Second pass: decrypt tracks + for album_folder in decrypted_tracks: + multi_disks = len(decrypted_tracks[album_folder]) > 1 + for disk_number in decrypted_tracks[album_folder]: + for track_number, track in decrypted_tracks[album_folder][disk_number].items(): + try: + filename = helpers.replace_illegal_chars(track['SNG_TITLE']).strip() + filename = ('{:02d}'.format(track_number) + '_' + filename + '.mp3') + + # Add a 'cd x' sub-folder if album has more than one disk + disk_folder = os.path.join(album_folder, 'cd ' + str(disk_number)) if multi_disks else album_folder + + dest = os.path.join(disk_folder, filename).encode(headphones.SYS_ENCODING, 'replace') + + # Decrypt track if not already done + if not os.path.exists(dest): + try: + __decryptDownload(path, sng_id, dest) + __tagTrack(dest, track) + except Exception as e: + logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) + if os.path.exists(dest): + os.remove(dest) + decrypted_tracks[album_folder][disk_number].pop(track_number) + continue + + decrypted_tracks[album_folder][disk_number][track_number]['path'] = dest + + except Exception as e: + logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) + + return decrypted_tracks + +def __decryptDownload(source, sng_id, dest): + interval_chunk = 3 + chunk_size = 2048 + blowFishKey = __getBlowFishKey(sng_id) + i = 0 + iv = "\x00\x01\x02\x03\x04\x05\x06\x07" + + dest_folder = os.path.dirname(dest) + if not os.path.exists(dest_folder): + os.makedirs(dest_folder) + + f = open(source, "rb") + fout = open(dest, "wb") + try: + chunk = f.read(chunk_size) + while chunk: + if(i % interval_chunk == 0): + cipher = Blowfish.new(blowFishKey, Blowfish.MODE_CBC, iv) + chunk = cipher.decrypt(__pad(chunk, Blowfish.block_size)) + + fout.write(chunk) + i += 1 + chunk = f.read(chunk_size) + finally: + f.close() + fout.close() + +def __getBlowFishKey(encryptionKey): + if encryptionKey < 1: + encryptionKey *= -1 + + hashcode = md5(str(encryptionKey)).hexdigest() + hPart = hashcode[0:16] + lPart = hashcode[16:32] + parts = ['g4el58wc0zvf9na1', hPart, lPart] + + return __xorHex(parts) + +def __xorHex(parts): + data = "" + for i in range(0, 16): + character = ord(parts[0][i]) + + for j in range(1, len(parts)): + character ^= ord(parts[j][i]) + + data += chr(character) + + return data diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index e3deee23..336030af 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -29,7 +29,7 @@ from beetsplug import lyrics as beetslyrics from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync from headphones import logger, helpers, mb, music_encoder -from headphones import metadata +from headphones import metadata, deezloader postprocessor_lock = threading.Lock() @@ -47,6 +47,8 @@ def checkFolder(): single = False if album['Kind'] == 'nzb': download_dir = headphones.CONFIG.DOWNLOAD_DIR + elif album['Kind'] == 'ddl': + download_dir = headphones.CONFIG.DOWNLOAD_DDL_DIR else: if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3: download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY @@ -204,6 +206,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid]) downloaded_track_list = [] + downloaded_deezer_list = [] downloaded_cuecount = 0 for r, d, f in os.walk(albumpath): @@ -212,8 +215,10 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list.append(os.path.join(r, files)) elif files.lower().endswith('.cue'): downloaded_cuecount += 1 + elif files.lower().endswith('.dzr'): + downloaded_deezer_list.append(os.path.join(r, files)) # 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 files.lower().endswith(('.part', '.utpart', '.aria2')) and not forced: logger.info( "Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + " isn't complete yet. Will try again on the next run") @@ -252,6 +257,37 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list = helpers.get_downloaded_track_list(albumpath) keep_original_folder = False + # Decrypt deezer tracks + if downloaded_deezer_list: + logger.info('Decrypting deezer tracks') + decrypted_deezer_list = deezloader.decryptTracks(downloaded_deezer_list) + + # Check if album is complete based on album duration only + # (total track numbers is not determinant enough due to hidden tracks for eg) + db_track_duration = 0 + downloaded_track_duration = 0 + try: + for track in tracks: + db_track_duration += track['TrackDuration'] / 1000 + except: + downloaded_track_duration = False + + try: + for disk_number in decrypted_deezer_list[albumpath]: + for track in decrypted_deezer_list[albumpath][disk_number].values(): + downloaded_track_list.append(track['path']) + downloaded_track_duration += int(track['DURATION']) + except: + downloaded_track_duration = False + + if not downloaded_track_duration or not db_track_duration or abs(downloaded_track_duration - db_track_duration) > 240: + logger.info("Looks like " + + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + + " isn't complete yet (duration mismatch). Will try again on the next run") + return + + downloaded_track_list = list(set(downloaded_track_list)) # Remove duplicates + # test #1: metadata - usually works logger.debug('Verifying metadata...') diff --git a/headphones/searcher.py b/headphones/searcher.py index b0652f7f..2516bc31 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -36,6 +36,7 @@ import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent +from headphones import deezloader, aria2 from bencode import bencode, bdecode # Magnet to torrent services, for Black hole. Stolen from CouchPotato. @@ -51,6 +52,24 @@ ruobj = None # Persistent RED API object redobj = None +# Persistent Aria2 RPC object +__aria2rpc_obj = None + +def getAria2RPC(): + global __aria2rpc_obj + if not __aria2rpc_obj: + __aria2rpc_obj = aria2.Aria2JsonRpc( + ID='headphones', + uri=headphones.CONFIG.ARIA_HOST, + token=headphones.CONFIG.ARIA_TOKEN if headphones.CONFIG.ARIA_TOKEN else None, + http_user=headphones.CONFIG.ARIA_USERNAME if headphones.CONFIG.ARIA_USERNAME else None, + http_passwd=headphones.CONFIG.ARIA_PASSWORD if headphones.CONFIG.ARIA_PASSWORD else None + ) + return __aria2rpc_obj + +def reconfigure(): + global __aria2rpc_obj + __aria2rpc_obj = None def fix_url(s, charset="utf-8"): """ @@ -281,32 +300,53 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.STRIKE or headphones.CONFIG.TQUATTRECENTONZE) - results = [] + DDL_PROVIDERS = (headphones.CONFIG.DEEZLOADER) + myDB = db.DBConnection() albumlength = myDB.select('SELECT sum(TrackDuration) from tracks WHERE AlbumID=?', [album['AlbumID']])[0][0] + nzb_results = None + torrent_results = None + ddl_results = None + if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download: if NZB_PROVIDERS and NZB_DOWNLOADERS: - results = searchNZB(album, new, losslessOnly, albumlength) + nzb_results = searchNZB(album, new, losslessOnly, albumlength) - if not results and TORRENT_PROVIDERS: - results = searchTorrent(album, new, losslessOnly, albumlength) + if not nzb_results: + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength) + + if TORRENT_PROVIDERS: + torrent_results = searchTorrent(album, new, losslessOnly, albumlength) elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download: if TORRENT_PROVIDERS: - results = searchTorrent(album, new, losslessOnly, albumlength) + torrent_results = searchTorrent(album, new, losslessOnly, albumlength) - if not results and NZB_PROVIDERS and NZB_DOWNLOADERS: - results = searchNZB(album, new, losslessOnly, albumlength) + if not torrent_results: + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength) + + if NZB_PROVIDERS and NZB_DOWNLOADERS: + nzb_results = searchNZB(album, new, losslessOnly, albumlength) + + elif headphones.CONFIG.PREFER_TORRENTS == 3 and not choose_specific_download: + + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength) + + if not ddl_results: + if TORRENT_PROVIDERS: + torrent_results = searchTorrent(album, new, losslessOnly, albumlength) + + if NZB_PROVIDERS and NZB_DOWNLOADERS: + nzb_results = searchNZB(album, new, losslessOnly, albumlength) else: - - nzb_results = None - torrent_results = None - if NZB_PROVIDERS and NZB_DOWNLOADERS: nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download) @@ -314,13 +354,19 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): torrent_results = searchTorrent(album, new, losslessOnly, albumlength, choose_specific_download) - if not nzb_results: - nzb_results = [] + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength, choose_specific_download) - if not torrent_results: - torrent_results = [] + if not nzb_results: + nzb_results = [] - results = nzb_results + torrent_results + if not torrent_results: + torrent_results = [] + + if not ddl_results: + ddl_results = [] + + results = nzb_results + torrent_results + ddl_results if choose_specific_download: return results @@ -826,6 +872,31 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error('Couldn\'t write NZB file: %s', e) return + + elif kind == 'ddl': + folder_name = '%s - %s [%s]' % ( + helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), + helpers.latinToAscii(album['AlbumTitle']).encode('UTF-8').replace('/', '_'), + get_year_from_release_date(album['ReleaseDate'])) + + # Aria2 downloader + if headphones.CONFIG.DDL_DOWNLOADER == 0: + logger.info("Sending download to Aria2") + + try: + deezer_album = deezloader.getAlbumByLink(bestqual[2]) + + for album_track in deezer_album['tracks']['data']: + track = deezloader.getTrack(album_track['id']) + if track: + filename = track['SNG_ID'] + '.dzr' + logger.debug(u'Sending song "%s" to Aria' % track['SNG_TITLE']) + getAria2RPC().addUri([track['downloadUrl']], {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', 'dir': folder_name}) + + except Exception as e: + logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) + return + else: folder_name = '%s - %s [%s]' % ( helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), @@ -1203,6 +1274,61 @@ def verifyresult(title, artistterm, term, lossless): return True +def searchDdl(album, new=False, losslessOnly=False, albumlength=None, + choose_specific_download=False): + reldate = album['ReleaseDate'] + year = get_year_from_release_date(reldate) + + # MERGE THIS WITH THE TERM CLEANUP FROM searchNZB + dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ', '"': '', ',': ' ', + '*': ''} + + semi_cleanalbum = helpers.replace_all(album['AlbumTitle'], dic) + cleanalbum = helpers.latinToAscii(semi_cleanalbum) + semi_cleanartist = helpers.replace_all(album['ArtistName'], dic) + cleanartist = helpers.latinToAscii(semi_cleanartist) + + # Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot + # of these torrent providers are just using cleanartist/cleanalbum terms + if album['SearchTerm']: + term = album['SearchTerm'] + elif album['Type'] == 'part of': + term = cleanalbum + " " + year + else: + # FLAC usually doesn't have a year for some reason so I'll leave it out + # Various Artist albums might be listed as VA, so I'll leave that out too + # Only use the year if the term could return a bunch of different albums, i.e. self-titled albums + if album['ArtistName'] in album['AlbumTitle'] or len(album['ArtistName']) < 4 or len( + album['AlbumTitle']) < 4: + term = cleanartist + ' ' + cleanalbum + ' ' + year + elif album['ArtistName'] == 'Various Artists': + term = cleanalbum + ' ' + year + else: + term = cleanartist + ' ' + cleanalbum + + # Replace bad characters in the term and unicode it + term = re.sub('[\.\-\/]', ' ', term).encode('utf-8') + artistterm = re.sub('[\.\-\/]', ' ', cleanartist).encode('utf-8', 'replace') + + logger.debug(u'Using search term: "%s"' % helpers.latinToAscii(term)) + + resultlist = [] + + # Deezer only provides lossy + if headphones.CONFIG.DEEZLOADER and not losslessOnly: + resultlist += deezloader.searchAlbum(album['ArtistName'], album['AlbumTitle'], album['SearchTerm'], int(albumlength / 1000)) + + # attempt to verify that this isn't a substring result + # when looking for "Foo - Foo" we don't want "Foobar" + # this should be less of an issue when it isn't a self-titled album so we'll only check vs artist + results = [result for result in resultlist if verifyresult(result[0], artistterm, term, losslessOnly)] + + # Additional filtering for size etc + if results and not choose_specific_download: + results = more_filtering(results, album, albumlength, new) + + return results + def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): @@ -2036,7 +2162,10 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, def preprocess(resultlist): for result in resultlist: - if result[4] == 'torrent': + if result[3] == deezloader.PROVIDER_NAME: + return True, result + + elif result[4] == 'torrent': # rutracker always needs the torrent data if result[3] == 'rutracker.org': diff --git a/headphones/webserve.py b/headphones/webserve.py index 855fed24..19e2ffb3 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1174,6 +1174,10 @@ class WebInterface(object): "utorrent_username": headphones.CONFIG.UTORRENT_USERNAME, "utorrent_password": headphones.CONFIG.UTORRENT_PASSWORD, "utorrent_label": headphones.CONFIG.UTORRENT_LABEL, + "aria_host": headphones.CONFIG.ARIA_HOST, + "aria_password": headphones.CONFIG.ARIA_PASSWORD, + "aria_token": headphones.CONFIG.ARIA_TOKEN, + "aria_username": headphones.CONFIG.ARIA_USERNAME, "nzb_downloader_sabnzbd": radio(headphones.CONFIG.NZB_DOWNLOADER, 0), "nzb_downloader_nzbget": radio(headphones.CONFIG.NZB_DOWNLOADER, 1), "nzb_downloader_blackhole": radio(headphones.CONFIG.NZB_DOWNLOADER, 2), @@ -1182,6 +1186,7 @@ class WebInterface(object): "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), + "ddl_downloader_aria": radio(headphones.CONFIG.DDL_DOWNLOADER, 0), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, @@ -1243,6 +1248,8 @@ class WebInterface(object): "use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE), "tquattrecentonze_user": headphones.CONFIG.TQUATTRECENTONZE_USER, "tquattrecentonze_password": headphones.CONFIG.TQUATTRECENTONZE_PASSWORD, + "download_ddl_dir": headphones.CONFIG.DOWNLOAD_DDL_DIR, + "use_deezloader": checked(headphones.CONFIG.DEEZLOADER), "pref_qual_0": radio(headphones.CONFIG.PREFERRED_QUALITY, 0), "pref_qual_1": radio(headphones.CONFIG.PREFERRED_QUALITY, 1), "pref_qual_2": radio(headphones.CONFIG.PREFERRED_QUALITY, 2), @@ -1288,6 +1295,7 @@ class WebInterface(object): "prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0), "prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1), "prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2), + "prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3), "magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0), "magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1), "magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2), @@ -1446,7 +1454,7 @@ class WebInterface(object): "launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer", "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay", - "use_mininova", "use_waffles", "use_rutracker", + "use_mininova", "use_waffles", "use_rutracker", "use_deezloader", "use_apollo", "use_redacted", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", @@ -1579,6 +1587,9 @@ class WebInterface(object): # Reconfigure musicbrainz database connection with the new values mb.startmb() + + # Reconfigure Aria2 + searcher.reconfigure() raise cherrypy.HTTPRedirect("config") From 34b8a11145e7d8150f0008da2cd79bae601b165d Mon Sep 17 00:00:00 2001 From: Kallys Date: Wed, 19 Apr 2017 19:55:41 +0200 Subject: [PATCH 090/137] Fix travis errors --- headphones/aria2.py | 1798 ++++++++++++++++------------------- headphones/deezloader.py | 117 ++- headphones/helpers_test.py | 4 +- headphones/postprocessor.py | 10 +- headphones/searcher.py | 8 +- headphones/webserve.py | 2 +- 6 files changed, 920 insertions(+), 1019 deletions(-) diff --git a/headphones/aria2.py b/headphones/aria2.py index 68c4cb0a..06d8e4e0 100644 --- a/headphones/aria2.py +++ b/headphones/aria2.py @@ -27,7 +27,7 @@ import urllib2 from headphones import logger -################################## Constants ################################### +# ################################ Constants ################################### DEFAULT_PORT = 6800 SERVER_URI_FORMAT = '{}://{}:{:d}/jsonrpc' @@ -39,1057 +39,941 @@ FINAL_STATUS = ('complete', 'error') ARIA2_CONTROL_FILE_EXT = '.aria2' -############################ Convenience Functions ############################# +# ########################## Convenience Functions ############################# + def to_json_list(objs): - ''' - Wrap strings in lists. Other iterables are converted to lists directly. - ''' - if isinstance(objs, str): - return [objs] - elif not isinstance(objs, list): - return list(objs) - else: - return objs - + ''' + Wrap strings in lists. Other iterables are converted to lists directly. + ''' + if isinstance(objs, str): + return [objs] + elif not isinstance(objs, list): + return list(objs) + else: + return objs def add_options_and_position(params, options=None, position=None): - ''' - Convenience method for adding options and position to parameters. - ''' - if options: - params.append(options) - if position: - if not isinstance(position, int): - try: - position = int(position) - except ValueError: - position = -1 - if position >= 0: - params.append(position) - return params - + ''' + Convenience method for adding options and position to parameters. + ''' + if options: + params.append(options) + if position: + if not isinstance(position, int): + try: + position = int(position) + except ValueError: + position = -1 + if position >= 0: + params.append(position) + return params def get_status(response): - ''' - Process a status response. - ''' - if response: - try: - return response['status'] - except KeyError: - logger.error('no status returned from Aria2 RPC server') - return 'error' - else: - logger.error('no response from server') - return 'error' - + ''' + Process a status response. + ''' + if response: + try: + return response['status'] + except KeyError: + logger.error('no status returned from Aria2 RPC server') + return 'error' + else: + logger.error('no response from server') + return 'error' def random_token(length, valid_chars=None): - ''' - Get a random secret token for the Aria2 RPC server. + ''' + Get a random secret token for the Aria2 RPC server. - length: - The length of the token + length: + The length of the token - valid_chars: - A list or other ordered and indexable iterable of valid characters. If not - given of None, asciinumberic characters with some punctuation characters - will be used. - ''' - if not valid_chars: - valid_chars = string.ascii_letters + string.digits + '!@#$%^&*()-_=+' - number_of_chars = len(valid_chars) - bytes_to_read = math.ceil(math.log(number_of_chars) / math.log(0x100)) - max_value = 0x100**bytes_to_read - max_index = number_of_chars - 1 - token = '' - for _ in range(length): - value = int.from_bytes(os.urandom(bytes_to_read), byteorder='little') - index = round((value * max_index)/max_value) - token += valid_chars[index] - return token + valid_chars: + A list or other ordered and indexable iterable of valid characters. If not + given of None, asciinumberic characters with some punctuation characters + will be used. + ''' + if not valid_chars: + valid_chars = string.ascii_letters + string.digits + '!@#$%^&*()-_=+' + number_of_chars = len(valid_chars) + bytes_to_read = math.ceil(math.log(number_of_chars) / math.log(0x100)) + max_value = 0x100**bytes_to_read + max_index = number_of_chars - 1 + token = '' + for _ in range(length): + value = int.from_bytes(os.urandom(bytes_to_read), byteorder='little') + index = round((value * max_index) / max_value) + token += valid_chars[index] + return token +# ################ From python3-aur's ThreadedServers.common ################### -################## From python3-aur's ThreadedServers.common ################### def format_bytes(size): - '''Format bytes for inferior humans.''' - if size < 0x400: - return '{:d} B'.format(size) - else: - size = float(size) / 0x400 - for prefix in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB'): - if size < 0x400: - return '{:0.02f} {}'.format(size, prefix) - else: - size /= 0x400 - return '{:0.02f} YiB'.format(size) - + '''Format bytes for inferior humans.''' + if size < 0x400: + return '{:d} B'.format(size) + else: + size = float(size) / 0x400 + for prefix in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB'): + if size < 0x400: + return '{:0.02f} {}'.format(size, prefix) + else: + size /= 0x400 + return '{:0.02f} YiB'.format(size) def format_seconds(s): - '''Format seconds for inferior humans.''' - string = '' - for base, char in ( - (60, 's'), - (60, 'm'), - (24, 'h') - ): - s, r = divmod(s, base) - if s == 0: - return '{:d}{}{}'.format(r, char, string) - elif r != 0: - string = '{:02d}{}{}'.format(r, char, string) - else: - return '{:d}d{}'.format(s, string) + '''Format seconds for inferior humans.''' + string = '' + for base, char in ( + (60, 's'), + (60, 'm'), + (24, 'h') + ): + s, r = divmod(s, base) + if s == 0: + return '{:d}{}{}'.format(r, char, string) + elif r != 0: + string = '{:02d}{}{}'.format(r, char, string) + else: + return '{:d}d{}'.format(s, string) + +# ############################ Aria2JsonRpcError ############################### -############################## Aria2JsonRpcError ############################### class Aria2JsonRpcError(Exception): - def __init__(self, msg, connection_error=False): - super(self.__class__, self).__init__(self, msg) - self.connection_error = connection_error + def __init__(self, msg, connection_error=False): + super(self.__class__, self).__init__(self, msg) + self.connection_error = connection_error + +# ############################ Aria2JsonRpc Class ############################## -############################## Aria2JsonRpc Class ############################## class Aria2JsonRpc(object): - ''' - Interface class for interacting with an Aria2 RPC server. - ''' - # TODO: certificate options, etc. - def __init__( - self, ID, uri, - mode='normal', - token=None, - http_user=None, http_passwd=None, - server_cert=None, client_cert=None, client_cert_password=None, - ssl_protocol=None, - setup_function=None - ): - ''' - ID: the ID to send to the RPC interface + ''' + Interface class for interacting with an Aria2 RPC server. + ''' + # TODO: certificate options, etc. + def __init__( + self, ID, uri, + mode='normal', + token=None, + http_user=None, http_passwd=None, + server_cert=None, client_cert=None, client_cert_password=None, + ssl_protocol=None, + setup_function=None + ): + ''' + ID: the ID to send to the RPC interface - uri: the URI of the RPC interface + uri: the URI of the RPC interface - mode: - normal - process requests immediately - batch - queue requests (run with "process_queue") - format - return RPC request objects + mode: + normal - process requests immediately + batch - queue requests (run with "process_queue") + format - return RPC request objects - token: - RPC method-level authorization token (set using `--rpc-secret`) + token: + RPC method-level authorization token (set using `--rpc-secret`) - http_user, http_password: - HTTP Basic authentication credentials (deprecated) + http_user, http_password: + HTTP Basic authentication credentials (deprecated) - server_cert: - server certificate for HTTPS connections + server_cert: + server certificate for HTTPS connections - client_cert: - client certificate for HTTPS connections + client_cert: + client certificate for HTTPS connections - client_cert_password: - prompt for client certificate password + client_cert_password: + prompt for client certificate password - ssl_protocol: - SSL protocol from the ssl module + ssl_protocol: + SSL protocol from the ssl module - setup_function: - A function to invoke prior to the first server call. This could be the - launch() method of an Aria2RpcServer instance, for example. This attribute - is set automatically in instances returned from Aria2RpcServer.get_a2jr() - ''' - self.id = ID - self.uri = uri - self.mode = mode - self.queue = [] - self.handlers = dict() - self.token = token - self.setup_function = setup_function + setup_function: + A function to invoke prior to the first server call. This could be the + launch() method of an Aria2RpcServer instance, for example. This attribute + is set automatically in instances returned from Aria2RpcServer.get_a2jr() + ''' + self.id = ID + self.uri = uri + self.mode = mode + self.queue = [] + self.handlers = dict() + self.token = token + self.setup_function = setup_function - if None not in (http_user, http_passwd): - self.add_HTTPBasicAuthHandler(http_user, http_passwd) + if None not in (http_user, http_passwd): + self.add_HTTPBasicAuthHandler(http_user, http_passwd) - if server_cert or client_cert: - self.add_HTTPSHandler( - server_cert=server_cert, - client_cert=client_cert, - client_cert_password=client_cert_password, - protocol=ssl_protocol - ) + if server_cert or client_cert: + self.add_HTTPSHandler( + server_cert=server_cert, + client_cert=client_cert, + client_cert_password=client_cert_password, + protocol=ssl_protocol + ) - self.update_opener() + self.update_opener() + def iter_handlers(self): + ''' + Iterate over handlers. + ''' + for name in ('HTTPS', 'HTTPBasicAuth'): + try: + yield self.handlers[name] + except KeyError: + pass + def update_opener(self): + ''' + Build an opener from the current handlers. + ''' + self.opener = urllib2.build_opener(*self.iter_handlers()) - def iter_handlers(self): - ''' - Iterate over handlers. - ''' - for name in ('HTTPS', 'HTTPBasicAuth'): - try: - yield self.handlers[name] - except KeyError: - pass + def remove_handler(self, name): + ''' + Remove a handler. + ''' + try: + del self.handlers[name] + except KeyError: + pass + def add_HTTPBasicAuthHandler(self, user, passwd): + ''' + Add a handler for HTTP Basic authentication. + If either user or passwd are None, the handler is removed. + ''' + handler = urllib2.HTTPBasicAuthHandler() + handler.add_password( + realm='aria2', + uri=self.uri, + user=user, + passwd=passwd, + ) + self.handlers['HTTPBasicAuth'] = handler - def update_opener(self): - ''' - Build an opener from the current handlers. - ''' - self.opener = urllib2.build_opener(*self.iter_handlers()) + def remove_HTTPBasicAuthHandler(self): + self.remove_handler('HTTPBasicAuth') - - - def remove_handler(self, name): - ''' - Remove a handler. - ''' - try: - del self.handlers[name] - except KeyError: - pass - - - - def add_HTTPBasicAuthHandler(self, user, passwd): - ''' - Add a handler for HTTP Basic authentication. - - If either user or passwd are None, the handler is removed. - ''' - handler = urllib2.HTTPBasicAuthHandler() - handler.add_password( - realm='aria2', - uri=self.uri, - user=user, - passwd=passwd, - ) - self.handlers['HTTPBasicAuth'] = handler - - - - def remove_HTTPBasicAuthHandler(self): - self.remove_handler('HTTPBasicAuth') - - - - def add_HTTPSHandler( - self, - server_cert=None, - client_cert=None, - client_cert_password=None, - protocol=None, - ): - ''' - Add a handler for HTTPS connections with optional server and client - certificates. - ''' - if not protocol: - protocol = ssl.PROTOCOL_TLSv1 + def add_HTTPSHandler( + self, + server_cert=None, + client_cert=None, + client_cert_password=None, + protocol=None, + ): + ''' + Add a handler for HTTPS connections with optional server and client + certificates. + ''' + if not protocol: + protocol = ssl.PROTOCOL_TLSv1 # protocol = ssl.PROTOCOL_TLSv1_1 # openssl 1.0.1+ # protocol = ssl.PROTOCOL_TLSv1_2 # Python 3.4+ - context = ssl.SSLContext(protocol) - - if server_cert: - context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations(cafile=server_cert) - else: - context.verify_mode = ssl.CERT_OPTIONAL - - if client_cert: - context.load_cert_chain(client_cert, password=client_cert_password) - - self.handlers['HTTPS'] = urllib2.HTTPSHandler( - context=context, - check_hostname=False - ) - - - - def remove_HTTPSHandler(self): - self.remove_handler('HTTPS') - - - - def send_request(self, req_obj): - ''' - Send the request and return the response. - ''' - if self.setup_function: - self.setup_function() - self.setup_function = None - logger.debug("Aria req_obj: %s" % json.dumps(req_obj, indent=2, sort_keys=True)) - req = json.dumps(req_obj).encode('UTF-8') - try: - f = self.opener.open(self.uri, req) - obj = json.loads(f.read()) - try: - return obj['result'] - except KeyError: - raise Aria2JsonRpcError('unexpected result: {}'.format(obj)) - except (urllib2.URLError) as e: - # This should work but URLError does not set the errno attribute: - # e.errno == errno.ECONNREFUSED - raise Aria2JsonRpcError( - str(e), - connection_error=( - '111' in str(e) - ) - ) - except httplib.BadStatusLine as e: - raise Aria2JsonRpcError('{}: BadStatusLine: {} (HTTPS error?)'.format( - self.__class__.__name__, e - )) + context = ssl.SSLContext(protocol) + + if server_cert: + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(cafile=server_cert) + else: + context.verify_mode = ssl.CERT_OPTIONAL + + if client_cert: + context.load_cert_chain(client_cert, password=client_cert_password) + + self.handlers['HTTPS'] = urllib2.HTTPSHandler( + context=context, + check_hostname=False + ) + + def remove_HTTPSHandler(self): + self.remove_handler('HTTPS') + + def send_request(self, req_obj): + ''' + Send the request and return the response. + ''' + if self.setup_function: + self.setup_function() + self.setup_function = None + logger.debug("Aria req_obj: %s" % json.dumps(req_obj, indent=2, sort_keys=True)) + req = json.dumps(req_obj).encode('UTF-8') + try: + f = self.opener.open(self.uri, req) + obj = json.loads(f.read()) + try: + return obj['result'] + except KeyError: + raise Aria2JsonRpcError('unexpected result: {}'.format(obj)) + except (urllib2.URLError) as e: + # This should work but URLError does not set the errno attribute: + # e.errno == errno.ECONNREFUSED + raise Aria2JsonRpcError( + str(e), + connection_error=( + '111' in str(e) + ) + ) + except httplib.BadStatusLine as e: + raise Aria2JsonRpcError('{}: BadStatusLine: {} (HTTPS error?)'.format( + self.__class__.__name__, e + )) + + def jsonrpc(self, method, params=None, prefix='aria2.'): + ''' + POST a request to the RPC interface. + ''' + if not params: + params = [] + + if self.token is not None: + token_str = 'token:{}'.format(self.token) + if method == 'multicall': + for p in params[0]: + try: + p['params'].insert(0, token_str) + except KeyError: + p['params'] = [token_str] + else: + params.insert(0, token_str) + + req_obj = { + 'jsonrpc': '2.0', + 'id': self.id, + 'method': prefix + method, + 'params': params, + } + if self.mode == 'batch': + self.queue.append(req_obj) + return None + elif self.mode == 'format': + return req_obj + else: + return self.send_request(req_obj) + + def process_queue(self): + ''' + Processed queued requests. + ''' + req_obj = self.queue + self.queue = [] + return self.send_request(req_obj) + + +# ############################# Standard Methods ############################### + def addUri(self, uris, options=None, position=None): + ''' + aria2.addUri method + + uris: list of URIs + + options: dictionary of additional options + + position: position in queue + + Returns a GID + ''' + params = [uris] + params = add_options_and_position(params, options, position) + return self.jsonrpc('addUri', params) + + def addTorrent(self, torrent, uris=None, options=None, position=None): + ''' + aria2.addTorrent method + + torrent: base64-encoded torrent file + + uris: list of webseed URIs + + options: dictionary of additional options + + position: position in queue + + Returns a GID. + ''' + params = [torrent] + if uris: + params.append(uris) + params = add_options_and_position(params, options, position) + return self.jsonrpc('addTorrent', params) + + def addMetalink(self, metalink, options=None, position=None): + ''' + aria2.addMetalink method + + metalink: base64-encoded metalink file + + options: dictionary of additional options + + position: position in queue + + Returns an array of GIDs. + ''' + params = [metalink] + params = add_options_and_position(params, options, position) + return self.jsonrpc('addTorrent', params) + + def remove(self, gid): + ''' + aria2.remove method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('remove', params) + + def forceRemove(self, gid): + ''' + aria2.forceRemove method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('forceRemove', params) + + def pause(self, gid): + ''' + aria2.pause method + + gid: GID to pause + ''' + params = [gid] + return self.jsonrpc('pause', params) + + def pauseAll(self): + ''' + aria2.pauseAll method + ''' + return self.jsonrpc('pauseAll') + def forcePause(self, gid): + ''' + aria2.forcePause method + gid: GID to pause + ''' + params = [gid] + return self.jsonrpc('forcePause', params) - def jsonrpc(self, method, params=None, prefix='aria2.'): - ''' - POST a request to the RPC interface. - ''' - if not params: - params = [] + def forcePauseAll(self): + ''' + aria2.forcePauseAll method + ''' + return self.jsonrpc('forcePauseAll') - if self.token is not None: - token_str = 'token:{}'.format(self.token) - if method == 'multicall': - for p in params[0]: - try: - p['params'].insert(0, token_str) - except KeyError: - p['params'] = [token_str] - else: - params.insert(0, token_str) + def unpause(self, gid): + ''' + aria2.unpause method - req_obj = { - 'jsonrpc' : '2.0', - 'id' : self.id, - 'method' : prefix + method, - 'params' : params, - } - if self.mode == 'batch': - self.queue.append(req_obj) - return None - elif self.mode == 'format': - return req_obj - else: - return self.send_request(req_obj) + gid: GID to unpause + ''' + params = [gid] + return self.jsonrpc('unpause', params) + def unpauseAll(self): + ''' + aria2.unpauseAll method + ''' + return self.jsonrpc('unpauseAll') + def tellStatus(self, gid, keys=None): + ''' + aria2.tellStatus method - def process_queue(self): - ''' - Processed queued requests. - ''' - req_obj = self.queue - self.queue = [] - return self.send_request(req_obj) + gid: GID to query + keys: subset of status keys to return (all keys are returned otherwise) + Returns a dictionary. + ''' + params = [gid] + if keys: + params.append(keys) + return self.jsonrpc('tellStatus', params) + + def getUris(self, gid): + ''' + aria2.getUris method -############################### Standard Methods ############################### + gid: GID to query - def addUri(self, uris, options=None, position=None): - ''' - aria2.addUri method + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getUris', params) - uris: list of URIs + def getFiles(self, gid): + ''' + aria2.getFiles method - options: dictionary of additional options + gid: GID to query - position: position in queue + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getFiles', params) - Returns a GID - ''' - params = [uris] - params = add_options_and_position(params, options, position) - return self.jsonrpc('addUri', params) + def getPeers(self, gid): + ''' + aria2.getPeers method + gid: GID to query + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getPeers', params) - def addTorrent(self, torrent, uris=None, options=None, position=None): - ''' - aria2.addTorrent method + def getServers(self, gid): + ''' + aria2.getServers method - torrent: base64-encoded torrent file + gid: GID to query - uris: list of webseed URIs + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getServers', params) - options: dictionary of additional options + def tellActive(self, keys=None): + ''' + aria2.tellActive method - position: position in queue + keys: same as tellStatus - Returns a GID. - ''' - params = [torrent] - if uris: - params.append(uris) - params = add_options_and_position(params, options, position) - return self.jsonrpc('addTorrent', params) + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + if keys: + params = [keys] + else: + params = None + return self.jsonrpc('tellActive', params) + def tellWaiting(self, offset, num, keys=None): + ''' + aria2.tellWaiting method + offset: offset from start of waiting download queue + (negative values are counted from the end of the queue) - def addMetalink(self, metalink, options=None, position=None): - ''' - aria2.addMetalink method + num: number of downloads to return - metalink: base64-encoded metalink file + keys: same as tellStatus - options: dictionary of additional options + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + params = [offset, num] + if keys: + params.append(keys) + return self.jsonrpc('tellWaiting', params) - position: position in queue + def tellStopped(self, offset, num, keys=None): + ''' + aria2.tellStopped method - Returns an array of GIDs. - ''' - params = [metalink] - params = add_options_and_position(params, options, position) - return self.jsonrpc('addTorrent', params) + offset: offset from oldest download (same semantics as tellWaiting) + num: same as tellWaiting + keys: same as tellStatus - def remove(self, gid): - ''' - aria2.remove method + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + params = [offset, num] + if keys: + params.append(keys) + return self.jsonrpc('tellStopped', params) - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('remove', params) + def changePosition(self, gid, pos, how): + ''' + aria2.changePosition method + gid: GID to change + pos: the position - def forceRemove(self, gid): - ''' - aria2.forceRemove method + how: "POS_SET", "POS_CUR" or "POS_END" + ''' + params = [gid, pos, how] + return self.jsonrpc('changePosition', params) - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('forceRemove', params) + def changeUri(self, gid, fileIndex, delUris, addUris, position=None): + ''' + aria2.changePosition method + gid: GID to change + fileIndex: file to affect (1-based) - def pause(self, gid): - ''' - aria2.pause method + delUris: URIs to remove - gid: GID to pause - ''' - params = [gid] - return self.jsonrpc('pause', params) + addUris: URIs to add + position: where URIs are inserted, after URIs have been removed + ''' + params = [gid, fileIndex, delUris, addUris] + if position: + params.append(position) + return self.jsonrpc('changePosition', params) + def getOption(self, gid): + ''' + aria2.getOption method - def pauseAll(self): - ''' - aria2.pauseAll method - ''' - return self.jsonrpc('pauseAll') + gid: GID to query + Returns a dictionary of options. + ''' + params = [gid] + return self.jsonrpc('getOption', params) + def changeOption(self, gid, options): + ''' + aria2.changeOption method - def forcePause(self, gid): - ''' - aria2.forcePause method - - gid: GID to pause - ''' - params = [gid] - return self.jsonrpc('forcePause', params) - - - - def forcePauseAll(self): - ''' - aria2.forcePauseAll method - ''' - return self.jsonrpc('forcePauseAll') - - - - def unpause(self, gid): - ''' - aria2.unpause method - - gid: GID to unpause - ''' - params = [gid] - return self.jsonrpc('unpause', params) - - - - def unpauseAll(self): - ''' - aria2.unpauseAll method - ''' - return self.jsonrpc('unpauseAll') - - - - def tellStatus(self, gid, keys=None): - ''' - aria2.tellStatus method - - gid: GID to query - - keys: subset of status keys to return (all keys are returned otherwise) - - Returns a dictionary. - ''' - params = [gid] - if keys: - params.append(keys) - return self.jsonrpc('tellStatus', params) - - - - def getUris(self, gid): - ''' - aria2.getUris method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getUris', params) - - - - def getFiles(self, gid): - ''' - aria2.getFiles method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getFiles', params) - - - - def getPeers(self, gid): - ''' - aria2.getPeers method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getPeers', params) - - - - def getServers(self, gid): - ''' - aria2.getServers method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getServers', params) - - - - def tellActive(self, keys=None): - ''' - aria2.tellActive method - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - if keys: - params = [keys] - else: - params = None - return self.jsonrpc('tellActive', params) - - - - def tellWaiting(self, offset, num, keys=None): - ''' - aria2.tellWaiting method - - offset: offset from start of waiting download queue - (negative values are counted from the end of the queue) - - num: number of downloads to return - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - params = [offset, num] - if keys: - params.append(keys) - return self.jsonrpc('tellWaiting', params) - - - - def tellStopped(self, offset, num, keys=None): - ''' - aria2.tellStopped method - - offset: offset from oldest download (same semantics as tellWaiting) - - num: same as tellWaiting - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - params = [offset, num] - if keys: - params.append(keys) - return self.jsonrpc('tellStopped', params) - - - - def changePosition(self, gid, pos, how): - ''' - aria2.changePosition method - - gid: GID to change - - pos: the position - - how: "POS_SET", "POS_CUR" or "POS_END" - ''' - params = [gid, pos, how] - return self.jsonrpc('changePosition', params) - - - - def changeUri(self, gid, fileIndex, delUris, addUris, position=None): - ''' - aria2.changePosition method - - gid: GID to change - - fileIndex: file to affect (1-based) - - delUris: URIs to remove - - addUris: URIs to add - - position: where URIs are inserted, after URIs have been removed - ''' - params = [gid, fileIndex, delUris, addUris] - if position: - params.append(position) - return self.jsonrpc('changePosition', params) - - - - def getOption(self, gid): - ''' - aria2.getOption method - - gid: GID to query - - Returns a dictionary of options. - ''' - params = [gid] - return self.jsonrpc('getOption', params) - - - - def changeOption(self, gid, options): - ''' - aria2.changeOption method - - gid: GID to change - - options: dictionary of new options - (not all options can be changed for active downloads) - ''' - params = [gid, options] - return self.jsonrpc('changeOption', params) - - - - def getGlobalOption(self): - ''' - aria2.getGlobalOption method - - Returns a dictionary. - ''' - return self.jsonrpc('getGlobalOption') - - - - def changeGlobalOption(self, options): - ''' - aria2.changeGlobalOption method - - options: dictionary of new options - ''' - params = [options] - return self.jsonrpc('changeGlobalOption', params) - - - - def getGlobalStat(self): - ''' - aria2.getGlobalStat method - - Returns a dictionary. - ''' - return self.jsonrpc('getGlobalStat') - - - - def purgeDownloadResult(self): - ''' - aria2.purgeDownloadResult method - ''' - self.jsonrpc('purgeDownloadResult') - - - - def removeDownloadResult(self, gid): - ''' - aria2.removeDownloadResult method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('removeDownloadResult', params) - - - - def getVersion(self): - ''' - aria2.getVersion method - - Returns a dictionary. - ''' - return self.jsonrpc('getVersion') - - - - def getSessionInfo(self): - ''' - aria2.getSessionInfo method - - Returns a dictionary. - ''' - return self.jsonrpc('getSessionInfo') - - - - def shutdown(self): - ''' - aria2.shutdown method - ''' - return self.jsonrpc('shutdown') - - - - def forceShutdown(self): - ''' - aria2.forceShutdown method - ''' - return self.jsonrpc('forceShutdown') - - - - def multicall(self, methods): - ''' - aria2.multicall method - - methods: list of dictionaries (keys: methodName, params) - - The method names must be those used by Aria2c, e.g. "aria2.tellStatus". - ''' - return self.jsonrpc('multicall', [methods], prefix='system.') - - - - -############################# Convenience Methods ############################## - - def add_torrent(self, path, uris=None, options=None, position=None): - ''' - A wrapper around addTorrent for loading files. - ''' - with open(path, 'r') as f: - torrent = base64.encode(f.read()) - return self.addTorrent(torrent, uris, options, position) - - - - def add_metalink(self, path, uris=None, options=None, position=None): - ''' - A wrapper around addMetalink for loading files. - ''' - with open(path, 'r') as f: - metalink = base64.encode(f.read()) - return self.addMetalink(metalink, uris, options, position) - - - - def get_status(self, gid): - ''' - Get the status of a single GID. - ''' - response = self.tellStatus(gid, ['status']) - return get_status(response) - - - - def wait_for_final_status(self, gid, interval=1): - ''' - Wait for a GID to complete or fail and return its status. - ''' - if not interval or interval < 0: - interval = 1 - while True: - status = self.get_status(gid) - if status in TEMPORARY_STATUS: - time.sleep(interval) - else: - return status - - - - def get_statuses(self, gids): - ''' - Get the status of multiple GIDs. The status of each is yielded in order. - ''' - methods = [ - { - 'methodName' : 'aria2.tellStatus', - 'params' : [gid, ['gid', 'status']] - } - for gid in gids - ] - results = self.multicall(methods) - if results: - status = dict((r[0]['gid'], r[0]['status']) for r in results) - for gid in gids: - try: - yield status[gid] - except KeyError: - logger.error('Aria2 RPC server returned no status for GID {}'.format(gid)) - yield 'error' - else: - logger.error('no response from Aria2 RPC server') - for gid in gids: - yield 'error' - - - - def wait_for_final_statuses(self, gids, interval=1): - ''' - Wait for multiple GIDs to complete or fail and return their statuses in - order. - - gids: - A flat list of GIDs. - ''' - if not interval or interval < 0: - interval = 1 - statusmap = dict((g, None) for g in gids) - remaining = list( - g for g,s in statusmap.items() if s is None - ) - while remaining: - for g, s in zip(remaining, self.get_statuses(remaining)): - if s in TEMPORARY_STATUS: - continue - else: - statusmap[g] = s - remaining = list( - g for g,s in statusmap.items() if s is None - ) - if remaining: - time.sleep(interval) - for g in gids: - yield statusmap[g] - - - - def print_global_status(self): - ''' - Print global status of the RPC server. - ''' - status = self.getGlobalStat() - if status: - numWaiting = int(status['numWaiting']) - numStopped = int(status['numStopped']) - keys = ['totalLength', 'completedLength'] - total = self.tellActive(keys) - waiting = self.tellWaiting(0, numWaiting, keys) - if waiting: - total += waiting - stopped = self.tellStopped(0, numStopped, keys) - if stopped: - total += stopped - - downloadSpeed = int(status['downloadSpeed']) - uploadSpeed = int(status['uploadSpeed']) - totalLength = sum(int(x['totalLength']) for x in total) - completedLength = sum(int(x['completedLength']) for x in total) - remaining = totalLength - completedLength - - status['downloadSpeed'] = format_bytes(downloadSpeed) + '/s' - status['uploadSpeed'] = format_bytes(uploadSpeed) + '/s' - - preordered = ('downloadSpeed', 'uploadSpeed') - - rows = list() - for k in sorted(status): - if k in preordered: - continue - rows.append((k, status[k])) - - rows.extend((x, status[x]) for x in preordered) - - if totalLength > 0: - rows.append(('total', format(format_bytes(totalLength)))) - rows.append(('completed', format(format_bytes(completedLength)))) - rows.append(('remaining', format(format_bytes(remaining)))) - if completedLength == totalLength: - eta = 'finished' - else: - try: - eta = format_seconds(remaining // downloadSpeed) - except ZeroDivisionError: - eta = 'never' - rows.append(('ETA', eta)) - - l = max(len(r[0]) for r in rows) - r = max(len(r[1]) for r in rows) - r = max(r, len(self.uri) - (l + 2)) - fmt = '{:<' + str(l) + 's} {:>' + str(r) + 's}' - - print(self.uri) - for k, v in rows: - print(fmt.format(k, v)) - - - - def queue_uris(self, uris, options, interval=None): - ''' - Enqueue URIs and wait for download to finish while printing status at - regular intervals. - ''' - gid = self.addUri(uris, options) - print('GID: {}'.format(gid)) - - if gid and interval is not None: - blanker = '' - while True: - response = self.tellStatus(gid, ['status']) - if response: - try: - status = response['status'] - except KeyError: - print('error: no status returned from Aria2 RPC server') - break - print('{}\rstatus: {}'.format(blanker, status)), - blanker = ' ' * len(status) - if status in TEMPORARY_STATUS: - time.sleep(interval) - else: - break - else: - print('error: no response from server') - break - - - -######################### Polymethod download handlers ######################### - - def polymethod_enqueue_many(self, downloads): - ''' - Enqueue downloads. - - downloads: Same as polymethod_download(). - ''' - methods = list( - { - 'methodName': 'aria2.{}'.format(d[0]), - 'params': list(d[1:]) - } for d in downloads - ) - return self.multicall(methods) - - - - def polymethod_wait_many(self, gids, interval=1): - ''' - Wait for the GIDs to complete or fail and return their statuses. - - gids: - A list of lists of GIDs. - ''' - # The flattened list of GIDs - gs = list(g for gs in gids for g in gs) - statusmap = dict(tuple(zip( - gs, - self.wait_for_final_statuses(gs, interval=interval) - ))) - for gs in gids: - yield list(statusmap.get(g, 'error') for g in gs) - - - - - def polymethod_enqueue_one(self, download): - ''' - Same as polymethod_enqueue_many but for one element. - ''' - return getattr(self, download[0])(*download[1:]) - - - - def polymethod_download(self, downloads, interval=1): - ''' - Enqueue a series of downloads and wait for them to finish. Iterate over the - status of each, in order. - - downloads: - An iterable over (, , ...) where indicates the "add" - method to use ('addUri', 'addTorrent', 'addMetalink') and everything that - follows are arguments to pass to that method. - - interval: - The status check interval while waiting. - - Iterates over the download status of finished downloads. "complete" - indicates success. Lists of statuses will be returned for downloads that - create multiple GIDs (e.g. metalinks). - ''' - gids = self.polymethod_enqueue_many(downloads) - return self.polymethod_wait_many(gids, interval=interval) - - - - def polymethod_download_bool(self, *args, **kwargs): - ''' - A wrapper around polymethod_download() which returns a boolean for each - download to indicate success (True) or failure (False). - ''' + gid: GID to change + + options: dictionary of new options + (not all options can be changed for active downloads) + ''' + params = [gid, options] + return self.jsonrpc('changeOption', params) + + def getGlobalOption(self): + ''' + aria2.getGlobalOption method + + Returns a dictionary. + ''' + return self.jsonrpc('getGlobalOption') + + def changeGlobalOption(self, options): + ''' + aria2.changeGlobalOption method + + options: dictionary of new options + ''' + params = [options] + return self.jsonrpc('changeGlobalOption', params) + + def getGlobalStat(self): + ''' + aria2.getGlobalStat method + + Returns a dictionary. + ''' + return self.jsonrpc('getGlobalStat') + + def purgeDownloadResult(self): + ''' + aria2.purgeDownloadResult method + ''' + self.jsonrpc('purgeDownloadResult') + + def removeDownloadResult(self, gid): + ''' + aria2.removeDownloadResult method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('removeDownloadResult', params) + + def getVersion(self): + ''' + aria2.getVersion method + + Returns a dictionary. + ''' + return self.jsonrpc('getVersion') + + def getSessionInfo(self): + ''' + aria2.getSessionInfo method + + Returns a dictionary. + ''' + return self.jsonrpc('getSessionInfo') + + def shutdown(self): + ''' + aria2.shutdown method + ''' + return self.jsonrpc('shutdown') + + def forceShutdown(self): + ''' + aria2.forceShutdown method + ''' + return self.jsonrpc('forceShutdown') + + def multicall(self, methods): + ''' + aria2.multicall method + + methods: list of dictionaries (keys: methodName, params) + + The method names must be those used by Aria2c, e.g. "aria2.tellStatus". + ''' + return self.jsonrpc('multicall', [methods], prefix='system.') + + +# ########################### Convenience Methods ############################## + def add_torrent(self, path, uris=None, options=None, position=None): + ''' + A wrapper around addTorrent for loading files. + ''' + with open(path, 'r') as f: + torrent = base64.encode(f.read()) + return self.addTorrent(torrent, uris, options, position) + + def add_metalink(self, path, uris=None, options=None, position=None): + ''' + A wrapper around addMetalink for loading files. + ''' + with open(path, 'r') as f: + metalink = base64.encode(f.read()) + return self.addMetalink(metalink, uris, options, position) + + def get_status(self, gid): + ''' + Get the status of a single GID. + ''' + response = self.tellStatus(gid, ['status']) + return get_status(response) + + def wait_for_final_status(self, gid, interval=1): + ''' + Wait for a GID to complete or fail and return its status. + ''' + if not interval or interval < 0: + interval = 1 + while True: + status = self.get_status(gid) + if status in TEMPORARY_STATUS: + time.sleep(interval) + else: + return status + + def get_statuses(self, gids): + ''' + Get the status of multiple GIDs. The status of each is yielded in order. + ''' + methods = [ + { + 'methodName': 'aria2.tellStatus', + 'params': [gid, ['gid', 'status']] + } + for gid in gids + ] + results = self.multicall(methods) + if results: + status = dict((r[0]['gid'], r[0]['status']) for r in results) + for gid in gids: + try: + yield status[gid] + except KeyError: + logger.error('Aria2 RPC server returned no status for GID {}'.format(gid)) + yield 'error' + else: + logger.error('no response from Aria2 RPC server') + for gid in gids: + yield 'error' + + def wait_for_final_statuses(self, gids, interval=1): + ''' + Wait for multiple GIDs to complete or fail and return their statuses in + order. + + gids: + A flat list of GIDs. + ''' + if not interval or interval < 0: + interval = 1 + statusmap = dict((g, None) for g in gids) + remaining = list( + g for g, s in statusmap.items() if s is None + ) + while remaining: + for g, s in zip(remaining, self.get_statuses(remaining)): + if s in TEMPORARY_STATUS: + continue + else: + statusmap[g] = s + remaining = list( + g for g, s in statusmap.items() if s is None + ) + if remaining: + time.sleep(interval) + for g in gids: + yield statusmap[g] + + def print_global_status(self): + ''' + Print global status of the RPC server. + ''' + status = self.getGlobalStat() + if status: + numWaiting = int(status['numWaiting']) + numStopped = int(status['numStopped']) + keys = ['totalLength', 'completedLength'] + total = self.tellActive(keys) + waiting = self.tellWaiting(0, numWaiting, keys) + if waiting: + total += waiting + stopped = self.tellStopped(0, numStopped, keys) + if stopped: + total += stopped + + downloadSpeed = int(status['downloadSpeed']) + uploadSpeed = int(status['uploadSpeed']) + totalLength = sum(int(x['totalLength']) for x in total) + completedLength = sum(int(x['completedLength']) for x in total) + remaining = totalLength - completedLength + + status['downloadSpeed'] = format_bytes(downloadSpeed) + '/s' + status['uploadSpeed'] = format_bytes(uploadSpeed) + '/s' + + preordered = ('downloadSpeed', 'uploadSpeed') + + rows = list() + for k in sorted(status): + if k in preordered: + continue + rows.append((k, status[k])) + + rows.extend((x, status[x]) for x in preordered) + + if totalLength > 0: + rows.append(('total', format(format_bytes(totalLength)))) + rows.append(('completed', format(format_bytes(completedLength)))) + rows.append(('remaining', format(format_bytes(remaining)))) + if completedLength == totalLength: + eta = 'finished' + else: + try: + eta = format_seconds(remaining // downloadSpeed) + except ZeroDivisionError: + eta = 'never' + rows.append(('ETA', eta)) + + l = max(len(r[0]) for r in rows) + r = max(len(r[1]) for r in rows) + r = max(r, len(self.uri) - (l + 2)) + fmt = '{:<' + str(l) + 's} {:>' + str(r) + 's}' + + print(self.uri) + for k, v in rows: + print(fmt.format(k, v)) + + def queue_uris(self, uris, options, interval=None): + ''' + Enqueue URIs and wait for download to finish while printing status at + regular intervals. + ''' + gid = self.addUri(uris, options) + print('GID: {}'.format(gid)) + + if gid and interval is not None: + blanker = '' + while True: + response = self.tellStatus(gid, ['status']) + if response: + try: + status = response['status'] + except KeyError: + print('error: no status returned from Aria2 RPC server') + break + print('{}\rstatus: {}'.format(blanker, status)), + blanker = ' ' * len(status) + if status in TEMPORARY_STATUS: + time.sleep(interval) + else: + break + else: + print('error: no response from server') + break + + +# ####################### Polymethod download handlers ######################### + def polymethod_enqueue_many(self, downloads): + ''' + Enqueue downloads. + + downloads: Same as polymethod_download(). + ''' + methods = list( + { + 'methodName': 'aria2.{}'.format(d[0]), + 'params': list(d[1:]) + } for d in downloads + ) + return self.multicall(methods) + + def polymethod_wait_many(self, gids, interval=1): + ''' + Wait for the GIDs to complete or fail and return their statuses. + + gids: + A list of lists of GIDs. + ''' + # The flattened list of GIDs + gs = list(g for gs in gids for g in gs) + statusmap = dict(tuple(zip( + gs, + self.wait_for_final_statuses(gs, interval=interval) + ))) + for gs in gids: + yield list(statusmap.get(g, 'error') for g in gs) + + def polymethod_enqueue_one(self, download): + ''' + Same as polymethod_enqueue_many but for one element. + ''' + return getattr(self, download[0])(*download[1:]) + + def polymethod_download(self, downloads, interval=1): + ''' + Enqueue a series of downloads and wait for them to finish. Iterate over the + status of each, in order. + + downloads: + An iterable over (, , ...) where indicates the "add" + method to use ('addUri', 'addTorrent', 'addMetalink') and everything that + follows are arguments to pass to that method. + + interval: + The status check interval while waiting. + + Iterates over the download status of finished downloads. "complete" + indicates success. Lists of statuses will be returned for downloads that + create multiple GIDs (e.g. metalinks). + ''' + gids = self.polymethod_enqueue_many(downloads) + return self.polymethod_wait_many(gids, interval=interval) + + def polymethod_download_bool(self, *args, **kwargs): + ''' + A wrapper around polymethod_download() which returns a boolean for each + download to indicate success (True) or failure (False). + ''' # for status in self.polymethod_download(*args, **kwargs): # yield all(s == 'complete' for s in status) - return list( - all(s == 'complete' for s in status) - for status in self.polymethod_download(*args, **kwargs) - ) - \ No newline at end of file + return list( + all(s == 'complete' for s in status) + for status in self.polymethod_download(*args, **kwargs) + ) diff --git a/headphones/deezloader.py b/headphones/deezloader.py index db14ae5d..528cded6 100644 --- a/headphones/deezloader.py +++ b/headphones/deezloader.py @@ -26,7 +26,6 @@ import binascii from beets.mediafile import MediaFile from headphones import logger, request, helpers import headphones -from twisted.conch.insults import helper # Public constants PROVIDER_NAME = 'Deezer' @@ -53,6 +52,7 @@ __cookies = None __tracks_cache = {} __albums_cache = {} + def __getApiToken(): global __cookies response = request.request_response(url="http://www.deezer.com/", headers=__HTTP_HEADERS) @@ -67,39 +67,42 @@ def __getApiToken(): if not token: logger.error(u"Deezloader: Unable to get api token") - + + def getAlbumByLink(album_link): """Returns deezer album infos using album link url - + :param str album_link: deezer album link url (eg: 'http://www.deezer.com/album/1234567/') """ matches = re.search(r"album\/([0-9]+)\/?$", album_link) if matches: return getAlbum(matches.group(1)) - + + def getAlbum(album_id): """Returns deezer album infos - + :param int album_id: deezer album id """ global __albums_cache - + if str(album_id) in __albums_cache: return __albums_cache[str(album_id)] - + url = __API_INFO_URL + "album/" + str(album_id) data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) - + if data and 'error' not in data: __albums_cache[str(album_id)] = data return data else: logger.debug("Deezloader: Can't load album infos") return None - + + def searchAlbums(search_term): """Search for deezer albums using search term - + :param str search_term: search term to search album for """ logger.info(u'Searching Deezer using term: "%s"' % search_term) @@ -108,7 +111,7 @@ def searchAlbums(search_term): data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) albums = [] - + # Process content if data and 'total' in data and data['total'] > 0 and 'data' in data: for item in data['data']: @@ -121,13 +124,14 @@ def searchAlbums(search_term): return albums + def __matchAlbums(albums, artist_name, album_title, album_length): resultlist = [] - + for album in albums: total_size = 0 tracks_found = 0 - + for track in album['tracks']['data']: t = getTrack(track['id']) if t: @@ -139,29 +143,29 @@ def __matchAlbums(albums, artist_name, album_title, album_length): size = t["FILESIZE_MP3_128"] else: size = t["FILESIZE_MP3_64"] - + size = int(size) total_size += size tracks_found += 1 logger.debug(u'Found song "%s". Size: %s' % (t['SNG_TITLE'], helpers.bytes_to_mb(size))) - + matched = True mismatch_reason = 'matched!' - + if album_length > 0 and abs(int(album['duration']) - album_length) > 240: matched = False mismatch_reason = (u'duration mismatch: %i not in [%i, %i]' % (int(album['duration']), (album_length - 240), (album_length + 240))) - elif (helpers.latinToAscii(re.sub(r"\W", "", album_title, 0, re.UNICODE)).lower() != + elif (helpers.latinToAscii(re.sub(r"\W", "", album_title, 0, re.UNICODE)).lower() != helpers.latinToAscii(re.sub(r"\W", "", album['title'], 0, re.UNICODE)).lower()): matched = False mismatch_reason = (u'album name mismatch: %s != %s' % (album['title'], album_title)) - + elif (helpers.latinToAscii(re.sub(r"\W", "", artist_name, 0, re.UNICODE)).lower() != helpers.latinToAscii(re.sub(r"\W", "", album['artist']['name'], 0, re.UNICODE)).lower()): matched = False mismatch_reason = (u'artist name mismatch: %s != %s' % (album['artist']['name'], artist_name)) - + resultlist.append( (album['artist']['name'] + ' - ' + album['title'] + ' [' + album['release_date'][:4] + '] (' + str(tracks_found) + '/' + str(album['nb_tracks']) + ')', total_size, album['link'], PROVIDER_NAME, "ddl", matched) @@ -170,6 +174,7 @@ def __matchAlbums(albums, artist_name, album_title, album_length): return resultlist + def searchAlbum(artist_name, album_title, user_search_term=None, album_length=None): """Search for deezer specific album. This will iterate over deezer albums and try to find best matches @@ -182,7 +187,7 @@ def searchAlbum(artist_name, album_title, user_search_term=None, album_length=No # User search term by-pass normal search if user_search_term: return __matchAlbums(searchAlbums(user_search_term), artist_name, album_title, album_length) - + resultlist = __matchAlbums(searchAlbums((artist_name + ' ' + album_title).strip()), artist_name, album_title, album_length) if resultlist: return resultlist @@ -190,35 +195,36 @@ def searchAlbum(artist_name, album_title, user_search_term=None, album_length=No # Deezer API supports unicode, so just remove non alphanumeric characters clean_artist_name = re.sub(r"[^\w\s]", " ", artist_name, 0, re.UNICODE).strip() clean_album_name = re.sub(r"[^\w\s]", " ", album_title, 0, re.UNICODE).strip() - + resultlist = __matchAlbums(searchAlbums((clean_artist_name + ' ' + clean_album_name).strip()), artist_name, album_title, album_length) if resultlist: return resultlist - + resultlist = __matchAlbums(searchAlbums(clean_artist_name), artist_name, album_title, album_length) if resultlist: return resultlist - + return resultlist + def getTrack(sng_id, try_reload_api=True): """Returns deezer track infos - + :param int sng_id: deezer song id - :param bool try_reload_api: whether or not try reloading API if session expired + :param bool try_reload_api: whether or not try reloading API if session expired """ global __tracks_cache - + if str(sng_id) in __tracks_cache: return __tracks_cache[str(sng_id)] - + data = "[{\"method\":\"song.getListData\",\"params\":{\"sng_ids\":[" + str(sng_id) + "]}}]" json = request.request_json(url=__API_URL, headers=__HTTP_HEADERS, method='post', params=__api_queries, data=data, cookies=__cookies) - + results = [] error = None invalid_token = False - + if json: # Check for errors if 'error' in json: @@ -230,7 +236,7 @@ def getTrack(sng_id, try_reload_api=True): error = json[0]['error'] if 'VALID_TOKEN_REQUIRED' in json[0]['error'] and json[0]['error']['VALID_TOKEN_REQUIRED'] == u"Invalid CSRF token": invalid_token = True - + # Got invalid token error if error: if invalid_token and try_reload_api: @@ -245,26 +251,27 @@ def getTrack(sng_id, try_reload_api=True): if 'token' in item: logger.error(u"An unknown error occurred in the Deezer parser: Uploaded Files are currently not supported") return - + sng_id = item["SNG_ID"] md5Origin = item["MD5_ORIGIN"] sng_format = 3 - + if item["FILESIZE_MP3_320"] <= 0: if item["FILESIZE_MP3_256"] > 0: sng_format = 5 else: sng_format = 1 - + mediaVersion = int(item["MEDIA_VERSION"]) item['downloadUrl'] = __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion) - + __tracks_cache[sng_id] = item return item - + except Exception as e: logger.error(u"An unknown error occurred in the Deezer parser: %s" % e) + def __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion): urlPart = md5Origin.encode('utf-8') + b'\xa4' + str(sng_format) + b'\xa4' + str(sng_id) + b'\xa4' + str(mediaVersion) md5val = md5(urlPart).hexdigest() @@ -273,6 +280,7 @@ def __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion): ciphertext = cipher.encrypt(__pad(urlPart, AES.block_size)) return "http://e-cdn-proxy-" + md5Origin[:1] + ".deezer.com/mobile/1/" + binascii.hexlify(ciphertext).lower() + def __pad(raw, block_size): if (len(raw) % block_size == 0): return raw @@ -281,10 +289,11 @@ def __pad(raw, block_size): data = raw + padding_required * padChar return data + def __tagTrack(path, track): try: album = getAlbum(track['ALB_ID']) - + f = MediaFile(path) f.artist = track['ART_NAME'] f.album = track['ALB_TITLE'] @@ -295,22 +304,23 @@ def __tagTrack(path, track): f.bpm = track['BPM'] f.date = datetime.strptime(album['release_date'], '%Y-%m-%d').date() f.albumartist = album['artist']['name'] - if u'genres' in album and u'data' in album['genres']: + if u'genres' in album and u'data' in album['genres']: f.genres = [genre['name'] for genre in album['genres']['data']] - + f.save() except Exception as e: logger.error(u'Unable to tag deezer track "%s": %s' % (path, e)) + def decryptTracks(paths): """Decrypt downloaded deezer tracks. - + :param paths: list of path to deezer tracks (*.dzr files). """ # Note: tracks can be from different albums decrypted_tracks = {} - + # First pass: load tracks data for path in paths: try: @@ -322,15 +332,15 @@ def decryptTracks(paths): if album_folder not in decrypted_tracks: decrypted_tracks[album_folder] = {} - + if disk_number not in decrypted_tracks[album_folder]: decrypted_tracks[album_folder][disk_number] = {} decrypted_tracks[album_folder][disk_number][track_number] = track - + except Exception as e: logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) - + # Second pass: decrypt tracks for album_folder in decrypted_tracks: multi_disks = len(decrypted_tracks[album_folder]) > 1 @@ -339,12 +349,12 @@ def decryptTracks(paths): try: filename = helpers.replace_illegal_chars(track['SNG_TITLE']).strip() filename = ('{:02d}'.format(track_number) + '_' + filename + '.mp3') - + # Add a 'cd x' sub-folder if album has more than one disk disk_folder = os.path.join(album_folder, 'cd ' + str(disk_number)) if multi_disks else album_folder - + dest = os.path.join(disk_folder, filename).encode(headphones.SYS_ENCODING, 'replace') - + # Decrypt track if not already done if not os.path.exists(dest): try: @@ -358,12 +368,13 @@ def decryptTracks(paths): continue decrypted_tracks[album_folder][disk_number][track_number]['path'] = dest - + except Exception as e: logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) - + return decrypted_tracks + def __decryptDownload(source, sng_id, dest): interval_chunk = 3 chunk_size = 2048 @@ -383,7 +394,7 @@ def __decryptDownload(source, sng_id, dest): if(i % interval_chunk == 0): cipher = Blowfish.new(blowFishKey, Blowfish.MODE_CBC, iv) chunk = cipher.decrypt(__pad(chunk, Blowfish.block_size)) - + fout.write(chunk) i += 1 chunk = f.read(chunk_size) @@ -391,6 +402,7 @@ def __decryptDownload(source, sng_id, dest): f.close() fout.close() + def __getBlowFishKey(encryptionKey): if encryptionKey < 1: encryptionKey *= -1 @@ -402,14 +414,15 @@ def __getBlowFishKey(encryptionKey): return __xorHex(parts) + def __xorHex(parts): data = "" for i in range(0, 16): character = ord(parts[0][i]) - + for j in range(1, len(parts)): character ^= ord(parts[j][i]) - + data += chr(character) - + return data diff --git a/headphones/helpers_test.py b/headphones/helpers_test.py index 14b8540d..54c9e9bf 100644 --- a/headphones/helpers_test.py +++ b/headphones/helpers_test.py @@ -14,9 +14,9 @@ class HelpersTest(TestCase): u'Symphonęy Nº9': 'Symphoney No.9', u'ÆæßðÞIJij': u'AeaessdThIJıj', u'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral ' - 'apoplexy remix', + 'apoplexy remix', u'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', u'Björk Guðmundsdóttir': 'bjork gudmundsdottir', u'L\'Arc~en~Ciel': 'larc en ciel', diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 336030af..c9621506 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -261,7 +261,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal if downloaded_deezer_list: logger.info('Decrypting deezer tracks') decrypted_deezer_list = deezloader.decryptTracks(downloaded_deezer_list) - + # Check if album is complete based on album duration only # (total track numbers is not determinant enough due to hidden tracks for eg) db_track_duration = 0 @@ -271,7 +271,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal db_track_duration += track['TrackDuration'] / 1000 except: downloaded_track_duration = False - + try: for disk_number in decrypted_deezer_list[albumpath]: for track in decrypted_deezer_list[albumpath][disk_number].values(): @@ -279,14 +279,14 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_duration += int(track['DURATION']) except: downloaded_track_duration = False - + if not downloaded_track_duration or not db_track_duration or abs(downloaded_track_duration - db_track_duration) > 240: - logger.info("Looks like " + + logger.info("Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + " isn't complete yet (duration mismatch). Will try again on the next run") return - downloaded_track_list = list(set(downloaded_track_list)) # Remove duplicates + downloaded_track_list = list(set(downloaded_track_list)) # Remove duplicates # test #1: metadata - usually works logger.debug('Verifying metadata...') diff --git a/headphones/searcher.py b/headphones/searcher.py index 2516bc31..f6554cb1 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -55,6 +55,7 @@ redobj = None # Persistent Aria2 RPC object __aria2rpc_obj = None + def getAria2RPC(): global __aria2rpc_obj if not __aria2rpc_obj: @@ -67,10 +68,12 @@ def getAria2RPC(): ) return __aria2rpc_obj + def reconfigure(): global __aria2rpc_obj __aria2rpc_obj = None + def fix_url(s, charset="utf-8"): """ Fix the URL so it is proper formatted and encoded. @@ -896,7 +899,7 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) return - + else: folder_name = '%s - %s [%s]' % ( helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), @@ -1274,6 +1277,7 @@ def verifyresult(title, artistterm, term, lossless): return True + def searchDdl(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): reldate = album['ReleaseDate'] @@ -1313,7 +1317,7 @@ def searchDdl(album, new=False, losslessOnly=False, albumlength=None, logger.debug(u'Using search term: "%s"' % helpers.latinToAscii(term)) resultlist = [] - + # Deezer only provides lossy if headphones.CONFIG.DEEZLOADER and not losslessOnly: resultlist += deezloader.searchAlbum(album['ArtistName'], album['AlbumTitle'], album['SearchTerm'], int(albumlength / 1000)) diff --git a/headphones/webserve.py b/headphones/webserve.py index 19e2ffb3..7e97cefb 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1587,7 +1587,7 @@ class WebInterface(object): # Reconfigure musicbrainz database connection with the new values mb.startmb() - + # Reconfigure Aria2 searcher.reconfigure() From e8f436a5dbe40a12ceb0bc09fe7439ab8d8df006 Mon Sep 17 00:00:00 2001 From: Kallys Date: Wed, 19 Apr 2017 20:01:45 +0200 Subject: [PATCH 091/137] Fix travis errors bis --- headphones/helpers_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/headphones/helpers_test.py b/headphones/helpers_test.py index 54c9e9bf..14b8540d 100644 --- a/headphones/helpers_test.py +++ b/headphones/helpers_test.py @@ -14,9 +14,9 @@ class HelpersTest(TestCase): u'Symphonęy Nº9': 'Symphoney No.9', u'ÆæßðÞIJij': u'AeaessdThIJıj', u'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral ' - 'apoplexy remix', + 'apoplexy remix', u'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', u'Björk Guðmundsdóttir': 'bjork gudmundsdottir', u'L\'Arc~en~Ciel': 'larc en ciel', From 61a3abbf20373a1eb1e094fd53032db156c21079 Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 20 Apr 2017 20:10:34 +1200 Subject: [PATCH 092/137] last.fm series info --- headphones/cache.py | 33 +++++++++++++++++++++++++++------ headphones/helpers.py | 23 +++++++++++++++++++++++ headphones/mb.py | 24 ++++++++++++++++++++++++ headphones/metacritic.py | 3 ++- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/headphones/cache.py b/headphones/cache.py index 202b7802..0e946677 100644 --- a/headphones/cache.py +++ b/headphones/cache.py @@ -16,7 +16,7 @@ import os import headphones -from headphones import db, helpers, logger, lastfm, request +from headphones import db, helpers, logger, lastfm, request, mb LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4" @@ -290,6 +290,14 @@ class Cache(object): data = lastfm.request_lastfm("artist.getinfo", mbid=self.id, api_key=LASTFM_API_KEY) + # Try with name if not found + if not data: + dbartist = myDB.action('SELECT ArtistName, Type FROM artists WHERE ArtistID=?', [self.id]).fetchone() + if dbartist: + data = lastfm.request_lastfm("artist.getinfo", + artist=helpers.clean_musicbrainz_name(dbartist['ArtistName']), + api_key=LASTFM_API_KEY) + if not data: return @@ -315,18 +323,31 @@ class Cache(object): else: dbalbum = myDB.action( - 'SELECT ArtistName, AlbumTitle, ReleaseID FROM albums WHERE AlbumID=?', + 'SELECT ArtistName, AlbumTitle, ReleaseID, Type FROM albums WHERE AlbumID=?', [self.id]).fetchone() if dbalbum['ReleaseID'] != self.id: data = lastfm.request_lastfm("album.getinfo", mbid=dbalbum['ReleaseID'], api_key=LASTFM_API_KEY) if not data: - data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], - album=dbalbum['AlbumTitle'], + data = lastfm.request_lastfm("album.getinfo", + artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']), + album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']), api_key=LASTFM_API_KEY) else: - data = lastfm.request_lastfm("album.getinfo", artist=dbalbum['ArtistName'], - album=dbalbum['AlbumTitle'], api_key=LASTFM_API_KEY) + if dbalbum['Type'] != "part of": + data = lastfm.request_lastfm("album.getinfo", + artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']), + album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']), + api_key=LASTFM_API_KEY) + else: + + # Series, use actual artist for the release-group + artist = mb.getArtistForReleaseGroup(self.id) + if artist: + data = lastfm.request_lastfm("album.getinfo", + artist=helpers.clean_musicbrainz_name(artist), + album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']), + api_key=LASTFM_API_KEY) if not data: return diff --git a/headphones/helpers.py b/headphones/helpers.py index 2ba72101..86a60a10 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -257,6 +257,12 @@ _XLATE_SPECIAL = { u'&': ' and ', # expand & to ' and ' } +_XLATE_MUSICBRAINZ = { + # Translation table for Musicbrainz. + u"…": '...', # HORIZONTAL ELLIPSIS (U+2026) + u"’": "'", # APOSTROPHE (U+0027) + u"‐": "-", # EN DASH (U+2013) +} def _translate(s, dictionary): # type: (basestring,Mapping[basestring,basestring])->basestring @@ -325,6 +331,23 @@ def clean_name(s): return u +def clean_musicbrainz_name(s, return_as_string=True): + # type: (basestring)->unicode + """Substitute special Musicbrainz characters. + :param s: string to clean up, probably unicode. + :return: cleaned-up version of input string. + """ + if not isinstance(s, unicode): + u = unicode(s, 'ascii', 'replace') + else: + u = s + u = _translate(u, _XLATE_MUSICBRAINZ) + if return_as_string: + return u.encode('utf-8') + else: + return u + + def cleanTitle(title): title = re.sub('[\.\-\/\_]', ' ', title).lower() diff --git a/headphones/mb.py b/headphones/mb.py index faba6b8a..951675a2 100644 --- a/headphones/mb.py +++ b/headphones/mb.py @@ -770,3 +770,27 @@ def findAlbumID(artist=None, album=None): return False rgid = unicode(results[0]['id']) return rgid + + +def getArtistForReleaseGroup(rgid): + """ + Returns artist name for a release group + Used for series where we store the series instead of the artist + """ + releaseGroup = None + try: + with mb_lock: + releaseGroup = musicbrainzngs.get_release_group_by_id( + rgid, ["artists"]) + releaseGroup = releaseGroup['release-group'] + except musicbrainzngs.WebServiceError as e: + logger.warn( + 'Attempt to retrieve information from MusicBrainz for release group "%s" failed (%s)' % ( + rgid, str(e))) + mb_lock.snooze(5) + + if not releaseGroup: + return False + else: + return releaseGroup['artist-credit'][0]['artist']['name'] + diff --git a/headphones/metacritic.py b/headphones/metacritic.py index d482786f..4ff20140 100644 --- a/headphones/metacritic.py +++ b/headphones/metacritic.py @@ -27,8 +27,9 @@ def update(artistid, artist_name, release_groups): # cut down on api calls. If it's ineffective then we'll switch to search replacements = {" & ": " ", ".": ""} + mc_artist_name = helpers.clean_musicbrainz_name(artist_name, return_as_string=False) + mc_artist_name = mc_artist_name.replace("'", " ") mc_artist_name = helpers.replace_all(artist_name.lower(), replacements) - mc_artist_name = mc_artist_name.replace(" ", "-") headers = { From 50851a4953916b0ca6e0330c63ff8e919ed9a47a Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 20 Apr 2017 20:42:04 +1200 Subject: [PATCH 093/137] pep8 --- headphones/helpers.py | 1 + headphones/mb.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index 86a60a10..4c815ad7 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -264,6 +264,7 @@ _XLATE_MUSICBRAINZ = { u"‐": "-", # EN DASH (U+2013) } + def _translate(s, dictionary): # type: (basestring,Mapping[basestring,basestring])->basestring return ''.join(dictionary.get(x, x) for x in s) diff --git a/headphones/mb.py b/headphones/mb.py index 951675a2..c087d6b9 100644 --- a/headphones/mb.py +++ b/headphones/mb.py @@ -793,4 +793,3 @@ def getArtistForReleaseGroup(rgid): return False else: return releaseGroup['artist-credit'][0]['artist']['name'] - From 7fec54289b03d88bb9d994e2b84ca7ed44398bdf Mon Sep 17 00:00:00 2001 From: AdeHub Date: Fri, 21 Apr 2017 19:45:37 +1200 Subject: [PATCH 094/137] Revert "Add deezloader provider and aria2 downloader" --- data/interfaces/default/config.html | 61 +- headphones/aria2.py | 979 ---------------------------- headphones/config.py | 7 - headphones/deezloader.py | 428 ------------ headphones/postprocessor.py | 40 +- headphones/searcher.py | 167 +---- headphones/webserve.py | 13 +- 7 files changed, 21 insertions(+), 1674 deletions(-) delete mode 100644 headphones/aria2.py delete mode 100644 headphones/deezloader.py diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 43aead6a..841df0d1 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -304,45 +304,6 @@ - -
- Direct Download - Aria2 -
-
-
- - - usually http://localhost:6800/jsonrpc -
-
- - -
-
- - -
-
- - -
-
- - - Full path where ddl client downloads your music, e.g. /Users/name/Downloads/music -
-
@@ -500,7 +461,6 @@ NZBs Torrents - Direct Download No Preference
@@ -613,17 +573,6 @@ - -
- Direct Download - -
-
- -
-
- -
@@ -855,6 +804,7 @@
+ @@ -2455,11 +2405,6 @@ $("#deluge_options").show(); } - if ($("#ddl_downloader_aria").is(":checked")) - { - $("#ddl_aria_options").show(); - } - $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) { @@ -2513,9 +2458,6 @@ { $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } - if ($("#ddl_downloader_aria").is(":checked")) - { - } }); $("#mirror").change(handleNewServerSelection); @@ -2618,7 +2560,6 @@ initConfigCheckbox("#enable_https"); initConfigCheckbox("#customauth"); initConfigCheckbox("#use_tquattrecentonze"); - initConfigCheckbox("#use_deezloader"); $('#twitterStep1').click(function () { diff --git a/headphones/aria2.py b/headphones/aria2.py deleted file mode 100644 index 06d8e4e0..00000000 --- a/headphones/aria2.py +++ /dev/null @@ -1,979 +0,0 @@ -# -*- coding: utf8 -*- -# Copyright (C) 2012-2016 Xyne -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# (version 2) as published by the Free Software Foundation. -# -# -# This program 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 this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from __future__ import with_statement -import base64 -import json -import math -import os -import ssl -import string -import time -import httplib -import urllib2 - -from headphones import logger - -# ################################ Constants ################################### - -DEFAULT_PORT = 6800 -SERVER_URI_FORMAT = '{}://{}:{:d}/jsonrpc' - -# Status values for unfinished downloads. -TEMPORARY_STATUS = ('active', 'waiting', 'paused') -# Status values for finished downloads. -FINAL_STATUS = ('complete', 'error') - -ARIA2_CONTROL_FILE_EXT = '.aria2' - -# ########################## Convenience Functions ############################# - - -def to_json_list(objs): - ''' - Wrap strings in lists. Other iterables are converted to lists directly. - ''' - if isinstance(objs, str): - return [objs] - elif not isinstance(objs, list): - return list(objs) - else: - return objs - - -def add_options_and_position(params, options=None, position=None): - ''' - Convenience method for adding options and position to parameters. - ''' - if options: - params.append(options) - if position: - if not isinstance(position, int): - try: - position = int(position) - except ValueError: - position = -1 - if position >= 0: - params.append(position) - return params - - -def get_status(response): - ''' - Process a status response. - ''' - if response: - try: - return response['status'] - except KeyError: - logger.error('no status returned from Aria2 RPC server') - return 'error' - else: - logger.error('no response from server') - return 'error' - - -def random_token(length, valid_chars=None): - ''' - Get a random secret token for the Aria2 RPC server. - - length: - The length of the token - - valid_chars: - A list or other ordered and indexable iterable of valid characters. If not - given of None, asciinumberic characters with some punctuation characters - will be used. - ''' - if not valid_chars: - valid_chars = string.ascii_letters + string.digits + '!@#$%^&*()-_=+' - number_of_chars = len(valid_chars) - bytes_to_read = math.ceil(math.log(number_of_chars) / math.log(0x100)) - max_value = 0x100**bytes_to_read - max_index = number_of_chars - 1 - token = '' - for _ in range(length): - value = int.from_bytes(os.urandom(bytes_to_read), byteorder='little') - index = round((value * max_index) / max_value) - token += valid_chars[index] - return token - -# ################ From python3-aur's ThreadedServers.common ################### - - -def format_bytes(size): - '''Format bytes for inferior humans.''' - if size < 0x400: - return '{:d} B'.format(size) - else: - size = float(size) / 0x400 - for prefix in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB'): - if size < 0x400: - return '{:0.02f} {}'.format(size, prefix) - else: - size /= 0x400 - return '{:0.02f} YiB'.format(size) - - -def format_seconds(s): - '''Format seconds for inferior humans.''' - string = '' - for base, char in ( - (60, 's'), - (60, 'm'), - (24, 'h') - ): - s, r = divmod(s, base) - if s == 0: - return '{:d}{}{}'.format(r, char, string) - elif r != 0: - string = '{:02d}{}{}'.format(r, char, string) - else: - return '{:d}d{}'.format(s, string) - -# ############################ Aria2JsonRpcError ############################### - - -class Aria2JsonRpcError(Exception): - def __init__(self, msg, connection_error=False): - super(self.__class__, self).__init__(self, msg) - self.connection_error = connection_error - -# ############################ Aria2JsonRpc Class ############################## - - -class Aria2JsonRpc(object): - ''' - Interface class for interacting with an Aria2 RPC server. - ''' - # TODO: certificate options, etc. - def __init__( - self, ID, uri, - mode='normal', - token=None, - http_user=None, http_passwd=None, - server_cert=None, client_cert=None, client_cert_password=None, - ssl_protocol=None, - setup_function=None - ): - ''' - ID: the ID to send to the RPC interface - - uri: the URI of the RPC interface - - mode: - normal - process requests immediately - batch - queue requests (run with "process_queue") - format - return RPC request objects - - token: - RPC method-level authorization token (set using `--rpc-secret`) - - http_user, http_password: - HTTP Basic authentication credentials (deprecated) - - server_cert: - server certificate for HTTPS connections - - client_cert: - client certificate for HTTPS connections - - client_cert_password: - prompt for client certificate password - - ssl_protocol: - SSL protocol from the ssl module - - setup_function: - A function to invoke prior to the first server call. This could be the - launch() method of an Aria2RpcServer instance, for example. This attribute - is set automatically in instances returned from Aria2RpcServer.get_a2jr() - ''' - self.id = ID - self.uri = uri - self.mode = mode - self.queue = [] - self.handlers = dict() - self.token = token - self.setup_function = setup_function - - if None not in (http_user, http_passwd): - self.add_HTTPBasicAuthHandler(http_user, http_passwd) - - if server_cert or client_cert: - self.add_HTTPSHandler( - server_cert=server_cert, - client_cert=client_cert, - client_cert_password=client_cert_password, - protocol=ssl_protocol - ) - - self.update_opener() - - def iter_handlers(self): - ''' - Iterate over handlers. - ''' - for name in ('HTTPS', 'HTTPBasicAuth'): - try: - yield self.handlers[name] - except KeyError: - pass - - def update_opener(self): - ''' - Build an opener from the current handlers. - ''' - self.opener = urllib2.build_opener(*self.iter_handlers()) - - def remove_handler(self, name): - ''' - Remove a handler. - ''' - try: - del self.handlers[name] - except KeyError: - pass - - def add_HTTPBasicAuthHandler(self, user, passwd): - ''' - Add a handler for HTTP Basic authentication. - - If either user or passwd are None, the handler is removed. - ''' - handler = urllib2.HTTPBasicAuthHandler() - handler.add_password( - realm='aria2', - uri=self.uri, - user=user, - passwd=passwd, - ) - self.handlers['HTTPBasicAuth'] = handler - - def remove_HTTPBasicAuthHandler(self): - self.remove_handler('HTTPBasicAuth') - - def add_HTTPSHandler( - self, - server_cert=None, - client_cert=None, - client_cert_password=None, - protocol=None, - ): - ''' - Add a handler for HTTPS connections with optional server and client - certificates. - ''' - if not protocol: - protocol = ssl.PROTOCOL_TLSv1 -# protocol = ssl.PROTOCOL_TLSv1_1 # openssl 1.0.1+ -# protocol = ssl.PROTOCOL_TLSv1_2 # Python 3.4+ - context = ssl.SSLContext(protocol) - - if server_cert: - context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations(cafile=server_cert) - else: - context.verify_mode = ssl.CERT_OPTIONAL - - if client_cert: - context.load_cert_chain(client_cert, password=client_cert_password) - - self.handlers['HTTPS'] = urllib2.HTTPSHandler( - context=context, - check_hostname=False - ) - - def remove_HTTPSHandler(self): - self.remove_handler('HTTPS') - - def send_request(self, req_obj): - ''' - Send the request and return the response. - ''' - if self.setup_function: - self.setup_function() - self.setup_function = None - logger.debug("Aria req_obj: %s" % json.dumps(req_obj, indent=2, sort_keys=True)) - req = json.dumps(req_obj).encode('UTF-8') - try: - f = self.opener.open(self.uri, req) - obj = json.loads(f.read()) - try: - return obj['result'] - except KeyError: - raise Aria2JsonRpcError('unexpected result: {}'.format(obj)) - except (urllib2.URLError) as e: - # This should work but URLError does not set the errno attribute: - # e.errno == errno.ECONNREFUSED - raise Aria2JsonRpcError( - str(e), - connection_error=( - '111' in str(e) - ) - ) - except httplib.BadStatusLine as e: - raise Aria2JsonRpcError('{}: BadStatusLine: {} (HTTPS error?)'.format( - self.__class__.__name__, e - )) - - def jsonrpc(self, method, params=None, prefix='aria2.'): - ''' - POST a request to the RPC interface. - ''' - if not params: - params = [] - - if self.token is not None: - token_str = 'token:{}'.format(self.token) - if method == 'multicall': - for p in params[0]: - try: - p['params'].insert(0, token_str) - except KeyError: - p['params'] = [token_str] - else: - params.insert(0, token_str) - - req_obj = { - 'jsonrpc': '2.0', - 'id': self.id, - 'method': prefix + method, - 'params': params, - } - if self.mode == 'batch': - self.queue.append(req_obj) - return None - elif self.mode == 'format': - return req_obj - else: - return self.send_request(req_obj) - - def process_queue(self): - ''' - Processed queued requests. - ''' - req_obj = self.queue - self.queue = [] - return self.send_request(req_obj) - - -# ############################# Standard Methods ############################### - def addUri(self, uris, options=None, position=None): - ''' - aria2.addUri method - - uris: list of URIs - - options: dictionary of additional options - - position: position in queue - - Returns a GID - ''' - params = [uris] - params = add_options_and_position(params, options, position) - return self.jsonrpc('addUri', params) - - def addTorrent(self, torrent, uris=None, options=None, position=None): - ''' - aria2.addTorrent method - - torrent: base64-encoded torrent file - - uris: list of webseed URIs - - options: dictionary of additional options - - position: position in queue - - Returns a GID. - ''' - params = [torrent] - if uris: - params.append(uris) - params = add_options_and_position(params, options, position) - return self.jsonrpc('addTorrent', params) - - def addMetalink(self, metalink, options=None, position=None): - ''' - aria2.addMetalink method - - metalink: base64-encoded metalink file - - options: dictionary of additional options - - position: position in queue - - Returns an array of GIDs. - ''' - params = [metalink] - params = add_options_and_position(params, options, position) - return self.jsonrpc('addTorrent', params) - - def remove(self, gid): - ''' - aria2.remove method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('remove', params) - - def forceRemove(self, gid): - ''' - aria2.forceRemove method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('forceRemove', params) - - def pause(self, gid): - ''' - aria2.pause method - - gid: GID to pause - ''' - params = [gid] - return self.jsonrpc('pause', params) - - def pauseAll(self): - ''' - aria2.pauseAll method - ''' - return self.jsonrpc('pauseAll') - - def forcePause(self, gid): - ''' - aria2.forcePause method - - gid: GID to pause - ''' - params = [gid] - return self.jsonrpc('forcePause', params) - - def forcePauseAll(self): - ''' - aria2.forcePauseAll method - ''' - return self.jsonrpc('forcePauseAll') - - def unpause(self, gid): - ''' - aria2.unpause method - - gid: GID to unpause - ''' - params = [gid] - return self.jsonrpc('unpause', params) - - def unpauseAll(self): - ''' - aria2.unpauseAll method - ''' - return self.jsonrpc('unpauseAll') - - def tellStatus(self, gid, keys=None): - ''' - aria2.tellStatus method - - gid: GID to query - - keys: subset of status keys to return (all keys are returned otherwise) - - Returns a dictionary. - ''' - params = [gid] - if keys: - params.append(keys) - return self.jsonrpc('tellStatus', params) - - def getUris(self, gid): - ''' - aria2.getUris method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getUris', params) - - def getFiles(self, gid): - ''' - aria2.getFiles method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getFiles', params) - - def getPeers(self, gid): - ''' - aria2.getPeers method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getPeers', params) - - def getServers(self, gid): - ''' - aria2.getServers method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getServers', params) - - def tellActive(self, keys=None): - ''' - aria2.tellActive method - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - if keys: - params = [keys] - else: - params = None - return self.jsonrpc('tellActive', params) - - def tellWaiting(self, offset, num, keys=None): - ''' - aria2.tellWaiting method - - offset: offset from start of waiting download queue - (negative values are counted from the end of the queue) - - num: number of downloads to return - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - params = [offset, num] - if keys: - params.append(keys) - return self.jsonrpc('tellWaiting', params) - - def tellStopped(self, offset, num, keys=None): - ''' - aria2.tellStopped method - - offset: offset from oldest download (same semantics as tellWaiting) - - num: same as tellWaiting - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - params = [offset, num] - if keys: - params.append(keys) - return self.jsonrpc('tellStopped', params) - - def changePosition(self, gid, pos, how): - ''' - aria2.changePosition method - - gid: GID to change - - pos: the position - - how: "POS_SET", "POS_CUR" or "POS_END" - ''' - params = [gid, pos, how] - return self.jsonrpc('changePosition', params) - - def changeUri(self, gid, fileIndex, delUris, addUris, position=None): - ''' - aria2.changePosition method - - gid: GID to change - - fileIndex: file to affect (1-based) - - delUris: URIs to remove - - addUris: URIs to add - - position: where URIs are inserted, after URIs have been removed - ''' - params = [gid, fileIndex, delUris, addUris] - if position: - params.append(position) - return self.jsonrpc('changePosition', params) - - def getOption(self, gid): - ''' - aria2.getOption method - - gid: GID to query - - Returns a dictionary of options. - ''' - params = [gid] - return self.jsonrpc('getOption', params) - - def changeOption(self, gid, options): - ''' - aria2.changeOption method - - gid: GID to change - - options: dictionary of new options - (not all options can be changed for active downloads) - ''' - params = [gid, options] - return self.jsonrpc('changeOption', params) - - def getGlobalOption(self): - ''' - aria2.getGlobalOption method - - Returns a dictionary. - ''' - return self.jsonrpc('getGlobalOption') - - def changeGlobalOption(self, options): - ''' - aria2.changeGlobalOption method - - options: dictionary of new options - ''' - params = [options] - return self.jsonrpc('changeGlobalOption', params) - - def getGlobalStat(self): - ''' - aria2.getGlobalStat method - - Returns a dictionary. - ''' - return self.jsonrpc('getGlobalStat') - - def purgeDownloadResult(self): - ''' - aria2.purgeDownloadResult method - ''' - self.jsonrpc('purgeDownloadResult') - - def removeDownloadResult(self, gid): - ''' - aria2.removeDownloadResult method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('removeDownloadResult', params) - - def getVersion(self): - ''' - aria2.getVersion method - - Returns a dictionary. - ''' - return self.jsonrpc('getVersion') - - def getSessionInfo(self): - ''' - aria2.getSessionInfo method - - Returns a dictionary. - ''' - return self.jsonrpc('getSessionInfo') - - def shutdown(self): - ''' - aria2.shutdown method - ''' - return self.jsonrpc('shutdown') - - def forceShutdown(self): - ''' - aria2.forceShutdown method - ''' - return self.jsonrpc('forceShutdown') - - def multicall(self, methods): - ''' - aria2.multicall method - - methods: list of dictionaries (keys: methodName, params) - - The method names must be those used by Aria2c, e.g. "aria2.tellStatus". - ''' - return self.jsonrpc('multicall', [methods], prefix='system.') - - -# ########################### Convenience Methods ############################## - def add_torrent(self, path, uris=None, options=None, position=None): - ''' - A wrapper around addTorrent for loading files. - ''' - with open(path, 'r') as f: - torrent = base64.encode(f.read()) - return self.addTorrent(torrent, uris, options, position) - - def add_metalink(self, path, uris=None, options=None, position=None): - ''' - A wrapper around addMetalink for loading files. - ''' - with open(path, 'r') as f: - metalink = base64.encode(f.read()) - return self.addMetalink(metalink, uris, options, position) - - def get_status(self, gid): - ''' - Get the status of a single GID. - ''' - response = self.tellStatus(gid, ['status']) - return get_status(response) - - def wait_for_final_status(self, gid, interval=1): - ''' - Wait for a GID to complete or fail and return its status. - ''' - if not interval or interval < 0: - interval = 1 - while True: - status = self.get_status(gid) - if status in TEMPORARY_STATUS: - time.sleep(interval) - else: - return status - - def get_statuses(self, gids): - ''' - Get the status of multiple GIDs. The status of each is yielded in order. - ''' - methods = [ - { - 'methodName': 'aria2.tellStatus', - 'params': [gid, ['gid', 'status']] - } - for gid in gids - ] - results = self.multicall(methods) - if results: - status = dict((r[0]['gid'], r[0]['status']) for r in results) - for gid in gids: - try: - yield status[gid] - except KeyError: - logger.error('Aria2 RPC server returned no status for GID {}'.format(gid)) - yield 'error' - else: - logger.error('no response from Aria2 RPC server') - for gid in gids: - yield 'error' - - def wait_for_final_statuses(self, gids, interval=1): - ''' - Wait for multiple GIDs to complete or fail and return their statuses in - order. - - gids: - A flat list of GIDs. - ''' - if not interval or interval < 0: - interval = 1 - statusmap = dict((g, None) for g in gids) - remaining = list( - g for g, s in statusmap.items() if s is None - ) - while remaining: - for g, s in zip(remaining, self.get_statuses(remaining)): - if s in TEMPORARY_STATUS: - continue - else: - statusmap[g] = s - remaining = list( - g for g, s in statusmap.items() if s is None - ) - if remaining: - time.sleep(interval) - for g in gids: - yield statusmap[g] - - def print_global_status(self): - ''' - Print global status of the RPC server. - ''' - status = self.getGlobalStat() - if status: - numWaiting = int(status['numWaiting']) - numStopped = int(status['numStopped']) - keys = ['totalLength', 'completedLength'] - total = self.tellActive(keys) - waiting = self.tellWaiting(0, numWaiting, keys) - if waiting: - total += waiting - stopped = self.tellStopped(0, numStopped, keys) - if stopped: - total += stopped - - downloadSpeed = int(status['downloadSpeed']) - uploadSpeed = int(status['uploadSpeed']) - totalLength = sum(int(x['totalLength']) for x in total) - completedLength = sum(int(x['completedLength']) for x in total) - remaining = totalLength - completedLength - - status['downloadSpeed'] = format_bytes(downloadSpeed) + '/s' - status['uploadSpeed'] = format_bytes(uploadSpeed) + '/s' - - preordered = ('downloadSpeed', 'uploadSpeed') - - rows = list() - for k in sorted(status): - if k in preordered: - continue - rows.append((k, status[k])) - - rows.extend((x, status[x]) for x in preordered) - - if totalLength > 0: - rows.append(('total', format(format_bytes(totalLength)))) - rows.append(('completed', format(format_bytes(completedLength)))) - rows.append(('remaining', format(format_bytes(remaining)))) - if completedLength == totalLength: - eta = 'finished' - else: - try: - eta = format_seconds(remaining // downloadSpeed) - except ZeroDivisionError: - eta = 'never' - rows.append(('ETA', eta)) - - l = max(len(r[0]) for r in rows) - r = max(len(r[1]) for r in rows) - r = max(r, len(self.uri) - (l + 2)) - fmt = '{:<' + str(l) + 's} {:>' + str(r) + 's}' - - print(self.uri) - for k, v in rows: - print(fmt.format(k, v)) - - def queue_uris(self, uris, options, interval=None): - ''' - Enqueue URIs and wait for download to finish while printing status at - regular intervals. - ''' - gid = self.addUri(uris, options) - print('GID: {}'.format(gid)) - - if gid and interval is not None: - blanker = '' - while True: - response = self.tellStatus(gid, ['status']) - if response: - try: - status = response['status'] - except KeyError: - print('error: no status returned from Aria2 RPC server') - break - print('{}\rstatus: {}'.format(blanker, status)), - blanker = ' ' * len(status) - if status in TEMPORARY_STATUS: - time.sleep(interval) - else: - break - else: - print('error: no response from server') - break - - -# ####################### Polymethod download handlers ######################### - def polymethod_enqueue_many(self, downloads): - ''' - Enqueue downloads. - - downloads: Same as polymethod_download(). - ''' - methods = list( - { - 'methodName': 'aria2.{}'.format(d[0]), - 'params': list(d[1:]) - } for d in downloads - ) - return self.multicall(methods) - - def polymethod_wait_many(self, gids, interval=1): - ''' - Wait for the GIDs to complete or fail and return their statuses. - - gids: - A list of lists of GIDs. - ''' - # The flattened list of GIDs - gs = list(g for gs in gids for g in gs) - statusmap = dict(tuple(zip( - gs, - self.wait_for_final_statuses(gs, interval=interval) - ))) - for gs in gids: - yield list(statusmap.get(g, 'error') for g in gs) - - def polymethod_enqueue_one(self, download): - ''' - Same as polymethod_enqueue_many but for one element. - ''' - return getattr(self, download[0])(*download[1:]) - - def polymethod_download(self, downloads, interval=1): - ''' - Enqueue a series of downloads and wait for them to finish. Iterate over the - status of each, in order. - - downloads: - An iterable over (, , ...) where indicates the "add" - method to use ('addUri', 'addTorrent', 'addMetalink') and everything that - follows are arguments to pass to that method. - - interval: - The status check interval while waiting. - - Iterates over the download status of finished downloads. "complete" - indicates success. Lists of statuses will be returned for downloads that - create multiple GIDs (e.g. metalinks). - ''' - gids = self.polymethod_enqueue_many(downloads) - return self.polymethod_wait_many(gids, interval=interval) - - def polymethod_download_bool(self, *args, **kwargs): - ''' - A wrapper around polymethod_download() which returns a boolean for each - download to indicate success (True) or failure (False). - ''' -# for status in self.polymethod_download(*args, **kwargs): -# yield all(s == 'complete' for s in status) - return list( - all(s == 'complete' for s in status) - for status in self.polymethod_download(*args, **kwargs) - ) diff --git a/headphones/config.py b/headphones/config.py index 86aa9216..57af4c16 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -46,10 +46,6 @@ _CONFIG_DEFINITIONS = { 'APOLLO_RATIO': (str, 'Apollo.rip', ''), 'APOLLO_USERNAME': (str, 'Apollo.rip', ''), 'APOLLO_URL': (str, 'Apollo.rip', 'https://apollo.rip'), - 'ARIA_HOST': (str, 'Aria2', ''), - 'ARIA_PASSWORD': (str, 'Aria2', ''), - 'ARIA_TOKEN': (str, 'Aria2', ''), - 'ARIA_USERNAME': (str, 'Aria2', ''), 'AUTOWANT_ALL': (int, 'General', 0), 'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1), 'AUTOWANT_UPCOMING': (int, 'General', 1), @@ -77,8 +73,6 @@ _CONFIG_DEFINITIONS = { 'CUSTOMPORT': (int, 'General', 5000), 'CUSTOMSLEEP': (int, 'General', 1), 'CUSTOMUSER': (str, 'General', ''), - 'DDL_DOWNLOADER': (int, 'General', 0), - 'DEEZLOADER': (int, 'DeezLoader', 0), 'DELETE_LOSSLESS_FILES': (int, 'General', 1), 'DELUGE_HOST': (str, 'Deluge', ''), 'DELUGE_CERT': (str, 'Deluge', ''), @@ -92,7 +86,6 @@ _CONFIG_DEFINITIONS = { 'DOWNLOAD_DIR': (path, 'General', ''), 'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5), 'DOWNLOAD_TORRENT_DIR': (path, 'General', ''), - 'DOWNLOAD_DDL_DIR': (path, 'General', ''), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'EMAIL_ENABLED': (int, 'Email', 0), 'EMAIL_FROM': (str, 'Email', ''), diff --git a/headphones/deezloader.py b/headphones/deezloader.py deleted file mode 100644 index 528cded6..00000000 --- a/headphones/deezloader.py +++ /dev/null @@ -1,428 +0,0 @@ -# -*- coding: utf-8 -*- -# Deezloader (c) 2016 by ParadoxalManiak -# -# Deezloader is licensed under a -# Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. -# -# You should have received a copy of the license along with this -# work. If not, see . -# -# Version 2.1.0 -# Maintained by ParadoxalManiak -# Original work by ZzMTV -# -# Author's disclaimer: -# I am not responsible for the usage of this program by other people. -# I do not recommend you doing this illegally or against Deezer's terms of service. -# This project is licensed under CC BY-NC-SA 4.0 - -import re -import os -from datetime import datetime -from Crypto.Cipher import AES, Blowfish -from hashlib import md5 -import binascii - -from beets.mediafile import MediaFile -from headphones import logger, request, helpers -import headphones - -# Public constants -PROVIDER_NAME = 'Deezer' - -# Internal constants -__API_URL = "http://www.deezer.com/ajax/gw-light.php" -__API_INFO_URL = "http://api.deezer.com/" -__HTTP_HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36", - "Content-Language": "en-US", - "Cache-Control": "max-age=0", - "Accept": "*/*", - "Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3", - "Accept-Language": "de-DE,de;q=0.8,en-US;q=0.6,en;q=0.4" -} - -# Internal variables -__api_queries = { - 'api_version': "1.0", - 'api_token': "None", - 'input': "3" -} -__cookies = None -__tracks_cache = {} -__albums_cache = {} - - -def __getApiToken(): - global __cookies - response = request.request_response(url="http://www.deezer.com/", headers=__HTTP_HEADERS) - __cookies = response.cookies - data = response.content - if data: - matches = re.search(r"checkForm\s*=\s*['|\"](.*)[\"|'];", data) - if matches: - token = matches.group(1) - __api_queries['api_token'] = token - logger.debug(u"Deezloader : api token loeaded ('%s')" % token) - - if not token: - logger.error(u"Deezloader: Unable to get api token") - - -def getAlbumByLink(album_link): - """Returns deezer album infos using album link url - - :param str album_link: deezer album link url (eg: 'http://www.deezer.com/album/1234567/') - """ - matches = re.search(r"album\/([0-9]+)\/?$", album_link) - if matches: - return getAlbum(matches.group(1)) - - -def getAlbum(album_id): - """Returns deezer album infos - - :param int album_id: deezer album id - """ - global __albums_cache - - if str(album_id) in __albums_cache: - return __albums_cache[str(album_id)] - - url = __API_INFO_URL + "album/" + str(album_id) - data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) - - if data and 'error' not in data: - __albums_cache[str(album_id)] = data - return data - else: - logger.debug("Deezloader: Can't load album infos") - return None - - -def searchAlbums(search_term): - """Search for deezer albums using search term - - :param str search_term: search term to search album for - """ - logger.info(u'Searching Deezer using term: "%s"' % search_term) - - url = __API_INFO_URL + "search/album?q=" + search_term - data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) - - albums = [] - - # Process content - if data and 'total' in data and data['total'] > 0 and 'data' in data: - for item in data['data']: - try: - albums.append(getAlbum(item['id'])) - except Exception as e: - logger.error(u"An unknown error occurred in the Deezer parser: %s" % e) - else: - logger.info(u'No results found from Deezer using term: "%s"' % search_term) - - return albums - - -def __matchAlbums(albums, artist_name, album_title, album_length): - resultlist = [] - - for album in albums: - total_size = 0 - tracks_found = 0 - - for track in album['tracks']['data']: - t = getTrack(track['id']) - if t: - if t["FILESIZE_MP3_320"] > 0: - size = t["FILESIZE_MP3_320"] - elif t["FILESIZE_MP3_256"] > 0: - size = t["FILESIZE_MP3_256"] - elif t["FILESIZE_MP3_128"] > 0: - size = t["FILESIZE_MP3_128"] - else: - size = t["FILESIZE_MP3_64"] - - size = int(size) - total_size += size - tracks_found += 1 - logger.debug(u'Found song "%s". Size: %s' % (t['SNG_TITLE'], helpers.bytes_to_mb(size))) - - matched = True - mismatch_reason = 'matched!' - - if album_length > 0 and abs(int(album['duration']) - album_length) > 240: - matched = False - mismatch_reason = (u'duration mismatch: %i not in [%i, %i]' % (int(album['duration']), (album_length - 240), (album_length + 240))) - - elif (helpers.latinToAscii(re.sub(r"\W", "", album_title, 0, re.UNICODE)).lower() != - helpers.latinToAscii(re.sub(r"\W", "", album['title'], 0, re.UNICODE)).lower()): - matched = False - mismatch_reason = (u'album name mismatch: %s != %s' % (album['title'], album_title)) - - elif (helpers.latinToAscii(re.sub(r"\W", "", artist_name, 0, re.UNICODE)).lower() != - helpers.latinToAscii(re.sub(r"\W", "", album['artist']['name'], 0, re.UNICODE)).lower()): - matched = False - mismatch_reason = (u'artist name mismatch: %s != %s' % (album['artist']['name'], artist_name)) - - resultlist.append( - (album['artist']['name'] + ' - ' + album['title'] + ' [' + album['release_date'][:4] + '] (' + str(tracks_found) + '/' + str(album['nb_tracks']) + ')', - total_size, album['link'], PROVIDER_NAME, "ddl", matched) - ) - logger.info(u'Found "%s". Tracks %i/%i. Size: %s (%s)' % (album['title'], tracks_found, album['nb_tracks'], helpers.bytes_to_mb(total_size), mismatch_reason)) - - return resultlist - - -def searchAlbum(artist_name, album_title, user_search_term=None, album_length=None): - """Search for deezer specific album. - This will iterate over deezer albums and try to find best matches - - :param str artist_name: album artist name - :param str album_title: album title - :param str user_search_term: search terms provided by user - :param int album_length: targeted album duration in seconds - """ - # User search term by-pass normal search - if user_search_term: - return __matchAlbums(searchAlbums(user_search_term), artist_name, album_title, album_length) - - resultlist = __matchAlbums(searchAlbums((artist_name + ' ' + album_title).strip()), artist_name, album_title, album_length) - if resultlist: - return resultlist - - # Deezer API supports unicode, so just remove non alphanumeric characters - clean_artist_name = re.sub(r"[^\w\s]", " ", artist_name, 0, re.UNICODE).strip() - clean_album_name = re.sub(r"[^\w\s]", " ", album_title, 0, re.UNICODE).strip() - - resultlist = __matchAlbums(searchAlbums((clean_artist_name + ' ' + clean_album_name).strip()), artist_name, album_title, album_length) - if resultlist: - return resultlist - - resultlist = __matchAlbums(searchAlbums(clean_artist_name), artist_name, album_title, album_length) - if resultlist: - return resultlist - - return resultlist - - -def getTrack(sng_id, try_reload_api=True): - """Returns deezer track infos - - :param int sng_id: deezer song id - :param bool try_reload_api: whether or not try reloading API if session expired - """ - global __tracks_cache - - if str(sng_id) in __tracks_cache: - return __tracks_cache[str(sng_id)] - - data = "[{\"method\":\"song.getListData\",\"params\":{\"sng_ids\":[" + str(sng_id) + "]}}]" - json = request.request_json(url=__API_URL, headers=__HTTP_HEADERS, method='post', params=__api_queries, data=data, cookies=__cookies) - - results = [] - error = None - invalid_token = False - - if json: - # Check for errors - if 'error' in json: - error = json['error'] - if 'GATEWAY_ERROR' in json['error'] and json['error']['GATEWAY_ERROR'] == u"invalid api token": - invalid_token = True - - elif 'error' in json[0] and json[0]['error']: - error = json[0]['error'] - if 'VALID_TOKEN_REQUIRED' in json[0]['error'] and json[0]['error']['VALID_TOKEN_REQUIRED'] == u"Invalid CSRF token": - invalid_token = True - - # Got invalid token error - if error: - if invalid_token and try_reload_api: - __getApiToken() - return getTrack(sng_id, False) - else: - logger.error(u"An unknown error occurred in the Deezer parser: %s" % error) - else: - try: - results = json[0]['results'] - item = results['data'][0] - if 'token' in item: - logger.error(u"An unknown error occurred in the Deezer parser: Uploaded Files are currently not supported") - return - - sng_id = item["SNG_ID"] - md5Origin = item["MD5_ORIGIN"] - sng_format = 3 - - if item["FILESIZE_MP3_320"] <= 0: - if item["FILESIZE_MP3_256"] > 0: - sng_format = 5 - else: - sng_format = 1 - - mediaVersion = int(item["MEDIA_VERSION"]) - item['downloadUrl'] = __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion) - - __tracks_cache[sng_id] = item - return item - - except Exception as e: - logger.error(u"An unknown error occurred in the Deezer parser: %s" % e) - - -def __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion): - urlPart = md5Origin.encode('utf-8') + b'\xa4' + str(sng_format) + b'\xa4' + str(sng_id) + b'\xa4' + str(mediaVersion) - md5val = md5(urlPart).hexdigest() - urlPart = md5val + b'\xa4' + urlPart + b'\xa4' - cipher = AES.new('jo6aey6haid2Teih', AES.MODE_ECB) - ciphertext = cipher.encrypt(__pad(urlPart, AES.block_size)) - return "http://e-cdn-proxy-" + md5Origin[:1] + ".deezer.com/mobile/1/" + binascii.hexlify(ciphertext).lower() - - -def __pad(raw, block_size): - if (len(raw) % block_size == 0): - return raw - padding_required = block_size - (len(raw) % block_size) - padChar = b'\x00' - data = raw + padding_required * padChar - return data - - -def __tagTrack(path, track): - try: - album = getAlbum(track['ALB_ID']) - - f = MediaFile(path) - f.artist = track['ART_NAME'] - f.album = track['ALB_TITLE'] - f.title = track['SNG_TITLE'] - f.track = track['TRACK_NUMBER'] - f.tracktotal = album['nb_tracks'] - f.disc = track['DISK_NUMBER'] - f.bpm = track['BPM'] - f.date = datetime.strptime(album['release_date'], '%Y-%m-%d').date() - f.albumartist = album['artist']['name'] - if u'genres' in album and u'data' in album['genres']: - f.genres = [genre['name'] for genre in album['genres']['data']] - - f.save() - - except Exception as e: - logger.error(u'Unable to tag deezer track "%s": %s' % (path, e)) - - -def decryptTracks(paths): - """Decrypt downloaded deezer tracks. - - :param paths: list of path to deezer tracks (*.dzr files). - """ - # Note: tracks can be from different albums - decrypted_tracks = {} - - # First pass: load tracks data - for path in paths: - try: - album_folder = os.path.dirname(path) - sng_id = os.path.splitext(os.path.basename(path))[0] - track = getTrack(sng_id) - track_number = int(track['TRACK_NUMBER']) - disk_number = int(track['DISK_NUMBER']) - - if album_folder not in decrypted_tracks: - decrypted_tracks[album_folder] = {} - - if disk_number not in decrypted_tracks[album_folder]: - decrypted_tracks[album_folder][disk_number] = {} - - decrypted_tracks[album_folder][disk_number][track_number] = track - - except Exception as e: - logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) - - # Second pass: decrypt tracks - for album_folder in decrypted_tracks: - multi_disks = len(decrypted_tracks[album_folder]) > 1 - for disk_number in decrypted_tracks[album_folder]: - for track_number, track in decrypted_tracks[album_folder][disk_number].items(): - try: - filename = helpers.replace_illegal_chars(track['SNG_TITLE']).strip() - filename = ('{:02d}'.format(track_number) + '_' + filename + '.mp3') - - # Add a 'cd x' sub-folder if album has more than one disk - disk_folder = os.path.join(album_folder, 'cd ' + str(disk_number)) if multi_disks else album_folder - - dest = os.path.join(disk_folder, filename).encode(headphones.SYS_ENCODING, 'replace') - - # Decrypt track if not already done - if not os.path.exists(dest): - try: - __decryptDownload(path, sng_id, dest) - __tagTrack(dest, track) - except Exception as e: - logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) - if os.path.exists(dest): - os.remove(dest) - decrypted_tracks[album_folder][disk_number].pop(track_number) - continue - - decrypted_tracks[album_folder][disk_number][track_number]['path'] = dest - - except Exception as e: - logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) - - return decrypted_tracks - - -def __decryptDownload(source, sng_id, dest): - interval_chunk = 3 - chunk_size = 2048 - blowFishKey = __getBlowFishKey(sng_id) - i = 0 - iv = "\x00\x01\x02\x03\x04\x05\x06\x07" - - dest_folder = os.path.dirname(dest) - if not os.path.exists(dest_folder): - os.makedirs(dest_folder) - - f = open(source, "rb") - fout = open(dest, "wb") - try: - chunk = f.read(chunk_size) - while chunk: - if(i % interval_chunk == 0): - cipher = Blowfish.new(blowFishKey, Blowfish.MODE_CBC, iv) - chunk = cipher.decrypt(__pad(chunk, Blowfish.block_size)) - - fout.write(chunk) - i += 1 - chunk = f.read(chunk_size) - finally: - f.close() - fout.close() - - -def __getBlowFishKey(encryptionKey): - if encryptionKey < 1: - encryptionKey *= -1 - - hashcode = md5(str(encryptionKey)).hexdigest() - hPart = hashcode[0:16] - lPart = hashcode[16:32] - parts = ['g4el58wc0zvf9na1', hPart, lPart] - - return __xorHex(parts) - - -def __xorHex(parts): - data = "" - for i in range(0, 16): - character = ord(parts[0][i]) - - for j in range(1, len(parts)): - character ^= ord(parts[j][i]) - - data += chr(character) - - return data diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index c9621506..e3deee23 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -29,7 +29,7 @@ from beetsplug import lyrics as beetslyrics from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync from headphones import logger, helpers, mb, music_encoder -from headphones import metadata, deezloader +from headphones import metadata postprocessor_lock = threading.Lock() @@ -47,8 +47,6 @@ def checkFolder(): single = False if album['Kind'] == 'nzb': download_dir = headphones.CONFIG.DOWNLOAD_DIR - elif album['Kind'] == 'ddl': - download_dir = headphones.CONFIG.DOWNLOAD_DDL_DIR else: if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3: download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY @@ -206,7 +204,6 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid]) downloaded_track_list = [] - downloaded_deezer_list = [] downloaded_cuecount = 0 for r, d, f in os.walk(albumpath): @@ -215,10 +212,8 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list.append(os.path.join(r, files)) elif files.lower().endswith('.cue'): downloaded_cuecount += 1 - elif files.lower().endswith('.dzr'): - downloaded_deezer_list.append(os.path.join(r, files)) # 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', '.aria2')) and not forced: + elif files.lower().endswith(('.part', '.utpart')) and not forced: logger.info( "Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + " isn't complete yet. Will try again on the next run") @@ -257,37 +252,6 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list = helpers.get_downloaded_track_list(albumpath) keep_original_folder = False - # Decrypt deezer tracks - if downloaded_deezer_list: - logger.info('Decrypting deezer tracks') - decrypted_deezer_list = deezloader.decryptTracks(downloaded_deezer_list) - - # Check if album is complete based on album duration only - # (total track numbers is not determinant enough due to hidden tracks for eg) - db_track_duration = 0 - downloaded_track_duration = 0 - try: - for track in tracks: - db_track_duration += track['TrackDuration'] / 1000 - except: - downloaded_track_duration = False - - try: - for disk_number in decrypted_deezer_list[albumpath]: - for track in decrypted_deezer_list[albumpath][disk_number].values(): - downloaded_track_list.append(track['path']) - downloaded_track_duration += int(track['DURATION']) - except: - downloaded_track_duration = False - - if not downloaded_track_duration or not db_track_duration or abs(downloaded_track_duration - db_track_duration) > 240: - logger.info("Looks like " + - os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + - " isn't complete yet (duration mismatch). Will try again on the next run") - return - - downloaded_track_list = list(set(downloaded_track_list)) # Remove duplicates - # test #1: metadata - usually works logger.debug('Verifying metadata...') diff --git a/headphones/searcher.py b/headphones/searcher.py index f6554cb1..b0652f7f 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -36,7 +36,6 @@ import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent -from headphones import deezloader, aria2 from bencode import bencode, bdecode # Magnet to torrent services, for Black hole. Stolen from CouchPotato. @@ -52,27 +51,6 @@ ruobj = None # Persistent RED API object redobj = None -# Persistent Aria2 RPC object -__aria2rpc_obj = None - - -def getAria2RPC(): - global __aria2rpc_obj - if not __aria2rpc_obj: - __aria2rpc_obj = aria2.Aria2JsonRpc( - ID='headphones', - uri=headphones.CONFIG.ARIA_HOST, - token=headphones.CONFIG.ARIA_TOKEN if headphones.CONFIG.ARIA_TOKEN else None, - http_user=headphones.CONFIG.ARIA_USERNAME if headphones.CONFIG.ARIA_USERNAME else None, - http_passwd=headphones.CONFIG.ARIA_PASSWORD if headphones.CONFIG.ARIA_PASSWORD else None - ) - return __aria2rpc_obj - - -def reconfigure(): - global __aria2rpc_obj - __aria2rpc_obj = None - def fix_url(s, charset="utf-8"): """ @@ -303,53 +281,32 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.STRIKE or headphones.CONFIG.TQUATTRECENTONZE) - DDL_PROVIDERS = (headphones.CONFIG.DEEZLOADER) - + results = [] myDB = db.DBConnection() albumlength = myDB.select('SELECT sum(TrackDuration) from tracks WHERE AlbumID=?', [album['AlbumID']])[0][0] - nzb_results = None - torrent_results = None - ddl_results = None - if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download: if NZB_PROVIDERS and NZB_DOWNLOADERS: - nzb_results = searchNZB(album, new, losslessOnly, albumlength) + results = searchNZB(album, new, losslessOnly, albumlength) - if not nzb_results: - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength) - - if TORRENT_PROVIDERS: - torrent_results = searchTorrent(album, new, losslessOnly, albumlength) + if not results and TORRENT_PROVIDERS: + results = searchTorrent(album, new, losslessOnly, albumlength) elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download: if TORRENT_PROVIDERS: - torrent_results = searchTorrent(album, new, losslessOnly, albumlength) + results = searchTorrent(album, new, losslessOnly, albumlength) - if not torrent_results: - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength) - - if NZB_PROVIDERS and NZB_DOWNLOADERS: - nzb_results = searchNZB(album, new, losslessOnly, albumlength) - - elif headphones.CONFIG.PREFER_TORRENTS == 3 and not choose_specific_download: - - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength) - - if not ddl_results: - if TORRENT_PROVIDERS: - torrent_results = searchTorrent(album, new, losslessOnly, albumlength) - - if NZB_PROVIDERS and NZB_DOWNLOADERS: - nzb_results = searchNZB(album, new, losslessOnly, albumlength) + if not results and NZB_PROVIDERS and NZB_DOWNLOADERS: + results = searchNZB(album, new, losslessOnly, albumlength) else: + + nzb_results = None + torrent_results = None + if NZB_PROVIDERS and NZB_DOWNLOADERS: nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download) @@ -357,19 +314,13 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): torrent_results = searchTorrent(album, new, losslessOnly, albumlength, choose_specific_download) - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength, choose_specific_download) + if not nzb_results: + nzb_results = [] - if not nzb_results: - nzb_results = [] + if not torrent_results: + torrent_results = [] - if not torrent_results: - torrent_results = [] - - if not ddl_results: - ddl_results = [] - - results = nzb_results + torrent_results + ddl_results + results = nzb_results + torrent_results if choose_specific_download: return results @@ -875,31 +826,6 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error('Couldn\'t write NZB file: %s', e) return - - elif kind == 'ddl': - folder_name = '%s - %s [%s]' % ( - helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), - helpers.latinToAscii(album['AlbumTitle']).encode('UTF-8').replace('/', '_'), - get_year_from_release_date(album['ReleaseDate'])) - - # Aria2 downloader - if headphones.CONFIG.DDL_DOWNLOADER == 0: - logger.info("Sending download to Aria2") - - try: - deezer_album = deezloader.getAlbumByLink(bestqual[2]) - - for album_track in deezer_album['tracks']['data']: - track = deezloader.getTrack(album_track['id']) - if track: - filename = track['SNG_ID'] + '.dzr' - logger.debug(u'Sending song "%s" to Aria' % track['SNG_TITLE']) - getAria2RPC().addUri([track['downloadUrl']], {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', 'dir': folder_name}) - - except Exception as e: - logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) - return - else: folder_name = '%s - %s [%s]' % ( helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), @@ -1278,62 +1204,6 @@ def verifyresult(title, artistterm, term, lossless): return True -def searchDdl(album, new=False, losslessOnly=False, albumlength=None, - choose_specific_download=False): - reldate = album['ReleaseDate'] - year = get_year_from_release_date(reldate) - - # MERGE THIS WITH THE TERM CLEANUP FROM searchNZB - dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ', '"': '', ',': ' ', - '*': ''} - - semi_cleanalbum = helpers.replace_all(album['AlbumTitle'], dic) - cleanalbum = helpers.latinToAscii(semi_cleanalbum) - semi_cleanartist = helpers.replace_all(album['ArtistName'], dic) - cleanartist = helpers.latinToAscii(semi_cleanartist) - - # Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot - # of these torrent providers are just using cleanartist/cleanalbum terms - if album['SearchTerm']: - term = album['SearchTerm'] - elif album['Type'] == 'part of': - term = cleanalbum + " " + year - else: - # FLAC usually doesn't have a year for some reason so I'll leave it out - # Various Artist albums might be listed as VA, so I'll leave that out too - # Only use the year if the term could return a bunch of different albums, i.e. self-titled albums - if album['ArtistName'] in album['AlbumTitle'] or len(album['ArtistName']) < 4 or len( - album['AlbumTitle']) < 4: - term = cleanartist + ' ' + cleanalbum + ' ' + year - elif album['ArtistName'] == 'Various Artists': - term = cleanalbum + ' ' + year - else: - term = cleanartist + ' ' + cleanalbum - - # Replace bad characters in the term and unicode it - term = re.sub('[\.\-\/]', ' ', term).encode('utf-8') - artistterm = re.sub('[\.\-\/]', ' ', cleanartist).encode('utf-8', 'replace') - - logger.debug(u'Using search term: "%s"' % helpers.latinToAscii(term)) - - resultlist = [] - - # Deezer only provides lossy - if headphones.CONFIG.DEEZLOADER and not losslessOnly: - resultlist += deezloader.searchAlbum(album['ArtistName'], album['AlbumTitle'], album['SearchTerm'], int(albumlength / 1000)) - - # attempt to verify that this isn't a substring result - # when looking for "Foo - Foo" we don't want "Foobar" - # this should be less of an issue when it isn't a self-titled album so we'll only check vs artist - results = [result for result in resultlist if verifyresult(result[0], artistterm, term, losslessOnly)] - - # Additional filtering for size etc - if results and not choose_specific_download: - results = more_filtering(results, album, albumlength, new) - - return results - - def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): global apolloobj # persistent apollo.rip api object to reduce number of login attempts @@ -2166,10 +2036,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, def preprocess(resultlist): for result in resultlist: - if result[3] == deezloader.PROVIDER_NAME: - return True, result - - elif result[4] == 'torrent': + if result[4] == 'torrent': # rutracker always needs the torrent data if result[3] == 'rutracker.org': diff --git a/headphones/webserve.py b/headphones/webserve.py index 7e97cefb..855fed24 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1174,10 +1174,6 @@ class WebInterface(object): "utorrent_username": headphones.CONFIG.UTORRENT_USERNAME, "utorrent_password": headphones.CONFIG.UTORRENT_PASSWORD, "utorrent_label": headphones.CONFIG.UTORRENT_LABEL, - "aria_host": headphones.CONFIG.ARIA_HOST, - "aria_password": headphones.CONFIG.ARIA_PASSWORD, - "aria_token": headphones.CONFIG.ARIA_TOKEN, - "aria_username": headphones.CONFIG.ARIA_USERNAME, "nzb_downloader_sabnzbd": radio(headphones.CONFIG.NZB_DOWNLOADER, 0), "nzb_downloader_nzbget": radio(headphones.CONFIG.NZB_DOWNLOADER, 1), "nzb_downloader_blackhole": radio(headphones.CONFIG.NZB_DOWNLOADER, 2), @@ -1186,7 +1182,6 @@ class WebInterface(object): "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), - "ddl_downloader_aria": radio(headphones.CONFIG.DDL_DOWNLOADER, 0), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, @@ -1248,8 +1243,6 @@ class WebInterface(object): "use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE), "tquattrecentonze_user": headphones.CONFIG.TQUATTRECENTONZE_USER, "tquattrecentonze_password": headphones.CONFIG.TQUATTRECENTONZE_PASSWORD, - "download_ddl_dir": headphones.CONFIG.DOWNLOAD_DDL_DIR, - "use_deezloader": checked(headphones.CONFIG.DEEZLOADER), "pref_qual_0": radio(headphones.CONFIG.PREFERRED_QUALITY, 0), "pref_qual_1": radio(headphones.CONFIG.PREFERRED_QUALITY, 1), "pref_qual_2": radio(headphones.CONFIG.PREFERRED_QUALITY, 2), @@ -1295,7 +1288,6 @@ class WebInterface(object): "prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0), "prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1), "prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2), - "prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3), "magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0), "magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1), "magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2), @@ -1454,7 +1446,7 @@ class WebInterface(object): "launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer", "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay", - "use_mininova", "use_waffles", "use_rutracker", "use_deezloader", + "use_mininova", "use_waffles", "use_rutracker", "use_apollo", "use_redacted", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", @@ -1588,9 +1580,6 @@ class WebInterface(object): # Reconfigure musicbrainz database connection with the new values mb.startmb() - # Reconfigure Aria2 - searcher.reconfigure() - raise cherrypy.HTTPRedirect("config") @cherrypy.expose From d6e1a16286b32fad91127e86b47469b60bf79ddf Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 22 Apr 2017 11:54:18 +1200 Subject: [PATCH 095/137] Pass full mb unicode names to beets Possibly fixes https://github.com/rembo10/headphones/issues/2919 --- headphones/postprocessor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index e3deee23..de68b9fa 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -955,10 +955,8 @@ def correctMetadata(albumid, release, downloaded_track_list): try: cur_artist, cur_album, prop = autotag.tag_album(items, - search_artist=helpers.latinToAscii( - release['ArtistName']), - search_album=helpers.latinToAscii( - release['AlbumTitle'])) + search_artist=release['ArtistName'], + search_album=release['AlbumTitle']) candidates = prop.candidates rec = prop.recommendation except Exception as e: From 1e3cbb64cafa5990a7ab76e16156ed45cbec7d79 Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 23 Apr 2017 20:08:28 +1200 Subject: [PATCH 096/137] beets recommendation logging --- headphones/helpers.py | 25 +++++++++++++++++++++++++ headphones/postprocessor.py | 19 ++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index 4c815ad7..10f3cbff 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -23,6 +23,10 @@ import sys import tempfile import glob +from beets import logging as beetslogging +import six +from contextlib import contextmanager + import fnmatch import re import os @@ -975,3 +979,24 @@ def create_https_certificates(ssl_cert, ssl_key): return False return True + + +class BeetsLogCapture(beetslogging.Handler): + + def __init__(self): + beetslogging.Handler.__init__(self) + self.messages = [] + + def emit(self, record): + self.messages.append(six.text_type(record.msg)) + + +@contextmanager +def capture_beets_log(logger='beets'): + capture = BeetsLogCapture() + log = beetslogging.getLogger(logger) + log.addHandler(capture) + try: + yield capture.messages + finally: + log.removeHandler(capture) \ No newline at end of file diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index de68b9fa..e8ffc602 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -24,6 +24,7 @@ import beets import headphones from beets import autotag from beets import config as beetsconfig +from beets import logging as beetslogging from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from beetsplug import lyrics as beetslyrics from headphones import notifiers, utorrent, transmission, deluge, qbittorrent @@ -954,11 +955,19 @@ def correctMetadata(albumid, release, downloaded_track_list): continue try: - cur_artist, cur_album, prop = autotag.tag_album(items, - search_artist=release['ArtistName'], - search_album=release['AlbumTitle']) - candidates = prop.candidates - rec = prop.recommendation + logger.debug('Getting recommendation from beets. Artist: %s. Album: %s. Tracks: %s', release['ArtistName'], + release['AlbumTitle'], len(items)) + beetslog = beetslogging.getLogger('beets') + beetslog.set_global_level(beetslogging.DEBUG) + with helpers.capture_beets_log() as logs: + cur_artist, cur_album, prop = autotag.tag_album(items, + search_artist=release['ArtistName'], + search_album=release['AlbumTitle']) + candidates = prop.candidates + rec = prop.recommendation + for log in logs: + logger.debug('Beets: %s', log) + beetslog.set_global_level(beetslogging.NOTSET) except Exception as e: logger.error('Error getting recommendation: %s. Not writing metadata', e) return False From 18d85d12dd8a2a530d5c13f6387713690ddde6cf Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 23 Apr 2017 20:26:04 +1200 Subject: [PATCH 097/137] pep --- headphones/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index 10f3cbff..d9b491f7 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -999,4 +999,4 @@ def capture_beets_log(logger='beets'): try: yield capture.messages finally: - log.removeHandler(capture) \ No newline at end of file + log.removeHandler(capture) From 2df13ad8234b382a3f1dbe376795f66fbcd80741 Mon Sep 17 00:00:00 2001 From: Ade Date: Mon, 24 Apr 2017 14:16:08 +1200 Subject: [PATCH 098/137] bit more beets logging --- headphones/postprocessor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index e8ffc602..ab00ab1a 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -958,7 +958,8 @@ def correctMetadata(albumid, release, downloaded_track_list): logger.debug('Getting recommendation from beets. Artist: %s. Album: %s. Tracks: %s', release['ArtistName'], release['AlbumTitle'], len(items)) beetslog = beetslogging.getLogger('beets') - beetslog.set_global_level(beetslogging.DEBUG) + beetslog.set_global_level(beetslogging.DEBUG) if headphones.VERBOSE else beetslog.set_global_level( + beetslogging.CRITICAL) with helpers.capture_beets_log() as logs: cur_artist, cur_album, prop = autotag.tag_album(items, search_artist=release['ArtistName'], From 04e8767ea99e804ccb909d3293f8d6e2561d3726 Mon Sep 17 00:00:00 2001 From: Kallys Date: Mon, 24 Apr 2017 14:32:51 +0200 Subject: [PATCH 099/137] Integration of deezloader provider by ParadoxalManiak under CC BY-NC-SA 4.0 Integration of aria2 downloader by Xyne under GPLv2 I'm not responsible of any kind for the usage of this programs by other people. These integrations come with no warranty. Please refer to your local country laws and respect Deezer's terms of service. --- data/interfaces/default/config.html | 64 ++ headphones/aria2.py | 979 ++++++++++++++++++++++++++++ headphones/classes.py | 31 + headphones/config.py | 7 + headphones/deezloader.py | 441 +++++++++++++ headphones/postprocessor.py | 40 +- headphones/searcher.py | 167 ++++- headphones/webserve.py | 13 +- 8 files changed, 1722 insertions(+), 20 deletions(-) create mode 100644 headphones/aria2.py create mode 100644 headphones/deezloader.py diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 841df0d1..bd331f2c 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -304,6 +304,45 @@ + +
+ Direct Download + Aria2 +
+
+
+ + + usually http://localhost:6800/jsonrpc +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Full path where ddl client downloads your music, e.g. /Users/name/Downloads/music +
+
@@ -461,6 +500,7 @@ NZBs Torrents + Direct Download No Preference
@@ -573,6 +613,21 @@ + +
+ Direct Download + +
+
+ + +
+
+ +
@@ -2405,6 +2460,11 @@ $("#deluge_options").show(); } + if ($("#ddl_downloader_aria").is(":checked")) + { + $("#ddl_aria_options").show(); + } + $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) { @@ -2458,6 +2518,9 @@ { $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } + if ($("#ddl_downloader_aria").is(":checked")) + { + } }); $("#mirror").change(handleNewServerSelection); @@ -2560,6 +2623,7 @@ initConfigCheckbox("#enable_https"); initConfigCheckbox("#customauth"); initConfigCheckbox("#use_tquattrecentonze"); + initConfigCheckbox("#use_deezloader"); $('#twitterStep1').click(function () { diff --git a/headphones/aria2.py b/headphones/aria2.py new file mode 100644 index 00000000..06d8e4e0 --- /dev/null +++ b/headphones/aria2.py @@ -0,0 +1,979 @@ +# -*- coding: utf8 -*- +# Copyright (C) 2012-2016 Xyne +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# (version 2) as published by the Free Software Foundation. +# +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +from __future__ import with_statement +import base64 +import json +import math +import os +import ssl +import string +import time +import httplib +import urllib2 + +from headphones import logger + +# ################################ Constants ################################### + +DEFAULT_PORT = 6800 +SERVER_URI_FORMAT = '{}://{}:{:d}/jsonrpc' + +# Status values for unfinished downloads. +TEMPORARY_STATUS = ('active', 'waiting', 'paused') +# Status values for finished downloads. +FINAL_STATUS = ('complete', 'error') + +ARIA2_CONTROL_FILE_EXT = '.aria2' + +# ########################## Convenience Functions ############################# + + +def to_json_list(objs): + ''' + Wrap strings in lists. Other iterables are converted to lists directly. + ''' + if isinstance(objs, str): + return [objs] + elif not isinstance(objs, list): + return list(objs) + else: + return objs + + +def add_options_and_position(params, options=None, position=None): + ''' + Convenience method for adding options and position to parameters. + ''' + if options: + params.append(options) + if position: + if not isinstance(position, int): + try: + position = int(position) + except ValueError: + position = -1 + if position >= 0: + params.append(position) + return params + + +def get_status(response): + ''' + Process a status response. + ''' + if response: + try: + return response['status'] + except KeyError: + logger.error('no status returned from Aria2 RPC server') + return 'error' + else: + logger.error('no response from server') + return 'error' + + +def random_token(length, valid_chars=None): + ''' + Get a random secret token for the Aria2 RPC server. + + length: + The length of the token + + valid_chars: + A list or other ordered and indexable iterable of valid characters. If not + given of None, asciinumberic characters with some punctuation characters + will be used. + ''' + if not valid_chars: + valid_chars = string.ascii_letters + string.digits + '!@#$%^&*()-_=+' + number_of_chars = len(valid_chars) + bytes_to_read = math.ceil(math.log(number_of_chars) / math.log(0x100)) + max_value = 0x100**bytes_to_read + max_index = number_of_chars - 1 + token = '' + for _ in range(length): + value = int.from_bytes(os.urandom(bytes_to_read), byteorder='little') + index = round((value * max_index) / max_value) + token += valid_chars[index] + return token + +# ################ From python3-aur's ThreadedServers.common ################### + + +def format_bytes(size): + '''Format bytes for inferior humans.''' + if size < 0x400: + return '{:d} B'.format(size) + else: + size = float(size) / 0x400 + for prefix in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB'): + if size < 0x400: + return '{:0.02f} {}'.format(size, prefix) + else: + size /= 0x400 + return '{:0.02f} YiB'.format(size) + + +def format_seconds(s): + '''Format seconds for inferior humans.''' + string = '' + for base, char in ( + (60, 's'), + (60, 'm'), + (24, 'h') + ): + s, r = divmod(s, base) + if s == 0: + return '{:d}{}{}'.format(r, char, string) + elif r != 0: + string = '{:02d}{}{}'.format(r, char, string) + else: + return '{:d}d{}'.format(s, string) + +# ############################ Aria2JsonRpcError ############################### + + +class Aria2JsonRpcError(Exception): + def __init__(self, msg, connection_error=False): + super(self.__class__, self).__init__(self, msg) + self.connection_error = connection_error + +# ############################ Aria2JsonRpc Class ############################## + + +class Aria2JsonRpc(object): + ''' + Interface class for interacting with an Aria2 RPC server. + ''' + # TODO: certificate options, etc. + def __init__( + self, ID, uri, + mode='normal', + token=None, + http_user=None, http_passwd=None, + server_cert=None, client_cert=None, client_cert_password=None, + ssl_protocol=None, + setup_function=None + ): + ''' + ID: the ID to send to the RPC interface + + uri: the URI of the RPC interface + + mode: + normal - process requests immediately + batch - queue requests (run with "process_queue") + format - return RPC request objects + + token: + RPC method-level authorization token (set using `--rpc-secret`) + + http_user, http_password: + HTTP Basic authentication credentials (deprecated) + + server_cert: + server certificate for HTTPS connections + + client_cert: + client certificate for HTTPS connections + + client_cert_password: + prompt for client certificate password + + ssl_protocol: + SSL protocol from the ssl module + + setup_function: + A function to invoke prior to the first server call. This could be the + launch() method of an Aria2RpcServer instance, for example. This attribute + is set automatically in instances returned from Aria2RpcServer.get_a2jr() + ''' + self.id = ID + self.uri = uri + self.mode = mode + self.queue = [] + self.handlers = dict() + self.token = token + self.setup_function = setup_function + + if None not in (http_user, http_passwd): + self.add_HTTPBasicAuthHandler(http_user, http_passwd) + + if server_cert or client_cert: + self.add_HTTPSHandler( + server_cert=server_cert, + client_cert=client_cert, + client_cert_password=client_cert_password, + protocol=ssl_protocol + ) + + self.update_opener() + + def iter_handlers(self): + ''' + Iterate over handlers. + ''' + for name in ('HTTPS', 'HTTPBasicAuth'): + try: + yield self.handlers[name] + except KeyError: + pass + + def update_opener(self): + ''' + Build an opener from the current handlers. + ''' + self.opener = urllib2.build_opener(*self.iter_handlers()) + + def remove_handler(self, name): + ''' + Remove a handler. + ''' + try: + del self.handlers[name] + except KeyError: + pass + + def add_HTTPBasicAuthHandler(self, user, passwd): + ''' + Add a handler for HTTP Basic authentication. + + If either user or passwd are None, the handler is removed. + ''' + handler = urllib2.HTTPBasicAuthHandler() + handler.add_password( + realm='aria2', + uri=self.uri, + user=user, + passwd=passwd, + ) + self.handlers['HTTPBasicAuth'] = handler + + def remove_HTTPBasicAuthHandler(self): + self.remove_handler('HTTPBasicAuth') + + def add_HTTPSHandler( + self, + server_cert=None, + client_cert=None, + client_cert_password=None, + protocol=None, + ): + ''' + Add a handler for HTTPS connections with optional server and client + certificates. + ''' + if not protocol: + protocol = ssl.PROTOCOL_TLSv1 +# protocol = ssl.PROTOCOL_TLSv1_1 # openssl 1.0.1+ +# protocol = ssl.PROTOCOL_TLSv1_2 # Python 3.4+ + context = ssl.SSLContext(protocol) + + if server_cert: + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(cafile=server_cert) + else: + context.verify_mode = ssl.CERT_OPTIONAL + + if client_cert: + context.load_cert_chain(client_cert, password=client_cert_password) + + self.handlers['HTTPS'] = urllib2.HTTPSHandler( + context=context, + check_hostname=False + ) + + def remove_HTTPSHandler(self): + self.remove_handler('HTTPS') + + def send_request(self, req_obj): + ''' + Send the request and return the response. + ''' + if self.setup_function: + self.setup_function() + self.setup_function = None + logger.debug("Aria req_obj: %s" % json.dumps(req_obj, indent=2, sort_keys=True)) + req = json.dumps(req_obj).encode('UTF-8') + try: + f = self.opener.open(self.uri, req) + obj = json.loads(f.read()) + try: + return obj['result'] + except KeyError: + raise Aria2JsonRpcError('unexpected result: {}'.format(obj)) + except (urllib2.URLError) as e: + # This should work but URLError does not set the errno attribute: + # e.errno == errno.ECONNREFUSED + raise Aria2JsonRpcError( + str(e), + connection_error=( + '111' in str(e) + ) + ) + except httplib.BadStatusLine as e: + raise Aria2JsonRpcError('{}: BadStatusLine: {} (HTTPS error?)'.format( + self.__class__.__name__, e + )) + + def jsonrpc(self, method, params=None, prefix='aria2.'): + ''' + POST a request to the RPC interface. + ''' + if not params: + params = [] + + if self.token is not None: + token_str = 'token:{}'.format(self.token) + if method == 'multicall': + for p in params[0]: + try: + p['params'].insert(0, token_str) + except KeyError: + p['params'] = [token_str] + else: + params.insert(0, token_str) + + req_obj = { + 'jsonrpc': '2.0', + 'id': self.id, + 'method': prefix + method, + 'params': params, + } + if self.mode == 'batch': + self.queue.append(req_obj) + return None + elif self.mode == 'format': + return req_obj + else: + return self.send_request(req_obj) + + def process_queue(self): + ''' + Processed queued requests. + ''' + req_obj = self.queue + self.queue = [] + return self.send_request(req_obj) + + +# ############################# Standard Methods ############################### + def addUri(self, uris, options=None, position=None): + ''' + aria2.addUri method + + uris: list of URIs + + options: dictionary of additional options + + position: position in queue + + Returns a GID + ''' + params = [uris] + params = add_options_and_position(params, options, position) + return self.jsonrpc('addUri', params) + + def addTorrent(self, torrent, uris=None, options=None, position=None): + ''' + aria2.addTorrent method + + torrent: base64-encoded torrent file + + uris: list of webseed URIs + + options: dictionary of additional options + + position: position in queue + + Returns a GID. + ''' + params = [torrent] + if uris: + params.append(uris) + params = add_options_and_position(params, options, position) + return self.jsonrpc('addTorrent', params) + + def addMetalink(self, metalink, options=None, position=None): + ''' + aria2.addMetalink method + + metalink: base64-encoded metalink file + + options: dictionary of additional options + + position: position in queue + + Returns an array of GIDs. + ''' + params = [metalink] + params = add_options_and_position(params, options, position) + return self.jsonrpc('addTorrent', params) + + def remove(self, gid): + ''' + aria2.remove method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('remove', params) + + def forceRemove(self, gid): + ''' + aria2.forceRemove method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('forceRemove', params) + + def pause(self, gid): + ''' + aria2.pause method + + gid: GID to pause + ''' + params = [gid] + return self.jsonrpc('pause', params) + + def pauseAll(self): + ''' + aria2.pauseAll method + ''' + return self.jsonrpc('pauseAll') + + def forcePause(self, gid): + ''' + aria2.forcePause method + + gid: GID to pause + ''' + params = [gid] + return self.jsonrpc('forcePause', params) + + def forcePauseAll(self): + ''' + aria2.forcePauseAll method + ''' + return self.jsonrpc('forcePauseAll') + + def unpause(self, gid): + ''' + aria2.unpause method + + gid: GID to unpause + ''' + params = [gid] + return self.jsonrpc('unpause', params) + + def unpauseAll(self): + ''' + aria2.unpauseAll method + ''' + return self.jsonrpc('unpauseAll') + + def tellStatus(self, gid, keys=None): + ''' + aria2.tellStatus method + + gid: GID to query + + keys: subset of status keys to return (all keys are returned otherwise) + + Returns a dictionary. + ''' + params = [gid] + if keys: + params.append(keys) + return self.jsonrpc('tellStatus', params) + + def getUris(self, gid): + ''' + aria2.getUris method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getUris', params) + + def getFiles(self, gid): + ''' + aria2.getFiles method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getFiles', params) + + def getPeers(self, gid): + ''' + aria2.getPeers method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getPeers', params) + + def getServers(self, gid): + ''' + aria2.getServers method + + gid: GID to query + + Returns a list of dictionaries. + ''' + params = [gid] + return self.jsonrpc('getServers', params) + + def tellActive(self, keys=None): + ''' + aria2.tellActive method + + keys: same as tellStatus + + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + if keys: + params = [keys] + else: + params = None + return self.jsonrpc('tellActive', params) + + def tellWaiting(self, offset, num, keys=None): + ''' + aria2.tellWaiting method + + offset: offset from start of waiting download queue + (negative values are counted from the end of the queue) + + num: number of downloads to return + + keys: same as tellStatus + + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + params = [offset, num] + if keys: + params.append(keys) + return self.jsonrpc('tellWaiting', params) + + def tellStopped(self, offset, num, keys=None): + ''' + aria2.tellStopped method + + offset: offset from oldest download (same semantics as tellWaiting) + + num: same as tellWaiting + + keys: same as tellStatus + + Returns a list of dictionaries. The dictionaries are the same as those + returned by tellStatus. + ''' + params = [offset, num] + if keys: + params.append(keys) + return self.jsonrpc('tellStopped', params) + + def changePosition(self, gid, pos, how): + ''' + aria2.changePosition method + + gid: GID to change + + pos: the position + + how: "POS_SET", "POS_CUR" or "POS_END" + ''' + params = [gid, pos, how] + return self.jsonrpc('changePosition', params) + + def changeUri(self, gid, fileIndex, delUris, addUris, position=None): + ''' + aria2.changePosition method + + gid: GID to change + + fileIndex: file to affect (1-based) + + delUris: URIs to remove + + addUris: URIs to add + + position: where URIs are inserted, after URIs have been removed + ''' + params = [gid, fileIndex, delUris, addUris] + if position: + params.append(position) + return self.jsonrpc('changePosition', params) + + def getOption(self, gid): + ''' + aria2.getOption method + + gid: GID to query + + Returns a dictionary of options. + ''' + params = [gid] + return self.jsonrpc('getOption', params) + + def changeOption(self, gid, options): + ''' + aria2.changeOption method + + gid: GID to change + + options: dictionary of new options + (not all options can be changed for active downloads) + ''' + params = [gid, options] + return self.jsonrpc('changeOption', params) + + def getGlobalOption(self): + ''' + aria2.getGlobalOption method + + Returns a dictionary. + ''' + return self.jsonrpc('getGlobalOption') + + def changeGlobalOption(self, options): + ''' + aria2.changeGlobalOption method + + options: dictionary of new options + ''' + params = [options] + return self.jsonrpc('changeGlobalOption', params) + + def getGlobalStat(self): + ''' + aria2.getGlobalStat method + + Returns a dictionary. + ''' + return self.jsonrpc('getGlobalStat') + + def purgeDownloadResult(self): + ''' + aria2.purgeDownloadResult method + ''' + self.jsonrpc('purgeDownloadResult') + + def removeDownloadResult(self, gid): + ''' + aria2.removeDownloadResult method + + gid: GID to remove + ''' + params = [gid] + return self.jsonrpc('removeDownloadResult', params) + + def getVersion(self): + ''' + aria2.getVersion method + + Returns a dictionary. + ''' + return self.jsonrpc('getVersion') + + def getSessionInfo(self): + ''' + aria2.getSessionInfo method + + Returns a dictionary. + ''' + return self.jsonrpc('getSessionInfo') + + def shutdown(self): + ''' + aria2.shutdown method + ''' + return self.jsonrpc('shutdown') + + def forceShutdown(self): + ''' + aria2.forceShutdown method + ''' + return self.jsonrpc('forceShutdown') + + def multicall(self, methods): + ''' + aria2.multicall method + + methods: list of dictionaries (keys: methodName, params) + + The method names must be those used by Aria2c, e.g. "aria2.tellStatus". + ''' + return self.jsonrpc('multicall', [methods], prefix='system.') + + +# ########################### Convenience Methods ############################## + def add_torrent(self, path, uris=None, options=None, position=None): + ''' + A wrapper around addTorrent for loading files. + ''' + with open(path, 'r') as f: + torrent = base64.encode(f.read()) + return self.addTorrent(torrent, uris, options, position) + + def add_metalink(self, path, uris=None, options=None, position=None): + ''' + A wrapper around addMetalink for loading files. + ''' + with open(path, 'r') as f: + metalink = base64.encode(f.read()) + return self.addMetalink(metalink, uris, options, position) + + def get_status(self, gid): + ''' + Get the status of a single GID. + ''' + response = self.tellStatus(gid, ['status']) + return get_status(response) + + def wait_for_final_status(self, gid, interval=1): + ''' + Wait for a GID to complete or fail and return its status. + ''' + if not interval or interval < 0: + interval = 1 + while True: + status = self.get_status(gid) + if status in TEMPORARY_STATUS: + time.sleep(interval) + else: + return status + + def get_statuses(self, gids): + ''' + Get the status of multiple GIDs. The status of each is yielded in order. + ''' + methods = [ + { + 'methodName': 'aria2.tellStatus', + 'params': [gid, ['gid', 'status']] + } + for gid in gids + ] + results = self.multicall(methods) + if results: + status = dict((r[0]['gid'], r[0]['status']) for r in results) + for gid in gids: + try: + yield status[gid] + except KeyError: + logger.error('Aria2 RPC server returned no status for GID {}'.format(gid)) + yield 'error' + else: + logger.error('no response from Aria2 RPC server') + for gid in gids: + yield 'error' + + def wait_for_final_statuses(self, gids, interval=1): + ''' + Wait for multiple GIDs to complete or fail and return their statuses in + order. + + gids: + A flat list of GIDs. + ''' + if not interval or interval < 0: + interval = 1 + statusmap = dict((g, None) for g in gids) + remaining = list( + g for g, s in statusmap.items() if s is None + ) + while remaining: + for g, s in zip(remaining, self.get_statuses(remaining)): + if s in TEMPORARY_STATUS: + continue + else: + statusmap[g] = s + remaining = list( + g for g, s in statusmap.items() if s is None + ) + if remaining: + time.sleep(interval) + for g in gids: + yield statusmap[g] + + def print_global_status(self): + ''' + Print global status of the RPC server. + ''' + status = self.getGlobalStat() + if status: + numWaiting = int(status['numWaiting']) + numStopped = int(status['numStopped']) + keys = ['totalLength', 'completedLength'] + total = self.tellActive(keys) + waiting = self.tellWaiting(0, numWaiting, keys) + if waiting: + total += waiting + stopped = self.tellStopped(0, numStopped, keys) + if stopped: + total += stopped + + downloadSpeed = int(status['downloadSpeed']) + uploadSpeed = int(status['uploadSpeed']) + totalLength = sum(int(x['totalLength']) for x in total) + completedLength = sum(int(x['completedLength']) for x in total) + remaining = totalLength - completedLength + + status['downloadSpeed'] = format_bytes(downloadSpeed) + '/s' + status['uploadSpeed'] = format_bytes(uploadSpeed) + '/s' + + preordered = ('downloadSpeed', 'uploadSpeed') + + rows = list() + for k in sorted(status): + if k in preordered: + continue + rows.append((k, status[k])) + + rows.extend((x, status[x]) for x in preordered) + + if totalLength > 0: + rows.append(('total', format(format_bytes(totalLength)))) + rows.append(('completed', format(format_bytes(completedLength)))) + rows.append(('remaining', format(format_bytes(remaining)))) + if completedLength == totalLength: + eta = 'finished' + else: + try: + eta = format_seconds(remaining // downloadSpeed) + except ZeroDivisionError: + eta = 'never' + rows.append(('ETA', eta)) + + l = max(len(r[0]) for r in rows) + r = max(len(r[1]) for r in rows) + r = max(r, len(self.uri) - (l + 2)) + fmt = '{:<' + str(l) + 's} {:>' + str(r) + 's}' + + print(self.uri) + for k, v in rows: + print(fmt.format(k, v)) + + def queue_uris(self, uris, options, interval=None): + ''' + Enqueue URIs and wait for download to finish while printing status at + regular intervals. + ''' + gid = self.addUri(uris, options) + print('GID: {}'.format(gid)) + + if gid and interval is not None: + blanker = '' + while True: + response = self.tellStatus(gid, ['status']) + if response: + try: + status = response['status'] + except KeyError: + print('error: no status returned from Aria2 RPC server') + break + print('{}\rstatus: {}'.format(blanker, status)), + blanker = ' ' * len(status) + if status in TEMPORARY_STATUS: + time.sleep(interval) + else: + break + else: + print('error: no response from server') + break + + +# ####################### Polymethod download handlers ######################### + def polymethod_enqueue_many(self, downloads): + ''' + Enqueue downloads. + + downloads: Same as polymethod_download(). + ''' + methods = list( + { + 'methodName': 'aria2.{}'.format(d[0]), + 'params': list(d[1:]) + } for d in downloads + ) + return self.multicall(methods) + + def polymethod_wait_many(self, gids, interval=1): + ''' + Wait for the GIDs to complete or fail and return their statuses. + + gids: + A list of lists of GIDs. + ''' + # The flattened list of GIDs + gs = list(g for gs in gids for g in gs) + statusmap = dict(tuple(zip( + gs, + self.wait_for_final_statuses(gs, interval=interval) + ))) + for gs in gids: + yield list(statusmap.get(g, 'error') for g in gs) + + def polymethod_enqueue_one(self, download): + ''' + Same as polymethod_enqueue_many but for one element. + ''' + return getattr(self, download[0])(*download[1:]) + + def polymethod_download(self, downloads, interval=1): + ''' + Enqueue a series of downloads and wait for them to finish. Iterate over the + status of each, in order. + + downloads: + An iterable over (, , ...) where indicates the "add" + method to use ('addUri', 'addTorrent', 'addMetalink') and everything that + follows are arguments to pass to that method. + + interval: + The status check interval while waiting. + + Iterates over the download status of finished downloads. "complete" + indicates success. Lists of statuses will be returned for downloads that + create multiple GIDs (e.g. metalinks). + ''' + gids = self.polymethod_enqueue_many(downloads) + return self.polymethod_wait_many(gids, interval=interval) + + def polymethod_download_bool(self, *args, **kwargs): + ''' + A wrapper around polymethod_download() which returns a boolean for each + download to indicate success (True) or failure (False). + ''' +# for status in self.polymethod_download(*args, **kwargs): +# yield all(s == 'complete' for s in status) + return list( + all(s == 'complete' for s in status) + for status in self.polymethod_download(*args, **kwargs) + ) diff --git a/headphones/classes.py b/headphones/classes.py index 6015a0f2..54a10a55 100644 --- a/headphones/classes.py +++ b/headphones/classes.py @@ -21,6 +21,7 @@ import urllib from common import USER_AGENT +from collections import OrderedDict class HeadphonesURLopener(urllib.FancyURLopener): @@ -135,3 +136,33 @@ class Proper: def __str__(self): return str(self.date) + " " + self.name + " " + str(self.season) + "x" + str( self.episode) + " of " + str(self.tvdbid) + + +class OptionalImport(object): + ''' + Dummy class for optional import (imports needed for optional features). + ''' + def __init__(self, name): + self.__name = name + + def __getattr__(self, attr): + raise ImportError('The following package is required to use this feature: {0}'.format(self.__name)) + + +class CacheDict(OrderedDict): + ''' + Ordered dictionary with fixed size, designed for caching. + ''' + def __init__(self, *args, **kwds): + self.size_limit = kwds.pop("size_limit", None) + OrderedDict.__init__(self, *args, **kwds) + self._check_size_limit() + + def __setitem__(self, key, value): + OrderedDict.__setitem__(self, key, value) + self._check_size_limit() + + def _check_size_limit(self): + if self.size_limit is not None: + while len(self) > self.size_limit: + self.popitem(last=False) diff --git a/headphones/config.py b/headphones/config.py index 57af4c16..86aa9216 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -46,6 +46,10 @@ _CONFIG_DEFINITIONS = { 'APOLLO_RATIO': (str, 'Apollo.rip', ''), 'APOLLO_USERNAME': (str, 'Apollo.rip', ''), 'APOLLO_URL': (str, 'Apollo.rip', 'https://apollo.rip'), + 'ARIA_HOST': (str, 'Aria2', ''), + 'ARIA_PASSWORD': (str, 'Aria2', ''), + 'ARIA_TOKEN': (str, 'Aria2', ''), + 'ARIA_USERNAME': (str, 'Aria2', ''), 'AUTOWANT_ALL': (int, 'General', 0), 'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1), 'AUTOWANT_UPCOMING': (int, 'General', 1), @@ -73,6 +77,8 @@ _CONFIG_DEFINITIONS = { 'CUSTOMPORT': (int, 'General', 5000), 'CUSTOMSLEEP': (int, 'General', 1), 'CUSTOMUSER': (str, 'General', ''), + 'DDL_DOWNLOADER': (int, 'General', 0), + 'DEEZLOADER': (int, 'DeezLoader', 0), 'DELETE_LOSSLESS_FILES': (int, 'General', 1), 'DELUGE_HOST': (str, 'Deluge', ''), 'DELUGE_CERT': (str, 'Deluge', ''), @@ -86,6 +92,7 @@ _CONFIG_DEFINITIONS = { 'DOWNLOAD_DIR': (path, 'General', ''), 'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5), 'DOWNLOAD_TORRENT_DIR': (path, 'General', ''), + 'DOWNLOAD_DDL_DIR': (path, 'General', ''), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'EMAIL_ENABLED': (int, 'Email', 0), 'EMAIL_FROM': (str, 'Email', ''), diff --git a/headphones/deezloader.py b/headphones/deezloader.py new file mode 100644 index 00000000..8de85df9 --- /dev/null +++ b/headphones/deezloader.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +# Deezloader (c) 2016 by ParadoxalManiak +# +# Deezloader is licensed under a +# Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. +# +# You should have received a copy of the license along with this +# work. If not, see . +# +# Version 2.1.0 +# Maintained by ParadoxalManiak +# Original work by ZzMTV +# +# Author's disclaimer: +# I am not responsible for the usage of this program by other people. +# I do not recommend you doing this illegally or against Deezer's terms of service. +# This project is licensed under CC BY-NC-SA 4.0 + +import re +import os +from datetime import datetime +from hashlib import md5 +import binascii + +from beets.mediafile import MediaFile +from headphones import logger, request, helpers +from headphones.classes import OptionalImport, CacheDict +import headphones + + +# Try to import optional Crypto.Cipher packages +try: + from Crypto.Cipher import AES, Blowfish +except ImportError: + AES = OptionalImport('Crypto.Cipher.AES') + Blowfish = OptionalImport('Crypto.Cipher.Blowfish') + +# Public constants +PROVIDER_NAME = 'Deezer' + +# Internal constants +__API_URL = "http://www.deezer.com/ajax/gw-light.php" +__API_INFO_URL = "http://api.deezer.com/" +__HTTP_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36", + "Content-Language": "en-US", + "Cache-Control": "max-age=0", + "Accept": "*/*", + "Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3", + "Accept-Language": "de-DE,de;q=0.8,en-US;q=0.6,en;q=0.4" +} + +# Internal variables +__api_queries = { + 'api_version': "1.0", + 'api_token': "None", + 'input': "3" +} +__cookies = None +__tracks_cache = CacheDict(size_limit=512) +__albums_cache = CacheDict(size_limit=64) + + +def __getApiToken(): + global __cookies + response = request.request_response(url="http://www.deezer.com/", headers=__HTTP_HEADERS) + __cookies = response.cookies + data = response.content + if data: + matches = re.search(r"checkForm\s*=\s*['|\"](.*)[\"|'];", data) + if matches: + token = matches.group(1) + __api_queries['api_token'] = token + logger.debug(u"Deezloader : api token loaded ('%s')" % token) + + if not token: + logger.error(u"Deezloader: Unable to get api token") + + +def getAlbumByLink(album_link): + """Returns deezer album infos using album link url + + :param str album_link: deezer album link url (eg: 'http://www.deezer.com/album/1234567/') + """ + matches = re.search(r"album\/([0-9]+)\/?$", album_link) + if matches: + return getAlbum(matches.group(1)) + + +def getAlbum(album_id): + """Returns deezer album infos + + :param int album_id: deezer album id + """ + global __albums_cache + + if str(album_id) in __albums_cache: + return __albums_cache[str(album_id)] + + url = __API_INFO_URL + "album/" + str(album_id) + data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) + + if data and 'error' not in data: + __albums_cache[str(album_id)] = data + return data + else: + logger.debug("Deezloader: Can't load album infos") + return None + + +def searchAlbums(search_term): + """Search for deezer albums using search term + + :param str search_term: search term to search album for + """ + logger.info(u'Searching Deezer using term: "%s"' % search_term) + + url = __API_INFO_URL + "search/album?q=" + search_term + data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) + + albums = [] + + # Process content + if data and 'total' in data and data['total'] > 0 and 'data' in data: + for item in data['data']: + try: + albums.append(getAlbum(item['id'])) + except Exception as e: + logger.error(u"An unknown error occurred in the Deezer search album parser: %s" % e) + else: + logger.info(u'No results found from Deezer using term: "%s"' % search_term) + + return albums + + +def __matchAlbums(albums, artist_name, album_title, album_length): + resultlist = [] + + for album in albums: + total_size = 0 + tracks_found = 0 + + for track in album['tracks']['data']: + t = getTrack(track['id']) + if t: + if t["FILESIZE_MP3_320"] > 0: + size = t["FILESIZE_MP3_320"] + elif t["FILESIZE_MP3_256"] > 0: + size = t["FILESIZE_MP3_256"] + elif t["FILESIZE_MP3_128"] > 0: + size = t["FILESIZE_MP3_128"] + else: + size = t["FILESIZE_MP3_64"] + + size = int(size) + total_size += size + tracks_found += 1 + logger.debug(u'Found song "%s". Size: %s' % (t['SNG_TITLE'], helpers.bytes_to_mb(size))) + + if tracks_found == 0: + logger.info(u'Ignoring album "%s" (no tracks to download)' % album['title']) + continue + + matched = True + mismatch_reason = 'matched!' + + if album_length > 0 and abs(int(album['duration']) - album_length) > 240: + matched = False + mismatch_reason = (u'duration mismatch: %i not in [%i, %i]' % (int(album['duration']), (album_length - 240), (album_length + 240))) + + elif (helpers.latinToAscii(re.sub(r"\W", "", album_title, 0, re.UNICODE)).lower() != + helpers.latinToAscii(re.sub(r"\W", "", album['title'], 0, re.UNICODE)).lower()): + matched = False + mismatch_reason = (u'album name mismatch: %s != %s' % (album['title'], album_title)) + + elif (helpers.latinToAscii(re.sub(r"\W", "", artist_name, 0, re.UNICODE)).lower() != + helpers.latinToAscii(re.sub(r"\W", "", album['artist']['name'], 0, re.UNICODE)).lower()): + matched = False + mismatch_reason = (u'artist name mismatch: %s != %s' % (album['artist']['name'], artist_name)) + + resultlist.append( + (album['artist']['name'] + ' - ' + album['title'] + ' [' + album['release_date'][:4] + '] (' + str(tracks_found) + '/' + str(album['nb_tracks']) + ')', + total_size, album['link'], PROVIDER_NAME, "ddl", matched) + ) + logger.info(u'Found "%s". Tracks %i/%i. Size: %s (%s)' % (album['title'], tracks_found, album['nb_tracks'], helpers.bytes_to_mb(total_size), mismatch_reason)) + + return resultlist + + +def searchAlbum(artist_name, album_title, user_search_term=None, album_length=None): + """Search for deezer specific album. + This will iterate over deezer albums and try to find best matches + + :param str artist_name: album artist name + :param str album_title: album title + :param str user_search_term: search terms provided by user + :param int album_length: targeted album duration in seconds + """ + # User search term by-pass normal search + if user_search_term: + return __matchAlbums(searchAlbums(user_search_term), artist_name, album_title, album_length) + + resultlist = __matchAlbums(searchAlbums((artist_name + ' ' + album_title).strip()), artist_name, album_title, album_length) + if resultlist: + return resultlist + + # Deezer API supports unicode, so just remove non alphanumeric characters + clean_artist_name = re.sub(r"[^\w\s]", " ", artist_name, 0, re.UNICODE).strip() + clean_album_name = re.sub(r"[^\w\s]", " ", album_title, 0, re.UNICODE).strip() + + resultlist = __matchAlbums(searchAlbums((clean_artist_name + ' ' + clean_album_name).strip()), artist_name, album_title, album_length) + if resultlist: + return resultlist + + resultlist = __matchAlbums(searchAlbums(clean_artist_name), artist_name, album_title, album_length) + if resultlist: + return resultlist + + return resultlist + + +def getTrack(sng_id, try_reload_api=True): + """Returns deezer track infos + + :param int sng_id: deezer song id + :param bool try_reload_api: whether or not try reloading API if session expired + """ + global __tracks_cache + + if str(sng_id) in __tracks_cache: + return __tracks_cache[str(sng_id)] + + data = "[{\"method\":\"song.getListData\",\"params\":{\"sng_ids\":[" + str(sng_id) + "]}}]" + json = request.request_json(url=__API_URL, headers=__HTTP_HEADERS, method='post', params=__api_queries, data=data, cookies=__cookies) + + results = [] + error = None + invalid_token = False + + if json: + # Check for errors + if 'error' in json: + error = json['error'] + if 'GATEWAY_ERROR' in json['error'] and json['error']['GATEWAY_ERROR'] == u"invalid api token": + invalid_token = True + + elif 'error' in json[0] and json[0]['error']: + error = json[0]['error'] + if 'VALID_TOKEN_REQUIRED' in json[0]['error'] and json[0]['error']['VALID_TOKEN_REQUIRED'] == u"Invalid CSRF token": + invalid_token = True + + # Got invalid token error + if error: + if invalid_token and try_reload_api: + __getApiToken() + return getTrack(sng_id, False) + else: + logger.error(u"An unknown error occurred in the Deezer track parser: %s" % error) + else: + try: + results = json[0]['results'] + item = results['data'][0] + if 'token' in item: + logger.error(u"An unknown error occurred in the Deezer parser: Uploaded Files are currently not supported") + return + + sng_id = item["SNG_ID"] + md5Origin = item["MD5_ORIGIN"] + sng_format = 3 + + if item["FILESIZE_MP3_320"] <= 0: + if item["FILESIZE_MP3_256"] > 0: + sng_format = 5 + else: + sng_format = 1 + + mediaVersion = int(item["MEDIA_VERSION"]) + item['downloadUrl'] = __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion) + + __tracks_cache[sng_id] = item + return item + + except Exception as e: + logger.error(u"An unknown error occurred in the Deezer track parser: %s" % e) + + +def __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion): + urlPart = md5Origin.encode('utf-8') + b'\xa4' + str(sng_format) + b'\xa4' + str(sng_id) + b'\xa4' + str(mediaVersion) + md5val = md5(urlPart).hexdigest() + urlPart = md5val + b'\xa4' + urlPart + b'\xa4' + cipher = AES.new('jo6aey6haid2Teih', AES.MODE_ECB) + ciphertext = cipher.encrypt(__pad(urlPart, AES.block_size)) + return "http://e-cdn-proxy-" + md5Origin[:1] + ".deezer.com/mobile/1/" + binascii.hexlify(ciphertext).lower() + + +def __pad(raw, block_size): + if (len(raw) % block_size == 0): + return raw + padding_required = block_size - (len(raw) % block_size) + padChar = b'\x00' + data = raw + padding_required * padChar + return data + + +def __tagTrack(path, track): + try: + album = getAlbum(track['ALB_ID']) + + f = MediaFile(path) + f.artist = track['ART_NAME'] + f.album = track['ALB_TITLE'] + f.title = track['SNG_TITLE'] + f.track = track['TRACK_NUMBER'] + f.tracktotal = album['nb_tracks'] + f.disc = track['DISK_NUMBER'] + f.bpm = track['BPM'] + f.date = datetime.strptime(album['release_date'], '%Y-%m-%d').date() + f.albumartist = album['artist']['name'] + if u'genres' in album and u'data' in album['genres']: + f.genres = [genre['name'] for genre in album['genres']['data']] + + f.save() + + except Exception as e: + logger.error(u'Unable to tag deezer track "%s": %s' % (path, e)) + + +def decryptTracks(paths): + """Decrypt downloaded deezer tracks. + + :param paths: list of path to deezer tracks (*.dzr files). + """ + # Note: tracks can be from different albums + decrypted_tracks = {} + + # First pass: load tracks data + for path in paths: + try: + album_folder = os.path.dirname(path) + sng_id = os.path.splitext(os.path.basename(path))[0] + track = getTrack(sng_id) + if track: + track_number = int(track['TRACK_NUMBER']) + disk_number = int(track['DISK_NUMBER']) + + if album_folder not in decrypted_tracks: + decrypted_tracks[album_folder] = {} + + if disk_number not in decrypted_tracks[album_folder]: + decrypted_tracks[album_folder][disk_number] = {} + + decrypted_tracks[album_folder][disk_number][track_number] = track + + except Exception as e: + logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) + + # Second pass: decrypt tracks + for album_folder in decrypted_tracks: + multi_disks = len(decrypted_tracks[album_folder]) > 1 + for disk_number in decrypted_tracks[album_folder]: + for track_number, track in decrypted_tracks[album_folder][disk_number].items(): + try: + filename = helpers.replace_illegal_chars(track['SNG_TITLE']).strip() + filename = ('{:02d}'.format(track_number) + '_' + filename + '.mp3') + + # Add a 'cd x' sub-folder if album has more than one disk + disk_folder = os.path.join(album_folder, 'cd ' + str(disk_number)) if multi_disks else album_folder + + dest = os.path.join(disk_folder, filename).encode(headphones.SYS_ENCODING, 'replace') + + # Decrypt track if not already done + if not os.path.exists(dest): + try: + __decryptDownload(path, sng_id, dest) + __tagTrack(dest, track) + except Exception as e: + logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) + if os.path.exists(dest): + os.remove(dest) + decrypted_tracks[album_folder][disk_number].pop(track_number) + continue + + decrypted_tracks[album_folder][disk_number][track_number]['path'] = dest + + except Exception as e: + logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) + + return decrypted_tracks + + +def __decryptDownload(source, sng_id, dest): + interval_chunk = 3 + chunk_size = 2048 + blowFishKey = __getBlowFishKey(sng_id) + i = 0 + iv = "\x00\x01\x02\x03\x04\x05\x06\x07" + + dest_folder = os.path.dirname(dest) + if not os.path.exists(dest_folder): + os.makedirs(dest_folder) + + f = open(source, "rb") + fout = open(dest, "wb") + try: + chunk = f.read(chunk_size) + while chunk: + if(i % interval_chunk == 0): + cipher = Blowfish.new(blowFishKey, Blowfish.MODE_CBC, iv) + chunk = cipher.decrypt(__pad(chunk, Blowfish.block_size)) + + fout.write(chunk) + i += 1 + chunk = f.read(chunk_size) + finally: + f.close() + fout.close() + + +def __getBlowFishKey(encryptionKey): + if encryptionKey < 1: + encryptionKey *= -1 + + hashcode = md5(str(encryptionKey)).hexdigest() + hPart = hashcode[0:16] + lPart = hashcode[16:32] + parts = ['g4el58wc0zvf9na1', hPart, lPart] + + return __xorHex(parts) + + +def __xorHex(parts): + data = "" + for i in range(0, 16): + character = ord(parts[0][i]) + + for j in range(1, len(parts)): + character ^= ord(parts[j][i]) + + data += chr(character) + + return data diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index ab00ab1a..0b574cd2 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -30,7 +30,7 @@ from beetsplug import lyrics as beetslyrics from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync from headphones import logger, helpers, mb, music_encoder -from headphones import metadata +from headphones import metadata, deezloader postprocessor_lock = threading.Lock() @@ -48,6 +48,8 @@ def checkFolder(): single = False if album['Kind'] == 'nzb': download_dir = headphones.CONFIG.DOWNLOAD_DIR + elif album['Kind'] == 'ddl': + download_dir = headphones.CONFIG.DOWNLOAD_DDL_DIR else: if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3: download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY @@ -205,6 +207,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid]) downloaded_track_list = [] + downloaded_deezer_list = [] downloaded_cuecount = 0 for r, d, f in os.walk(albumpath): @@ -213,8 +216,10 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list.append(os.path.join(r, files)) elif files.lower().endswith('.cue'): downloaded_cuecount += 1 + elif files.lower().endswith('.dzr'): + downloaded_deezer_list.append(os.path.join(r, files)) # 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 files.lower().endswith(('.part', '.utpart', '.aria2')) and not forced: logger.info( "Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + " isn't complete yet. Will try again on the next run") @@ -253,6 +258,37 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list = helpers.get_downloaded_track_list(albumpath) keep_original_folder = False + # Decrypt deezer tracks + if downloaded_deezer_list: + logger.info('Decrypting deezer tracks') + decrypted_deezer_list = deezloader.decryptTracks(downloaded_deezer_list) + + # Check if album is complete based on album duration only + # (total track numbers is not determinant enough due to hidden tracks for eg) + db_track_duration = 0 + downloaded_track_duration = 0 + try: + for track in tracks: + db_track_duration += track['TrackDuration'] / 1000 + except: + downloaded_track_duration = False + + try: + for disk_number in decrypted_deezer_list[albumpath]: + for track in decrypted_deezer_list[albumpath][disk_number].values(): + downloaded_track_list.append(track['path']) + downloaded_track_duration += int(track['DURATION']) + except: + downloaded_track_duration = False + + if not downloaded_track_duration or not db_track_duration or abs(downloaded_track_duration - db_track_duration) > 240: + logger.info("Looks like " + + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + + " isn't complete yet (duration mismatch). Will try again on the next run") + return + + downloaded_track_list = list(set(downloaded_track_list)) # Remove duplicates + # test #1: metadata - usually works logger.debug('Verifying metadata...') diff --git a/headphones/searcher.py b/headphones/searcher.py index b0652f7f..f6554cb1 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -36,6 +36,7 @@ import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent +from headphones import deezloader, aria2 from bencode import bencode, bdecode # Magnet to torrent services, for Black hole. Stolen from CouchPotato. @@ -51,6 +52,27 @@ ruobj = None # Persistent RED API object redobj = None +# Persistent Aria2 RPC object +__aria2rpc_obj = None + + +def getAria2RPC(): + global __aria2rpc_obj + if not __aria2rpc_obj: + __aria2rpc_obj = aria2.Aria2JsonRpc( + ID='headphones', + uri=headphones.CONFIG.ARIA_HOST, + token=headphones.CONFIG.ARIA_TOKEN if headphones.CONFIG.ARIA_TOKEN else None, + http_user=headphones.CONFIG.ARIA_USERNAME if headphones.CONFIG.ARIA_USERNAME else None, + http_passwd=headphones.CONFIG.ARIA_PASSWORD if headphones.CONFIG.ARIA_PASSWORD else None + ) + return __aria2rpc_obj + + +def reconfigure(): + global __aria2rpc_obj + __aria2rpc_obj = None + def fix_url(s, charset="utf-8"): """ @@ -281,32 +303,53 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.STRIKE or headphones.CONFIG.TQUATTRECENTONZE) - results = [] + DDL_PROVIDERS = (headphones.CONFIG.DEEZLOADER) + myDB = db.DBConnection() albumlength = myDB.select('SELECT sum(TrackDuration) from tracks WHERE AlbumID=?', [album['AlbumID']])[0][0] + nzb_results = None + torrent_results = None + ddl_results = None + if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download: if NZB_PROVIDERS and NZB_DOWNLOADERS: - results = searchNZB(album, new, losslessOnly, albumlength) + nzb_results = searchNZB(album, new, losslessOnly, albumlength) - if not results and TORRENT_PROVIDERS: - results = searchTorrent(album, new, losslessOnly, albumlength) + if not nzb_results: + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength) + + if TORRENT_PROVIDERS: + torrent_results = searchTorrent(album, new, losslessOnly, albumlength) elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download: if TORRENT_PROVIDERS: - results = searchTorrent(album, new, losslessOnly, albumlength) + torrent_results = searchTorrent(album, new, losslessOnly, albumlength) - if not results and NZB_PROVIDERS and NZB_DOWNLOADERS: - results = searchNZB(album, new, losslessOnly, albumlength) + if not torrent_results: + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength) + + if NZB_PROVIDERS and NZB_DOWNLOADERS: + nzb_results = searchNZB(album, new, losslessOnly, albumlength) + + elif headphones.CONFIG.PREFER_TORRENTS == 3 and not choose_specific_download: + + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength) + + if not ddl_results: + if TORRENT_PROVIDERS: + torrent_results = searchTorrent(album, new, losslessOnly, albumlength) + + if NZB_PROVIDERS and NZB_DOWNLOADERS: + nzb_results = searchNZB(album, new, losslessOnly, albumlength) else: - - nzb_results = None - torrent_results = None - if NZB_PROVIDERS and NZB_DOWNLOADERS: nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download) @@ -314,13 +357,19 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): torrent_results = searchTorrent(album, new, losslessOnly, albumlength, choose_specific_download) - if not nzb_results: - nzb_results = [] + if DDL_PROVIDERS: + ddl_results = searchDdl(album, new, losslessOnly, albumlength, choose_specific_download) - if not torrent_results: - torrent_results = [] + if not nzb_results: + nzb_results = [] - results = nzb_results + torrent_results + if not torrent_results: + torrent_results = [] + + if not ddl_results: + ddl_results = [] + + results = nzb_results + torrent_results + ddl_results if choose_specific_download: return results @@ -826,6 +875,31 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error('Couldn\'t write NZB file: %s', e) return + + elif kind == 'ddl': + folder_name = '%s - %s [%s]' % ( + helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), + helpers.latinToAscii(album['AlbumTitle']).encode('UTF-8').replace('/', '_'), + get_year_from_release_date(album['ReleaseDate'])) + + # Aria2 downloader + if headphones.CONFIG.DDL_DOWNLOADER == 0: + logger.info("Sending download to Aria2") + + try: + deezer_album = deezloader.getAlbumByLink(bestqual[2]) + + for album_track in deezer_album['tracks']['data']: + track = deezloader.getTrack(album_track['id']) + if track: + filename = track['SNG_ID'] + '.dzr' + logger.debug(u'Sending song "%s" to Aria' % track['SNG_TITLE']) + getAria2RPC().addUri([track['downloadUrl']], {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', 'dir': folder_name}) + + except Exception as e: + logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) + return + else: folder_name = '%s - %s [%s]' % ( helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), @@ -1204,6 +1278,62 @@ def verifyresult(title, artistterm, term, lossless): return True +def searchDdl(album, new=False, losslessOnly=False, albumlength=None, + choose_specific_download=False): + reldate = album['ReleaseDate'] + year = get_year_from_release_date(reldate) + + # MERGE THIS WITH THE TERM CLEANUP FROM searchNZB + dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ', '"': '', ',': ' ', + '*': ''} + + semi_cleanalbum = helpers.replace_all(album['AlbumTitle'], dic) + cleanalbum = helpers.latinToAscii(semi_cleanalbum) + semi_cleanartist = helpers.replace_all(album['ArtistName'], dic) + cleanartist = helpers.latinToAscii(semi_cleanartist) + + # Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot + # of these torrent providers are just using cleanartist/cleanalbum terms + if album['SearchTerm']: + term = album['SearchTerm'] + elif album['Type'] == 'part of': + term = cleanalbum + " " + year + else: + # FLAC usually doesn't have a year for some reason so I'll leave it out + # Various Artist albums might be listed as VA, so I'll leave that out too + # Only use the year if the term could return a bunch of different albums, i.e. self-titled albums + if album['ArtistName'] in album['AlbumTitle'] or len(album['ArtistName']) < 4 or len( + album['AlbumTitle']) < 4: + term = cleanartist + ' ' + cleanalbum + ' ' + year + elif album['ArtistName'] == 'Various Artists': + term = cleanalbum + ' ' + year + else: + term = cleanartist + ' ' + cleanalbum + + # Replace bad characters in the term and unicode it + term = re.sub('[\.\-\/]', ' ', term).encode('utf-8') + artistterm = re.sub('[\.\-\/]', ' ', cleanartist).encode('utf-8', 'replace') + + logger.debug(u'Using search term: "%s"' % helpers.latinToAscii(term)) + + resultlist = [] + + # Deezer only provides lossy + if headphones.CONFIG.DEEZLOADER and not losslessOnly: + resultlist += deezloader.searchAlbum(album['ArtistName'], album['AlbumTitle'], album['SearchTerm'], int(albumlength / 1000)) + + # attempt to verify that this isn't a substring result + # when looking for "Foo - Foo" we don't want "Foobar" + # this should be less of an issue when it isn't a self-titled album so we'll only check vs artist + results = [result for result in resultlist if verifyresult(result[0], artistterm, term, losslessOnly)] + + # Additional filtering for size etc + if results and not choose_specific_download: + results = more_filtering(results, album, albumlength, new) + + return results + + def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): global apolloobj # persistent apollo.rip api object to reduce number of login attempts @@ -2036,7 +2166,10 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, def preprocess(resultlist): for result in resultlist: - if result[4] == 'torrent': + if result[3] == deezloader.PROVIDER_NAME: + return True, result + + elif result[4] == 'torrent': # rutracker always needs the torrent data if result[3] == 'rutracker.org': diff --git a/headphones/webserve.py b/headphones/webserve.py index 855fed24..7e97cefb 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1174,6 +1174,10 @@ class WebInterface(object): "utorrent_username": headphones.CONFIG.UTORRENT_USERNAME, "utorrent_password": headphones.CONFIG.UTORRENT_PASSWORD, "utorrent_label": headphones.CONFIG.UTORRENT_LABEL, + "aria_host": headphones.CONFIG.ARIA_HOST, + "aria_password": headphones.CONFIG.ARIA_PASSWORD, + "aria_token": headphones.CONFIG.ARIA_TOKEN, + "aria_username": headphones.CONFIG.ARIA_USERNAME, "nzb_downloader_sabnzbd": radio(headphones.CONFIG.NZB_DOWNLOADER, 0), "nzb_downloader_nzbget": radio(headphones.CONFIG.NZB_DOWNLOADER, 1), "nzb_downloader_blackhole": radio(headphones.CONFIG.NZB_DOWNLOADER, 2), @@ -1182,6 +1186,7 @@ class WebInterface(object): "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), + "ddl_downloader_aria": radio(headphones.CONFIG.DDL_DOWNLOADER, 0), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, @@ -1243,6 +1248,8 @@ class WebInterface(object): "use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE), "tquattrecentonze_user": headphones.CONFIG.TQUATTRECENTONZE_USER, "tquattrecentonze_password": headphones.CONFIG.TQUATTRECENTONZE_PASSWORD, + "download_ddl_dir": headphones.CONFIG.DOWNLOAD_DDL_DIR, + "use_deezloader": checked(headphones.CONFIG.DEEZLOADER), "pref_qual_0": radio(headphones.CONFIG.PREFERRED_QUALITY, 0), "pref_qual_1": radio(headphones.CONFIG.PREFERRED_QUALITY, 1), "pref_qual_2": radio(headphones.CONFIG.PREFERRED_QUALITY, 2), @@ -1288,6 +1295,7 @@ class WebInterface(object): "prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0), "prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1), "prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2), + "prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3), "magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0), "magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1), "magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2), @@ -1446,7 +1454,7 @@ class WebInterface(object): "launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer", "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay", - "use_mininova", "use_waffles", "use_rutracker", + "use_mininova", "use_waffles", "use_rutracker", "use_deezloader", "use_apollo", "use_redacted", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", @@ -1580,6 +1588,9 @@ class WebInterface(object): # Reconfigure musicbrainz database connection with the new values mb.startmb() + # Reconfigure Aria2 + searcher.reconfigure() + raise cherrypy.HTTPRedirect("config") @cherrypy.expose From cef1563b4b7e517555d5f3cc9aa0a1f3c74f8cd7 Mon Sep 17 00:00:00 2001 From: Ade Date: Tue, 25 Apr 2017 17:03:59 +1200 Subject: [PATCH 100/137] Trap errors for Scan --- headphones/webserve.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/headphones/webserve.py b/headphones/webserve.py index 855fed24..f46b5254 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -871,11 +871,19 @@ class WebInterface(object): def musicScan(self, path, scan=0, redirect=None, autoadd=0, libraryscan=0): headphones.CONFIG.LIBRARYSCAN = libraryscan headphones.CONFIG.AUTO_ADD_ARTISTS = autoadd - headphones.CONFIG.MUSIC_DIR = path - headphones.CONFIG.write() + + try: + params = {} + headphones.CONFIG.MUSIC_DIR = path + headphones.CONFIG.write() + except Exception as e: + logger.warn("Cannot save scan directory to config: %s", e) + if scan: + params = {"dir": path} + if scan: try: - threading.Thread(target=librarysync.libraryScan).start() + threading.Thread(target=librarysync.libraryScan, kwargs=params).start() except Exception as e: logger.error('Unable to complete the scan: %s' % e) if redirect: From 39e589bc08df93d01468a57dd74087ab06debc46 Mon Sep 17 00:00:00 2001 From: Ade Date: Tue, 25 Apr 2017 18:16:33 +1200 Subject: [PATCH 101/137] Use Release Id for beets matching if supplied --- headphones/lastfm.py | 2 +- headphones/postprocessor.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/headphones/lastfm.py b/headphones/lastfm.py index 89ab7448..8f906d5f 100644 --- a/headphones/lastfm.py +++ b/headphones/lastfm.py @@ -55,7 +55,7 @@ def request_lastfm(method, **kwargs): return if "error" in data: - logger.error("Last.FM returned an error: %s", data["message"]) + logger.debug("Last.FM returned an error: %s", data["message"]) return return data diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index ab00ab1a..38e4bba5 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -954,16 +954,24 @@ def correctMetadata(albumid, release, downloaded_track_list): if not items: continue + search_ids = [] + logger.debug('Getting recommendation from beets. Artist: %s. Album: %s. Tracks: %s', release['ArtistName'], + release['AlbumTitle'], len(items)) + + # Try with specific release, e.g. alternate release selected from albumPage + if release['ReleaseID'] != release['AlbumID']: + logger.debug('trying beets with specific Release ID: %s', release['ReleaseID']) + search_ids = [release['ReleaseID']] + try: - logger.debug('Getting recommendation from beets. Artist: %s. Album: %s. Tracks: %s', release['ArtistName'], - release['AlbumTitle'], len(items)) beetslog = beetslogging.getLogger('beets') beetslog.set_global_level(beetslogging.DEBUG) if headphones.VERBOSE else beetslog.set_global_level( beetslogging.CRITICAL) with helpers.capture_beets_log() as logs: cur_artist, cur_album, prop = autotag.tag_album(items, search_artist=release['ArtistName'], - search_album=release['AlbumTitle']) + search_album=release['AlbumTitle'], + search_ids=search_ids) candidates = prop.candidates rec = prop.recommendation for log in logs: From bc0ce99adfa7df8176d874581d6c83a19fcc251e Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 29 Apr 2017 18:05:10 +1200 Subject: [PATCH 102/137] Set clean name to lower --- headphones/__init__.py | 8 ++++++++ headphones/helpers.py | 1 + 2 files changed, 9 insertions(+) diff --git a/headphones/__init__.py b/headphones/__init__.py index 20476169..8247e1c2 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -620,6 +620,14 @@ def dbcheck(): c.execute('ALTER TABLE snatched ADD COLUMN TorrentHash TEXT') c.execute('UPDATE snatched SET TorrentHash = FolderName WHERE Status LIKE "Seed_%"') + # One off script to set CleanName to lower case + clean_name_mixed = c.execute('SELECT CleanName FROM have ORDER BY Date Desc').fetchone()[0] + if clean_name_mixed != clean_name_mixed.lower(): + logger.info("Updating track clean name, this could take some time...") + c.execute('UPDATE tracks SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName') + c.execute('UPDATE alltracks SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName') + c.execute('UPDATE have SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName') + conn.commit() c.close() diff --git a/headphones/helpers.py b/headphones/helpers.py index d9b491f7..05f6108b 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -333,6 +333,7 @@ def clean_name(s): # 6. trim u = u.strip() # 7. lowercase + u = u.lower() return u From cd2860f4e373cdd7c358bc1bf5204d7944d8d0e6 Mon Sep 17 00:00:00 2001 From: Kallys Date: Sun, 30 Apr 2017 14:00:10 +0200 Subject: [PATCH 103/137] Fix #2930 Fix #2931 --- data/interfaces/default/config.html | 8 +++++++- headphones/deezloader.py | 3 ++- headphones/searcher.py | 5 ++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index bd331f2c..18200892 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -335,12 +335,18 @@ +
+ Note: With Aria2, you can specify a different download directory for downloads sent from Headphones. + Set it in the Music Download Directory below +
+
+
- Full path where ddl client downloads your music, e.g. /Users/name/Downloads/music + Full path where your direct download client downloads your music, e.g. /Users/name/Downloads/music
diff --git a/headphones/deezloader.py b/headphones/deezloader.py index 8de85df9..09eb60d9 100644 --- a/headphones/deezloader.py +++ b/headphones/deezloader.py @@ -350,6 +350,7 @@ def decryptTracks(paths): decrypted_tracks[album_folder][disk_number] = {} decrypted_tracks[album_folder][disk_number][track_number] = track + decrypted_tracks[album_folder][disk_number][track_number]['path'] = path except Exception as e: logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) @@ -371,7 +372,7 @@ def decryptTracks(paths): # Decrypt track if not already done if not os.path.exists(dest): try: - __decryptDownload(path, sng_id, dest) + __decryptDownload(track['path'], track['SNG_ID'], dest) __tagTrack(dest, track) except Exception as e: logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) diff --git a/headphones/searcher.py b/headphones/searcher.py index f6554cb1..fdef6153 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -894,7 +894,10 @@ def send_to_downloader(data, bestqual, album): if track: filename = track['SNG_ID'] + '.dzr' logger.debug(u'Sending song "%s" to Aria' % track['SNG_TITLE']) - getAria2RPC().addUri([track['downloadUrl']], {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', 'dir': folder_name}) + getAria2RPC().addUri( + [track['downloadUrl']], + {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', + 'dir': os.path.join(headphones.CONFIG.DOWNLOAD_DDL_DIR, folder_name)}) except Exception as e: logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) From 54edacdfe7e90116ceec932e2f585d4c1503a289 Mon Sep 17 00:00:00 2001 From: pratstercs Date: Mon, 1 May 2017 00:29:39 +0100 Subject: [PATCH 104/137] Fixed bug (#2933 at least) where an artist will null name errors when trying to remove from library as logger is trying to concat artist name (NoneType) with string --- headphones/webserve.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/headphones/webserve.py b/headphones/webserve.py index 267b78bc..a4063000 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -252,7 +252,10 @@ class WebInterface(object): namecheck = myDB.select('SELECT ArtistName from artists where ArtistID=?', [ArtistID]) for name in namecheck: artistname = name['ArtistName'] - logger.info(u"Deleting all traces of artist: " + artistname) + try: + logger.info(u"Deleting all traces of artist: " + artistname) + except TypeError: + logger.info(u"Deleting all traces of artist: null") myDB.action('DELETE from artists WHERE ArtistID=?', [ArtistID]) from headphones import cache From 74d5333bdf460d7e574b036b3734ce1712e82020 Mon Sep 17 00:00:00 2001 From: pratstercs Date: Mon, 1 May 2017 00:35:28 +0100 Subject: [PATCH 105/137] Fixing tabs/spaces --- headphones/webserve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/webserve.py b/headphones/webserve.py index a4063000..648e293b 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -253,7 +253,7 @@ class WebInterface(object): for name in namecheck: artistname = name['ArtistName'] try: - logger.info(u"Deleting all traces of artist: " + artistname) + logger.info(u"Deleting all traces of artist: " + artistname) except TypeError: logger.info(u"Deleting all traces of artist: null") myDB.action('DELETE from artists WHERE ArtistID=?', [ArtistID]) From e3c77900391e72583262db9d4bc04bcd91cd043f Mon Sep 17 00:00:00 2001 From: Kallys Date: Tue, 2 May 2017 13:41:29 +0200 Subject: [PATCH 106/137] Revert "Fix #2930" This reverts commit cd2860f4e373cdd7c358bc1bf5204d7944d8d0e6. --- data/interfaces/default/config.html | 8 +------- headphones/deezloader.py | 3 +-- headphones/searcher.py | 5 +---- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 18200892..bd331f2c 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -335,18 +335,12 @@ -
- Note: With Aria2, you can specify a different download directory for downloads sent from Headphones. - Set it in the Music Download Directory below -
- -
- Full path where your direct download client downloads your music, e.g. /Users/name/Downloads/music + Full path where ddl client downloads your music, e.g. /Users/name/Downloads/music
diff --git a/headphones/deezloader.py b/headphones/deezloader.py index 09eb60d9..8de85df9 100644 --- a/headphones/deezloader.py +++ b/headphones/deezloader.py @@ -350,7 +350,6 @@ def decryptTracks(paths): decrypted_tracks[album_folder][disk_number] = {} decrypted_tracks[album_folder][disk_number][track_number] = track - decrypted_tracks[album_folder][disk_number][track_number]['path'] = path except Exception as e: logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) @@ -372,7 +371,7 @@ def decryptTracks(paths): # Decrypt track if not already done if not os.path.exists(dest): try: - __decryptDownload(track['path'], track['SNG_ID'], dest) + __decryptDownload(path, sng_id, dest) __tagTrack(dest, track) except Exception as e: logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) diff --git a/headphones/searcher.py b/headphones/searcher.py index fdef6153..f6554cb1 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -894,10 +894,7 @@ def send_to_downloader(data, bestqual, album): if track: filename = track['SNG_ID'] + '.dzr' logger.debug(u'Sending song "%s" to Aria' % track['SNG_TITLE']) - getAria2RPC().addUri( - [track['downloadUrl']], - {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', - 'dir': os.path.join(headphones.CONFIG.DOWNLOAD_DDL_DIR, folder_name)}) + getAria2RPC().addUri([track['downloadUrl']], {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', 'dir': folder_name}) except Exception as e: logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) From 0acb5a413ac30b26a1a964f428c0fa2b3d74aa73 Mon Sep 17 00:00:00 2001 From: Kallys Date: Tue, 2 May 2017 13:41:51 +0200 Subject: [PATCH 107/137] Revert "Integration of deezloader provider by ParadoxalManiak under CC BY-NC-SA 4.0" This reverts commit 04e8767ea99e804ccb909d3293f8d6e2561d3726. --- data/interfaces/default/config.html | 64 -- headphones/aria2.py | 979 ---------------------------- headphones/classes.py | 31 - headphones/config.py | 7 - headphones/deezloader.py | 441 ------------- headphones/postprocessor.py | 40 +- headphones/searcher.py | 167 +---- headphones/webserve.py | 13 +- 8 files changed, 20 insertions(+), 1722 deletions(-) delete mode 100644 headphones/aria2.py delete mode 100644 headphones/deezloader.py diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index bd331f2c..841df0d1 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -304,45 +304,6 @@ - -
- Direct Download - Aria2 -
-
-
- - - usually http://localhost:6800/jsonrpc -
-
- - -
-
- - -
-
- - -
-
- - - Full path where ddl client downloads your music, e.g. /Users/name/Downloads/music -
-
@@ -500,7 +461,6 @@ NZBs Torrents - Direct Download No Preference
@@ -613,21 +573,6 @@ - -
- Direct Download - -
-
- - -
-
- -
@@ -2460,11 +2405,6 @@ $("#deluge_options").show(); } - if ($("#ddl_downloader_aria").is(":checked")) - { - $("#ddl_aria_options").show(); - } - $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) { @@ -2518,9 +2458,6 @@ { $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } - if ($("#ddl_downloader_aria").is(":checked")) - { - } }); $("#mirror").change(handleNewServerSelection); @@ -2623,7 +2560,6 @@ initConfigCheckbox("#enable_https"); initConfigCheckbox("#customauth"); initConfigCheckbox("#use_tquattrecentonze"); - initConfigCheckbox("#use_deezloader"); $('#twitterStep1').click(function () { diff --git a/headphones/aria2.py b/headphones/aria2.py deleted file mode 100644 index 06d8e4e0..00000000 --- a/headphones/aria2.py +++ /dev/null @@ -1,979 +0,0 @@ -# -*- coding: utf8 -*- -# Copyright (C) 2012-2016 Xyne -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# (version 2) as published by the Free Software Foundation. -# -# -# This program 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 this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -from __future__ import with_statement -import base64 -import json -import math -import os -import ssl -import string -import time -import httplib -import urllib2 - -from headphones import logger - -# ################################ Constants ################################### - -DEFAULT_PORT = 6800 -SERVER_URI_FORMAT = '{}://{}:{:d}/jsonrpc' - -# Status values for unfinished downloads. -TEMPORARY_STATUS = ('active', 'waiting', 'paused') -# Status values for finished downloads. -FINAL_STATUS = ('complete', 'error') - -ARIA2_CONTROL_FILE_EXT = '.aria2' - -# ########################## Convenience Functions ############################# - - -def to_json_list(objs): - ''' - Wrap strings in lists. Other iterables are converted to lists directly. - ''' - if isinstance(objs, str): - return [objs] - elif not isinstance(objs, list): - return list(objs) - else: - return objs - - -def add_options_and_position(params, options=None, position=None): - ''' - Convenience method for adding options and position to parameters. - ''' - if options: - params.append(options) - if position: - if not isinstance(position, int): - try: - position = int(position) - except ValueError: - position = -1 - if position >= 0: - params.append(position) - return params - - -def get_status(response): - ''' - Process a status response. - ''' - if response: - try: - return response['status'] - except KeyError: - logger.error('no status returned from Aria2 RPC server') - return 'error' - else: - logger.error('no response from server') - return 'error' - - -def random_token(length, valid_chars=None): - ''' - Get a random secret token for the Aria2 RPC server. - - length: - The length of the token - - valid_chars: - A list or other ordered and indexable iterable of valid characters. If not - given of None, asciinumberic characters with some punctuation characters - will be used. - ''' - if not valid_chars: - valid_chars = string.ascii_letters + string.digits + '!@#$%^&*()-_=+' - number_of_chars = len(valid_chars) - bytes_to_read = math.ceil(math.log(number_of_chars) / math.log(0x100)) - max_value = 0x100**bytes_to_read - max_index = number_of_chars - 1 - token = '' - for _ in range(length): - value = int.from_bytes(os.urandom(bytes_to_read), byteorder='little') - index = round((value * max_index) / max_value) - token += valid_chars[index] - return token - -# ################ From python3-aur's ThreadedServers.common ################### - - -def format_bytes(size): - '''Format bytes for inferior humans.''' - if size < 0x400: - return '{:d} B'.format(size) - else: - size = float(size) / 0x400 - for prefix in ('KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB'): - if size < 0x400: - return '{:0.02f} {}'.format(size, prefix) - else: - size /= 0x400 - return '{:0.02f} YiB'.format(size) - - -def format_seconds(s): - '''Format seconds for inferior humans.''' - string = '' - for base, char in ( - (60, 's'), - (60, 'm'), - (24, 'h') - ): - s, r = divmod(s, base) - if s == 0: - return '{:d}{}{}'.format(r, char, string) - elif r != 0: - string = '{:02d}{}{}'.format(r, char, string) - else: - return '{:d}d{}'.format(s, string) - -# ############################ Aria2JsonRpcError ############################### - - -class Aria2JsonRpcError(Exception): - def __init__(self, msg, connection_error=False): - super(self.__class__, self).__init__(self, msg) - self.connection_error = connection_error - -# ############################ Aria2JsonRpc Class ############################## - - -class Aria2JsonRpc(object): - ''' - Interface class for interacting with an Aria2 RPC server. - ''' - # TODO: certificate options, etc. - def __init__( - self, ID, uri, - mode='normal', - token=None, - http_user=None, http_passwd=None, - server_cert=None, client_cert=None, client_cert_password=None, - ssl_protocol=None, - setup_function=None - ): - ''' - ID: the ID to send to the RPC interface - - uri: the URI of the RPC interface - - mode: - normal - process requests immediately - batch - queue requests (run with "process_queue") - format - return RPC request objects - - token: - RPC method-level authorization token (set using `--rpc-secret`) - - http_user, http_password: - HTTP Basic authentication credentials (deprecated) - - server_cert: - server certificate for HTTPS connections - - client_cert: - client certificate for HTTPS connections - - client_cert_password: - prompt for client certificate password - - ssl_protocol: - SSL protocol from the ssl module - - setup_function: - A function to invoke prior to the first server call. This could be the - launch() method of an Aria2RpcServer instance, for example. This attribute - is set automatically in instances returned from Aria2RpcServer.get_a2jr() - ''' - self.id = ID - self.uri = uri - self.mode = mode - self.queue = [] - self.handlers = dict() - self.token = token - self.setup_function = setup_function - - if None not in (http_user, http_passwd): - self.add_HTTPBasicAuthHandler(http_user, http_passwd) - - if server_cert or client_cert: - self.add_HTTPSHandler( - server_cert=server_cert, - client_cert=client_cert, - client_cert_password=client_cert_password, - protocol=ssl_protocol - ) - - self.update_opener() - - def iter_handlers(self): - ''' - Iterate over handlers. - ''' - for name in ('HTTPS', 'HTTPBasicAuth'): - try: - yield self.handlers[name] - except KeyError: - pass - - def update_opener(self): - ''' - Build an opener from the current handlers. - ''' - self.opener = urllib2.build_opener(*self.iter_handlers()) - - def remove_handler(self, name): - ''' - Remove a handler. - ''' - try: - del self.handlers[name] - except KeyError: - pass - - def add_HTTPBasicAuthHandler(self, user, passwd): - ''' - Add a handler for HTTP Basic authentication. - - If either user or passwd are None, the handler is removed. - ''' - handler = urllib2.HTTPBasicAuthHandler() - handler.add_password( - realm='aria2', - uri=self.uri, - user=user, - passwd=passwd, - ) - self.handlers['HTTPBasicAuth'] = handler - - def remove_HTTPBasicAuthHandler(self): - self.remove_handler('HTTPBasicAuth') - - def add_HTTPSHandler( - self, - server_cert=None, - client_cert=None, - client_cert_password=None, - protocol=None, - ): - ''' - Add a handler for HTTPS connections with optional server and client - certificates. - ''' - if not protocol: - protocol = ssl.PROTOCOL_TLSv1 -# protocol = ssl.PROTOCOL_TLSv1_1 # openssl 1.0.1+ -# protocol = ssl.PROTOCOL_TLSv1_2 # Python 3.4+ - context = ssl.SSLContext(protocol) - - if server_cert: - context.verify_mode = ssl.CERT_REQUIRED - context.load_verify_locations(cafile=server_cert) - else: - context.verify_mode = ssl.CERT_OPTIONAL - - if client_cert: - context.load_cert_chain(client_cert, password=client_cert_password) - - self.handlers['HTTPS'] = urllib2.HTTPSHandler( - context=context, - check_hostname=False - ) - - def remove_HTTPSHandler(self): - self.remove_handler('HTTPS') - - def send_request(self, req_obj): - ''' - Send the request and return the response. - ''' - if self.setup_function: - self.setup_function() - self.setup_function = None - logger.debug("Aria req_obj: %s" % json.dumps(req_obj, indent=2, sort_keys=True)) - req = json.dumps(req_obj).encode('UTF-8') - try: - f = self.opener.open(self.uri, req) - obj = json.loads(f.read()) - try: - return obj['result'] - except KeyError: - raise Aria2JsonRpcError('unexpected result: {}'.format(obj)) - except (urllib2.URLError) as e: - # This should work but URLError does not set the errno attribute: - # e.errno == errno.ECONNREFUSED - raise Aria2JsonRpcError( - str(e), - connection_error=( - '111' in str(e) - ) - ) - except httplib.BadStatusLine as e: - raise Aria2JsonRpcError('{}: BadStatusLine: {} (HTTPS error?)'.format( - self.__class__.__name__, e - )) - - def jsonrpc(self, method, params=None, prefix='aria2.'): - ''' - POST a request to the RPC interface. - ''' - if not params: - params = [] - - if self.token is not None: - token_str = 'token:{}'.format(self.token) - if method == 'multicall': - for p in params[0]: - try: - p['params'].insert(0, token_str) - except KeyError: - p['params'] = [token_str] - else: - params.insert(0, token_str) - - req_obj = { - 'jsonrpc': '2.0', - 'id': self.id, - 'method': prefix + method, - 'params': params, - } - if self.mode == 'batch': - self.queue.append(req_obj) - return None - elif self.mode == 'format': - return req_obj - else: - return self.send_request(req_obj) - - def process_queue(self): - ''' - Processed queued requests. - ''' - req_obj = self.queue - self.queue = [] - return self.send_request(req_obj) - - -# ############################# Standard Methods ############################### - def addUri(self, uris, options=None, position=None): - ''' - aria2.addUri method - - uris: list of URIs - - options: dictionary of additional options - - position: position in queue - - Returns a GID - ''' - params = [uris] - params = add_options_and_position(params, options, position) - return self.jsonrpc('addUri', params) - - def addTorrent(self, torrent, uris=None, options=None, position=None): - ''' - aria2.addTorrent method - - torrent: base64-encoded torrent file - - uris: list of webseed URIs - - options: dictionary of additional options - - position: position in queue - - Returns a GID. - ''' - params = [torrent] - if uris: - params.append(uris) - params = add_options_and_position(params, options, position) - return self.jsonrpc('addTorrent', params) - - def addMetalink(self, metalink, options=None, position=None): - ''' - aria2.addMetalink method - - metalink: base64-encoded metalink file - - options: dictionary of additional options - - position: position in queue - - Returns an array of GIDs. - ''' - params = [metalink] - params = add_options_and_position(params, options, position) - return self.jsonrpc('addTorrent', params) - - def remove(self, gid): - ''' - aria2.remove method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('remove', params) - - def forceRemove(self, gid): - ''' - aria2.forceRemove method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('forceRemove', params) - - def pause(self, gid): - ''' - aria2.pause method - - gid: GID to pause - ''' - params = [gid] - return self.jsonrpc('pause', params) - - def pauseAll(self): - ''' - aria2.pauseAll method - ''' - return self.jsonrpc('pauseAll') - - def forcePause(self, gid): - ''' - aria2.forcePause method - - gid: GID to pause - ''' - params = [gid] - return self.jsonrpc('forcePause', params) - - def forcePauseAll(self): - ''' - aria2.forcePauseAll method - ''' - return self.jsonrpc('forcePauseAll') - - def unpause(self, gid): - ''' - aria2.unpause method - - gid: GID to unpause - ''' - params = [gid] - return self.jsonrpc('unpause', params) - - def unpauseAll(self): - ''' - aria2.unpauseAll method - ''' - return self.jsonrpc('unpauseAll') - - def tellStatus(self, gid, keys=None): - ''' - aria2.tellStatus method - - gid: GID to query - - keys: subset of status keys to return (all keys are returned otherwise) - - Returns a dictionary. - ''' - params = [gid] - if keys: - params.append(keys) - return self.jsonrpc('tellStatus', params) - - def getUris(self, gid): - ''' - aria2.getUris method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getUris', params) - - def getFiles(self, gid): - ''' - aria2.getFiles method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getFiles', params) - - def getPeers(self, gid): - ''' - aria2.getPeers method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getPeers', params) - - def getServers(self, gid): - ''' - aria2.getServers method - - gid: GID to query - - Returns a list of dictionaries. - ''' - params = [gid] - return self.jsonrpc('getServers', params) - - def tellActive(self, keys=None): - ''' - aria2.tellActive method - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - if keys: - params = [keys] - else: - params = None - return self.jsonrpc('tellActive', params) - - def tellWaiting(self, offset, num, keys=None): - ''' - aria2.tellWaiting method - - offset: offset from start of waiting download queue - (negative values are counted from the end of the queue) - - num: number of downloads to return - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - params = [offset, num] - if keys: - params.append(keys) - return self.jsonrpc('tellWaiting', params) - - def tellStopped(self, offset, num, keys=None): - ''' - aria2.tellStopped method - - offset: offset from oldest download (same semantics as tellWaiting) - - num: same as tellWaiting - - keys: same as tellStatus - - Returns a list of dictionaries. The dictionaries are the same as those - returned by tellStatus. - ''' - params = [offset, num] - if keys: - params.append(keys) - return self.jsonrpc('tellStopped', params) - - def changePosition(self, gid, pos, how): - ''' - aria2.changePosition method - - gid: GID to change - - pos: the position - - how: "POS_SET", "POS_CUR" or "POS_END" - ''' - params = [gid, pos, how] - return self.jsonrpc('changePosition', params) - - def changeUri(self, gid, fileIndex, delUris, addUris, position=None): - ''' - aria2.changePosition method - - gid: GID to change - - fileIndex: file to affect (1-based) - - delUris: URIs to remove - - addUris: URIs to add - - position: where URIs are inserted, after URIs have been removed - ''' - params = [gid, fileIndex, delUris, addUris] - if position: - params.append(position) - return self.jsonrpc('changePosition', params) - - def getOption(self, gid): - ''' - aria2.getOption method - - gid: GID to query - - Returns a dictionary of options. - ''' - params = [gid] - return self.jsonrpc('getOption', params) - - def changeOption(self, gid, options): - ''' - aria2.changeOption method - - gid: GID to change - - options: dictionary of new options - (not all options can be changed for active downloads) - ''' - params = [gid, options] - return self.jsonrpc('changeOption', params) - - def getGlobalOption(self): - ''' - aria2.getGlobalOption method - - Returns a dictionary. - ''' - return self.jsonrpc('getGlobalOption') - - def changeGlobalOption(self, options): - ''' - aria2.changeGlobalOption method - - options: dictionary of new options - ''' - params = [options] - return self.jsonrpc('changeGlobalOption', params) - - def getGlobalStat(self): - ''' - aria2.getGlobalStat method - - Returns a dictionary. - ''' - return self.jsonrpc('getGlobalStat') - - def purgeDownloadResult(self): - ''' - aria2.purgeDownloadResult method - ''' - self.jsonrpc('purgeDownloadResult') - - def removeDownloadResult(self, gid): - ''' - aria2.removeDownloadResult method - - gid: GID to remove - ''' - params = [gid] - return self.jsonrpc('removeDownloadResult', params) - - def getVersion(self): - ''' - aria2.getVersion method - - Returns a dictionary. - ''' - return self.jsonrpc('getVersion') - - def getSessionInfo(self): - ''' - aria2.getSessionInfo method - - Returns a dictionary. - ''' - return self.jsonrpc('getSessionInfo') - - def shutdown(self): - ''' - aria2.shutdown method - ''' - return self.jsonrpc('shutdown') - - def forceShutdown(self): - ''' - aria2.forceShutdown method - ''' - return self.jsonrpc('forceShutdown') - - def multicall(self, methods): - ''' - aria2.multicall method - - methods: list of dictionaries (keys: methodName, params) - - The method names must be those used by Aria2c, e.g. "aria2.tellStatus". - ''' - return self.jsonrpc('multicall', [methods], prefix='system.') - - -# ########################### Convenience Methods ############################## - def add_torrent(self, path, uris=None, options=None, position=None): - ''' - A wrapper around addTorrent for loading files. - ''' - with open(path, 'r') as f: - torrent = base64.encode(f.read()) - return self.addTorrent(torrent, uris, options, position) - - def add_metalink(self, path, uris=None, options=None, position=None): - ''' - A wrapper around addMetalink for loading files. - ''' - with open(path, 'r') as f: - metalink = base64.encode(f.read()) - return self.addMetalink(metalink, uris, options, position) - - def get_status(self, gid): - ''' - Get the status of a single GID. - ''' - response = self.tellStatus(gid, ['status']) - return get_status(response) - - def wait_for_final_status(self, gid, interval=1): - ''' - Wait for a GID to complete or fail and return its status. - ''' - if not interval or interval < 0: - interval = 1 - while True: - status = self.get_status(gid) - if status in TEMPORARY_STATUS: - time.sleep(interval) - else: - return status - - def get_statuses(self, gids): - ''' - Get the status of multiple GIDs. The status of each is yielded in order. - ''' - methods = [ - { - 'methodName': 'aria2.tellStatus', - 'params': [gid, ['gid', 'status']] - } - for gid in gids - ] - results = self.multicall(methods) - if results: - status = dict((r[0]['gid'], r[0]['status']) for r in results) - for gid in gids: - try: - yield status[gid] - except KeyError: - logger.error('Aria2 RPC server returned no status for GID {}'.format(gid)) - yield 'error' - else: - logger.error('no response from Aria2 RPC server') - for gid in gids: - yield 'error' - - def wait_for_final_statuses(self, gids, interval=1): - ''' - Wait for multiple GIDs to complete or fail and return their statuses in - order. - - gids: - A flat list of GIDs. - ''' - if not interval or interval < 0: - interval = 1 - statusmap = dict((g, None) for g in gids) - remaining = list( - g for g, s in statusmap.items() if s is None - ) - while remaining: - for g, s in zip(remaining, self.get_statuses(remaining)): - if s in TEMPORARY_STATUS: - continue - else: - statusmap[g] = s - remaining = list( - g for g, s in statusmap.items() if s is None - ) - if remaining: - time.sleep(interval) - for g in gids: - yield statusmap[g] - - def print_global_status(self): - ''' - Print global status of the RPC server. - ''' - status = self.getGlobalStat() - if status: - numWaiting = int(status['numWaiting']) - numStopped = int(status['numStopped']) - keys = ['totalLength', 'completedLength'] - total = self.tellActive(keys) - waiting = self.tellWaiting(0, numWaiting, keys) - if waiting: - total += waiting - stopped = self.tellStopped(0, numStopped, keys) - if stopped: - total += stopped - - downloadSpeed = int(status['downloadSpeed']) - uploadSpeed = int(status['uploadSpeed']) - totalLength = sum(int(x['totalLength']) for x in total) - completedLength = sum(int(x['completedLength']) for x in total) - remaining = totalLength - completedLength - - status['downloadSpeed'] = format_bytes(downloadSpeed) + '/s' - status['uploadSpeed'] = format_bytes(uploadSpeed) + '/s' - - preordered = ('downloadSpeed', 'uploadSpeed') - - rows = list() - for k in sorted(status): - if k in preordered: - continue - rows.append((k, status[k])) - - rows.extend((x, status[x]) for x in preordered) - - if totalLength > 0: - rows.append(('total', format(format_bytes(totalLength)))) - rows.append(('completed', format(format_bytes(completedLength)))) - rows.append(('remaining', format(format_bytes(remaining)))) - if completedLength == totalLength: - eta = 'finished' - else: - try: - eta = format_seconds(remaining // downloadSpeed) - except ZeroDivisionError: - eta = 'never' - rows.append(('ETA', eta)) - - l = max(len(r[0]) for r in rows) - r = max(len(r[1]) for r in rows) - r = max(r, len(self.uri) - (l + 2)) - fmt = '{:<' + str(l) + 's} {:>' + str(r) + 's}' - - print(self.uri) - for k, v in rows: - print(fmt.format(k, v)) - - def queue_uris(self, uris, options, interval=None): - ''' - Enqueue URIs and wait for download to finish while printing status at - regular intervals. - ''' - gid = self.addUri(uris, options) - print('GID: {}'.format(gid)) - - if gid and interval is not None: - blanker = '' - while True: - response = self.tellStatus(gid, ['status']) - if response: - try: - status = response['status'] - except KeyError: - print('error: no status returned from Aria2 RPC server') - break - print('{}\rstatus: {}'.format(blanker, status)), - blanker = ' ' * len(status) - if status in TEMPORARY_STATUS: - time.sleep(interval) - else: - break - else: - print('error: no response from server') - break - - -# ####################### Polymethod download handlers ######################### - def polymethod_enqueue_many(self, downloads): - ''' - Enqueue downloads. - - downloads: Same as polymethod_download(). - ''' - methods = list( - { - 'methodName': 'aria2.{}'.format(d[0]), - 'params': list(d[1:]) - } for d in downloads - ) - return self.multicall(methods) - - def polymethod_wait_many(self, gids, interval=1): - ''' - Wait for the GIDs to complete or fail and return their statuses. - - gids: - A list of lists of GIDs. - ''' - # The flattened list of GIDs - gs = list(g for gs in gids for g in gs) - statusmap = dict(tuple(zip( - gs, - self.wait_for_final_statuses(gs, interval=interval) - ))) - for gs in gids: - yield list(statusmap.get(g, 'error') for g in gs) - - def polymethod_enqueue_one(self, download): - ''' - Same as polymethod_enqueue_many but for one element. - ''' - return getattr(self, download[0])(*download[1:]) - - def polymethod_download(self, downloads, interval=1): - ''' - Enqueue a series of downloads and wait for them to finish. Iterate over the - status of each, in order. - - downloads: - An iterable over (, , ...) where indicates the "add" - method to use ('addUri', 'addTorrent', 'addMetalink') and everything that - follows are arguments to pass to that method. - - interval: - The status check interval while waiting. - - Iterates over the download status of finished downloads. "complete" - indicates success. Lists of statuses will be returned for downloads that - create multiple GIDs (e.g. metalinks). - ''' - gids = self.polymethod_enqueue_many(downloads) - return self.polymethod_wait_many(gids, interval=interval) - - def polymethod_download_bool(self, *args, **kwargs): - ''' - A wrapper around polymethod_download() which returns a boolean for each - download to indicate success (True) or failure (False). - ''' -# for status in self.polymethod_download(*args, **kwargs): -# yield all(s == 'complete' for s in status) - return list( - all(s == 'complete' for s in status) - for status in self.polymethod_download(*args, **kwargs) - ) diff --git a/headphones/classes.py b/headphones/classes.py index 54a10a55..6015a0f2 100644 --- a/headphones/classes.py +++ b/headphones/classes.py @@ -21,7 +21,6 @@ import urllib from common import USER_AGENT -from collections import OrderedDict class HeadphonesURLopener(urllib.FancyURLopener): @@ -136,33 +135,3 @@ class Proper: def __str__(self): return str(self.date) + " " + self.name + " " + str(self.season) + "x" + str( self.episode) + " of " + str(self.tvdbid) - - -class OptionalImport(object): - ''' - Dummy class for optional import (imports needed for optional features). - ''' - def __init__(self, name): - self.__name = name - - def __getattr__(self, attr): - raise ImportError('The following package is required to use this feature: {0}'.format(self.__name)) - - -class CacheDict(OrderedDict): - ''' - Ordered dictionary with fixed size, designed for caching. - ''' - def __init__(self, *args, **kwds): - self.size_limit = kwds.pop("size_limit", None) - OrderedDict.__init__(self, *args, **kwds) - self._check_size_limit() - - def __setitem__(self, key, value): - OrderedDict.__setitem__(self, key, value) - self._check_size_limit() - - def _check_size_limit(self): - if self.size_limit is not None: - while len(self) > self.size_limit: - self.popitem(last=False) diff --git a/headphones/config.py b/headphones/config.py index 86aa9216..57af4c16 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -46,10 +46,6 @@ _CONFIG_DEFINITIONS = { 'APOLLO_RATIO': (str, 'Apollo.rip', ''), 'APOLLO_USERNAME': (str, 'Apollo.rip', ''), 'APOLLO_URL': (str, 'Apollo.rip', 'https://apollo.rip'), - 'ARIA_HOST': (str, 'Aria2', ''), - 'ARIA_PASSWORD': (str, 'Aria2', ''), - 'ARIA_TOKEN': (str, 'Aria2', ''), - 'ARIA_USERNAME': (str, 'Aria2', ''), 'AUTOWANT_ALL': (int, 'General', 0), 'AUTOWANT_MANUALLY_ADDED': (int, 'General', 1), 'AUTOWANT_UPCOMING': (int, 'General', 1), @@ -77,8 +73,6 @@ _CONFIG_DEFINITIONS = { 'CUSTOMPORT': (int, 'General', 5000), 'CUSTOMSLEEP': (int, 'General', 1), 'CUSTOMUSER': (str, 'General', ''), - 'DDL_DOWNLOADER': (int, 'General', 0), - 'DEEZLOADER': (int, 'DeezLoader', 0), 'DELETE_LOSSLESS_FILES': (int, 'General', 1), 'DELUGE_HOST': (str, 'Deluge', ''), 'DELUGE_CERT': (str, 'Deluge', ''), @@ -92,7 +86,6 @@ _CONFIG_DEFINITIONS = { 'DOWNLOAD_DIR': (path, 'General', ''), 'DOWNLOAD_SCAN_INTERVAL': (int, 'General', 5), 'DOWNLOAD_TORRENT_DIR': (path, 'General', ''), - 'DOWNLOAD_DDL_DIR': (path, 'General', ''), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'EMAIL_ENABLED': (int, 'Email', 0), 'EMAIL_FROM': (str, 'Email', ''), diff --git a/headphones/deezloader.py b/headphones/deezloader.py deleted file mode 100644 index 8de85df9..00000000 --- a/headphones/deezloader.py +++ /dev/null @@ -1,441 +0,0 @@ -# -*- coding: utf-8 -*- -# Deezloader (c) 2016 by ParadoxalManiak -# -# Deezloader is licensed under a -# Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. -# -# You should have received a copy of the license along with this -# work. If not, see . -# -# Version 2.1.0 -# Maintained by ParadoxalManiak -# Original work by ZzMTV -# -# Author's disclaimer: -# I am not responsible for the usage of this program by other people. -# I do not recommend you doing this illegally or against Deezer's terms of service. -# This project is licensed under CC BY-NC-SA 4.0 - -import re -import os -from datetime import datetime -from hashlib import md5 -import binascii - -from beets.mediafile import MediaFile -from headphones import logger, request, helpers -from headphones.classes import OptionalImport, CacheDict -import headphones - - -# Try to import optional Crypto.Cipher packages -try: - from Crypto.Cipher import AES, Blowfish -except ImportError: - AES = OptionalImport('Crypto.Cipher.AES') - Blowfish = OptionalImport('Crypto.Cipher.Blowfish') - -# Public constants -PROVIDER_NAME = 'Deezer' - -# Internal constants -__API_URL = "http://www.deezer.com/ajax/gw-light.php" -__API_INFO_URL = "http://api.deezer.com/" -__HTTP_HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36", - "Content-Language": "en-US", - "Cache-Control": "max-age=0", - "Accept": "*/*", - "Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3", - "Accept-Language": "de-DE,de;q=0.8,en-US;q=0.6,en;q=0.4" -} - -# Internal variables -__api_queries = { - 'api_version': "1.0", - 'api_token': "None", - 'input': "3" -} -__cookies = None -__tracks_cache = CacheDict(size_limit=512) -__albums_cache = CacheDict(size_limit=64) - - -def __getApiToken(): - global __cookies - response = request.request_response(url="http://www.deezer.com/", headers=__HTTP_HEADERS) - __cookies = response.cookies - data = response.content - if data: - matches = re.search(r"checkForm\s*=\s*['|\"](.*)[\"|'];", data) - if matches: - token = matches.group(1) - __api_queries['api_token'] = token - logger.debug(u"Deezloader : api token loaded ('%s')" % token) - - if not token: - logger.error(u"Deezloader: Unable to get api token") - - -def getAlbumByLink(album_link): - """Returns deezer album infos using album link url - - :param str album_link: deezer album link url (eg: 'http://www.deezer.com/album/1234567/') - """ - matches = re.search(r"album\/([0-9]+)\/?$", album_link) - if matches: - return getAlbum(matches.group(1)) - - -def getAlbum(album_id): - """Returns deezer album infos - - :param int album_id: deezer album id - """ - global __albums_cache - - if str(album_id) in __albums_cache: - return __albums_cache[str(album_id)] - - url = __API_INFO_URL + "album/" + str(album_id) - data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) - - if data and 'error' not in data: - __albums_cache[str(album_id)] = data - return data - else: - logger.debug("Deezloader: Can't load album infos") - return None - - -def searchAlbums(search_term): - """Search for deezer albums using search term - - :param str search_term: search term to search album for - """ - logger.info(u'Searching Deezer using term: "%s"' % search_term) - - url = __API_INFO_URL + "search/album?q=" + search_term - data = request.request_json(url=url, headers=__HTTP_HEADERS, cookies=__cookies) - - albums = [] - - # Process content - if data and 'total' in data and data['total'] > 0 and 'data' in data: - for item in data['data']: - try: - albums.append(getAlbum(item['id'])) - except Exception as e: - logger.error(u"An unknown error occurred in the Deezer search album parser: %s" % e) - else: - logger.info(u'No results found from Deezer using term: "%s"' % search_term) - - return albums - - -def __matchAlbums(albums, artist_name, album_title, album_length): - resultlist = [] - - for album in albums: - total_size = 0 - tracks_found = 0 - - for track in album['tracks']['data']: - t = getTrack(track['id']) - if t: - if t["FILESIZE_MP3_320"] > 0: - size = t["FILESIZE_MP3_320"] - elif t["FILESIZE_MP3_256"] > 0: - size = t["FILESIZE_MP3_256"] - elif t["FILESIZE_MP3_128"] > 0: - size = t["FILESIZE_MP3_128"] - else: - size = t["FILESIZE_MP3_64"] - - size = int(size) - total_size += size - tracks_found += 1 - logger.debug(u'Found song "%s". Size: %s' % (t['SNG_TITLE'], helpers.bytes_to_mb(size))) - - if tracks_found == 0: - logger.info(u'Ignoring album "%s" (no tracks to download)' % album['title']) - continue - - matched = True - mismatch_reason = 'matched!' - - if album_length > 0 and abs(int(album['duration']) - album_length) > 240: - matched = False - mismatch_reason = (u'duration mismatch: %i not in [%i, %i]' % (int(album['duration']), (album_length - 240), (album_length + 240))) - - elif (helpers.latinToAscii(re.sub(r"\W", "", album_title, 0, re.UNICODE)).lower() != - helpers.latinToAscii(re.sub(r"\W", "", album['title'], 0, re.UNICODE)).lower()): - matched = False - mismatch_reason = (u'album name mismatch: %s != %s' % (album['title'], album_title)) - - elif (helpers.latinToAscii(re.sub(r"\W", "", artist_name, 0, re.UNICODE)).lower() != - helpers.latinToAscii(re.sub(r"\W", "", album['artist']['name'], 0, re.UNICODE)).lower()): - matched = False - mismatch_reason = (u'artist name mismatch: %s != %s' % (album['artist']['name'], artist_name)) - - resultlist.append( - (album['artist']['name'] + ' - ' + album['title'] + ' [' + album['release_date'][:4] + '] (' + str(tracks_found) + '/' + str(album['nb_tracks']) + ')', - total_size, album['link'], PROVIDER_NAME, "ddl", matched) - ) - logger.info(u'Found "%s". Tracks %i/%i. Size: %s (%s)' % (album['title'], tracks_found, album['nb_tracks'], helpers.bytes_to_mb(total_size), mismatch_reason)) - - return resultlist - - -def searchAlbum(artist_name, album_title, user_search_term=None, album_length=None): - """Search for deezer specific album. - This will iterate over deezer albums and try to find best matches - - :param str artist_name: album artist name - :param str album_title: album title - :param str user_search_term: search terms provided by user - :param int album_length: targeted album duration in seconds - """ - # User search term by-pass normal search - if user_search_term: - return __matchAlbums(searchAlbums(user_search_term), artist_name, album_title, album_length) - - resultlist = __matchAlbums(searchAlbums((artist_name + ' ' + album_title).strip()), artist_name, album_title, album_length) - if resultlist: - return resultlist - - # Deezer API supports unicode, so just remove non alphanumeric characters - clean_artist_name = re.sub(r"[^\w\s]", " ", artist_name, 0, re.UNICODE).strip() - clean_album_name = re.sub(r"[^\w\s]", " ", album_title, 0, re.UNICODE).strip() - - resultlist = __matchAlbums(searchAlbums((clean_artist_name + ' ' + clean_album_name).strip()), artist_name, album_title, album_length) - if resultlist: - return resultlist - - resultlist = __matchAlbums(searchAlbums(clean_artist_name), artist_name, album_title, album_length) - if resultlist: - return resultlist - - return resultlist - - -def getTrack(sng_id, try_reload_api=True): - """Returns deezer track infos - - :param int sng_id: deezer song id - :param bool try_reload_api: whether or not try reloading API if session expired - """ - global __tracks_cache - - if str(sng_id) in __tracks_cache: - return __tracks_cache[str(sng_id)] - - data = "[{\"method\":\"song.getListData\",\"params\":{\"sng_ids\":[" + str(sng_id) + "]}}]" - json = request.request_json(url=__API_URL, headers=__HTTP_HEADERS, method='post', params=__api_queries, data=data, cookies=__cookies) - - results = [] - error = None - invalid_token = False - - if json: - # Check for errors - if 'error' in json: - error = json['error'] - if 'GATEWAY_ERROR' in json['error'] and json['error']['GATEWAY_ERROR'] == u"invalid api token": - invalid_token = True - - elif 'error' in json[0] and json[0]['error']: - error = json[0]['error'] - if 'VALID_TOKEN_REQUIRED' in json[0]['error'] and json[0]['error']['VALID_TOKEN_REQUIRED'] == u"Invalid CSRF token": - invalid_token = True - - # Got invalid token error - if error: - if invalid_token and try_reload_api: - __getApiToken() - return getTrack(sng_id, False) - else: - logger.error(u"An unknown error occurred in the Deezer track parser: %s" % error) - else: - try: - results = json[0]['results'] - item = results['data'][0] - if 'token' in item: - logger.error(u"An unknown error occurred in the Deezer parser: Uploaded Files are currently not supported") - return - - sng_id = item["SNG_ID"] - md5Origin = item["MD5_ORIGIN"] - sng_format = 3 - - if item["FILESIZE_MP3_320"] <= 0: - if item["FILESIZE_MP3_256"] > 0: - sng_format = 5 - else: - sng_format = 1 - - mediaVersion = int(item["MEDIA_VERSION"]) - item['downloadUrl'] = __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion) - - __tracks_cache[sng_id] = item - return item - - except Exception as e: - logger.error(u"An unknown error occurred in the Deezer track parser: %s" % e) - - -def __getDownloadUrl(md5Origin, sng_id, sng_format, mediaVersion): - urlPart = md5Origin.encode('utf-8') + b'\xa4' + str(sng_format) + b'\xa4' + str(sng_id) + b'\xa4' + str(mediaVersion) - md5val = md5(urlPart).hexdigest() - urlPart = md5val + b'\xa4' + urlPart + b'\xa4' - cipher = AES.new('jo6aey6haid2Teih', AES.MODE_ECB) - ciphertext = cipher.encrypt(__pad(urlPart, AES.block_size)) - return "http://e-cdn-proxy-" + md5Origin[:1] + ".deezer.com/mobile/1/" + binascii.hexlify(ciphertext).lower() - - -def __pad(raw, block_size): - if (len(raw) % block_size == 0): - return raw - padding_required = block_size - (len(raw) % block_size) - padChar = b'\x00' - data = raw + padding_required * padChar - return data - - -def __tagTrack(path, track): - try: - album = getAlbum(track['ALB_ID']) - - f = MediaFile(path) - f.artist = track['ART_NAME'] - f.album = track['ALB_TITLE'] - f.title = track['SNG_TITLE'] - f.track = track['TRACK_NUMBER'] - f.tracktotal = album['nb_tracks'] - f.disc = track['DISK_NUMBER'] - f.bpm = track['BPM'] - f.date = datetime.strptime(album['release_date'], '%Y-%m-%d').date() - f.albumartist = album['artist']['name'] - if u'genres' in album and u'data' in album['genres']: - f.genres = [genre['name'] for genre in album['genres']['data']] - - f.save() - - except Exception as e: - logger.error(u'Unable to tag deezer track "%s": %s' % (path, e)) - - -def decryptTracks(paths): - """Decrypt downloaded deezer tracks. - - :param paths: list of path to deezer tracks (*.dzr files). - """ - # Note: tracks can be from different albums - decrypted_tracks = {} - - # First pass: load tracks data - for path in paths: - try: - album_folder = os.path.dirname(path) - sng_id = os.path.splitext(os.path.basename(path))[0] - track = getTrack(sng_id) - if track: - track_number = int(track['TRACK_NUMBER']) - disk_number = int(track['DISK_NUMBER']) - - if album_folder not in decrypted_tracks: - decrypted_tracks[album_folder] = {} - - if disk_number not in decrypted_tracks[album_folder]: - decrypted_tracks[album_folder][disk_number] = {} - - decrypted_tracks[album_folder][disk_number][track_number] = track - - except Exception as e: - logger.error(u'Unable to load deezer track infos "%s": %s' % (path, e)) - - # Second pass: decrypt tracks - for album_folder in decrypted_tracks: - multi_disks = len(decrypted_tracks[album_folder]) > 1 - for disk_number in decrypted_tracks[album_folder]: - for track_number, track in decrypted_tracks[album_folder][disk_number].items(): - try: - filename = helpers.replace_illegal_chars(track['SNG_TITLE']).strip() - filename = ('{:02d}'.format(track_number) + '_' + filename + '.mp3') - - # Add a 'cd x' sub-folder if album has more than one disk - disk_folder = os.path.join(album_folder, 'cd ' + str(disk_number)) if multi_disks else album_folder - - dest = os.path.join(disk_folder, filename).encode(headphones.SYS_ENCODING, 'replace') - - # Decrypt track if not already done - if not os.path.exists(dest): - try: - __decryptDownload(path, sng_id, dest) - __tagTrack(dest, track) - except Exception as e: - logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) - if os.path.exists(dest): - os.remove(dest) - decrypted_tracks[album_folder][disk_number].pop(track_number) - continue - - decrypted_tracks[album_folder][disk_number][track_number]['path'] = dest - - except Exception as e: - logger.error(u'Unable to decrypt deezer track "%s": %s' % (path, e)) - - return decrypted_tracks - - -def __decryptDownload(source, sng_id, dest): - interval_chunk = 3 - chunk_size = 2048 - blowFishKey = __getBlowFishKey(sng_id) - i = 0 - iv = "\x00\x01\x02\x03\x04\x05\x06\x07" - - dest_folder = os.path.dirname(dest) - if not os.path.exists(dest_folder): - os.makedirs(dest_folder) - - f = open(source, "rb") - fout = open(dest, "wb") - try: - chunk = f.read(chunk_size) - while chunk: - if(i % interval_chunk == 0): - cipher = Blowfish.new(blowFishKey, Blowfish.MODE_CBC, iv) - chunk = cipher.decrypt(__pad(chunk, Blowfish.block_size)) - - fout.write(chunk) - i += 1 - chunk = f.read(chunk_size) - finally: - f.close() - fout.close() - - -def __getBlowFishKey(encryptionKey): - if encryptionKey < 1: - encryptionKey *= -1 - - hashcode = md5(str(encryptionKey)).hexdigest() - hPart = hashcode[0:16] - lPart = hashcode[16:32] - parts = ['g4el58wc0zvf9na1', hPart, lPart] - - return __xorHex(parts) - - -def __xorHex(parts): - data = "" - for i in range(0, 16): - character = ord(parts[0][i]) - - for j in range(1, len(parts)): - character ^= ord(parts[j][i]) - - data += chr(character) - - return data diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 3b6f493f..38e4bba5 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -30,7 +30,7 @@ from beetsplug import lyrics as beetslyrics from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync from headphones import logger, helpers, mb, music_encoder -from headphones import metadata, deezloader +from headphones import metadata postprocessor_lock = threading.Lock() @@ -48,8 +48,6 @@ def checkFolder(): single = False if album['Kind'] == 'nzb': download_dir = headphones.CONFIG.DOWNLOAD_DIR - elif album['Kind'] == 'ddl': - download_dir = headphones.CONFIG.DOWNLOAD_DDL_DIR else: if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3: download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY @@ -207,7 +205,6 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid]) downloaded_track_list = [] - downloaded_deezer_list = [] downloaded_cuecount = 0 for r, d, f in os.walk(albumpath): @@ -216,10 +213,8 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list.append(os.path.join(r, files)) elif files.lower().endswith('.cue'): downloaded_cuecount += 1 - elif files.lower().endswith('.dzr'): - downloaded_deezer_list.append(os.path.join(r, files)) # 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', '.aria2')) and not forced: + elif files.lower().endswith(('.part', '.utpart')) and not forced: logger.info( "Looks like " + os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + " isn't complete yet. Will try again on the next run") @@ -258,37 +253,6 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal downloaded_track_list = helpers.get_downloaded_track_list(albumpath) keep_original_folder = False - # Decrypt deezer tracks - if downloaded_deezer_list: - logger.info('Decrypting deezer tracks') - decrypted_deezer_list = deezloader.decryptTracks(downloaded_deezer_list) - - # Check if album is complete based on album duration only - # (total track numbers is not determinant enough due to hidden tracks for eg) - db_track_duration = 0 - downloaded_track_duration = 0 - try: - for track in tracks: - db_track_duration += track['TrackDuration'] / 1000 - except: - downloaded_track_duration = False - - try: - for disk_number in decrypted_deezer_list[albumpath]: - for track in decrypted_deezer_list[albumpath][disk_number].values(): - downloaded_track_list.append(track['path']) - downloaded_track_duration += int(track['DURATION']) - except: - downloaded_track_duration = False - - if not downloaded_track_duration or not db_track_duration or abs(downloaded_track_duration - db_track_duration) > 240: - logger.info("Looks like " + - os.path.basename(albumpath).decode(headphones.SYS_ENCODING, 'replace') + - " isn't complete yet (duration mismatch). Will try again on the next run") - return - - downloaded_track_list = list(set(downloaded_track_list)) # Remove duplicates - # test #1: metadata - usually works logger.debug('Verifying metadata...') diff --git a/headphones/searcher.py b/headphones/searcher.py index f6554cb1..b0652f7f 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -36,7 +36,6 @@ import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent -from headphones import deezloader, aria2 from bencode import bencode, bdecode # Magnet to torrent services, for Black hole. Stolen from CouchPotato. @@ -52,27 +51,6 @@ ruobj = None # Persistent RED API object redobj = None -# Persistent Aria2 RPC object -__aria2rpc_obj = None - - -def getAria2RPC(): - global __aria2rpc_obj - if not __aria2rpc_obj: - __aria2rpc_obj = aria2.Aria2JsonRpc( - ID='headphones', - uri=headphones.CONFIG.ARIA_HOST, - token=headphones.CONFIG.ARIA_TOKEN if headphones.CONFIG.ARIA_TOKEN else None, - http_user=headphones.CONFIG.ARIA_USERNAME if headphones.CONFIG.ARIA_USERNAME else None, - http_passwd=headphones.CONFIG.ARIA_PASSWORD if headphones.CONFIG.ARIA_PASSWORD else None - ) - return __aria2rpc_obj - - -def reconfigure(): - global __aria2rpc_obj - __aria2rpc_obj = None - def fix_url(s, charset="utf-8"): """ @@ -303,53 +281,32 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): headphones.CONFIG.STRIKE or headphones.CONFIG.TQUATTRECENTONZE) - DDL_PROVIDERS = (headphones.CONFIG.DEEZLOADER) - + results = [] myDB = db.DBConnection() albumlength = myDB.select('SELECT sum(TrackDuration) from tracks WHERE AlbumID=?', [album['AlbumID']])[0][0] - nzb_results = None - torrent_results = None - ddl_results = None - if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download: if NZB_PROVIDERS and NZB_DOWNLOADERS: - nzb_results = searchNZB(album, new, losslessOnly, albumlength) + results = searchNZB(album, new, losslessOnly, albumlength) - if not nzb_results: - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength) - - if TORRENT_PROVIDERS: - torrent_results = searchTorrent(album, new, losslessOnly, albumlength) + if not results and TORRENT_PROVIDERS: + results = searchTorrent(album, new, losslessOnly, albumlength) elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download: if TORRENT_PROVIDERS: - torrent_results = searchTorrent(album, new, losslessOnly, albumlength) + results = searchTorrent(album, new, losslessOnly, albumlength) - if not torrent_results: - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength) - - if NZB_PROVIDERS and NZB_DOWNLOADERS: - nzb_results = searchNZB(album, new, losslessOnly, albumlength) - - elif headphones.CONFIG.PREFER_TORRENTS == 3 and not choose_specific_download: - - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength) - - if not ddl_results: - if TORRENT_PROVIDERS: - torrent_results = searchTorrent(album, new, losslessOnly, albumlength) - - if NZB_PROVIDERS and NZB_DOWNLOADERS: - nzb_results = searchNZB(album, new, losslessOnly, albumlength) + if not results and NZB_PROVIDERS and NZB_DOWNLOADERS: + results = searchNZB(album, new, losslessOnly, albumlength) else: + + nzb_results = None + torrent_results = None + if NZB_PROVIDERS and NZB_DOWNLOADERS: nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download) @@ -357,19 +314,13 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False): torrent_results = searchTorrent(album, new, losslessOnly, albumlength, choose_specific_download) - if DDL_PROVIDERS: - ddl_results = searchDdl(album, new, losslessOnly, albumlength, choose_specific_download) + if not nzb_results: + nzb_results = [] - if not nzb_results: - nzb_results = [] + if not torrent_results: + torrent_results = [] - if not torrent_results: - torrent_results = [] - - if not ddl_results: - ddl_results = [] - - results = nzb_results + torrent_results + ddl_results + results = nzb_results + torrent_results if choose_specific_download: return results @@ -875,31 +826,6 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error('Couldn\'t write NZB file: %s', e) return - - elif kind == 'ddl': - folder_name = '%s - %s [%s]' % ( - helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), - helpers.latinToAscii(album['AlbumTitle']).encode('UTF-8').replace('/', '_'), - get_year_from_release_date(album['ReleaseDate'])) - - # Aria2 downloader - if headphones.CONFIG.DDL_DOWNLOADER == 0: - logger.info("Sending download to Aria2") - - try: - deezer_album = deezloader.getAlbumByLink(bestqual[2]) - - for album_track in deezer_album['tracks']['data']: - track = deezloader.getTrack(album_track['id']) - if track: - filename = track['SNG_ID'] + '.dzr' - logger.debug(u'Sending song "%s" to Aria' % track['SNG_TITLE']) - getAria2RPC().addUri([track['downloadUrl']], {'out': filename, 'auto-file-renaming': 'false', 'continue': 'true', 'dir': folder_name}) - - except Exception as e: - logger.error(u'Error sending torrent to Aria2. Are you sure it\'s running? (%s)' % e) - return - else: folder_name = '%s - %s [%s]' % ( helpers.latinToAscii(album['ArtistName']).encode('UTF-8').replace('/', '_'), @@ -1278,62 +1204,6 @@ def verifyresult(title, artistterm, term, lossless): return True -def searchDdl(album, new=False, losslessOnly=False, albumlength=None, - choose_specific_download=False): - reldate = album['ReleaseDate'] - year = get_year_from_release_date(reldate) - - # MERGE THIS WITH THE TERM CLEANUP FROM searchNZB - dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ', '"': '', ',': ' ', - '*': ''} - - semi_cleanalbum = helpers.replace_all(album['AlbumTitle'], dic) - cleanalbum = helpers.latinToAscii(semi_cleanalbum) - semi_cleanartist = helpers.replace_all(album['ArtistName'], dic) - cleanartist = helpers.latinToAscii(semi_cleanartist) - - # Use provided term if available, otherwise build our own (this code needs to be cleaned up since a lot - # of these torrent providers are just using cleanartist/cleanalbum terms - if album['SearchTerm']: - term = album['SearchTerm'] - elif album['Type'] == 'part of': - term = cleanalbum + " " + year - else: - # FLAC usually doesn't have a year for some reason so I'll leave it out - # Various Artist albums might be listed as VA, so I'll leave that out too - # Only use the year if the term could return a bunch of different albums, i.e. self-titled albums - if album['ArtistName'] in album['AlbumTitle'] or len(album['ArtistName']) < 4 or len( - album['AlbumTitle']) < 4: - term = cleanartist + ' ' + cleanalbum + ' ' + year - elif album['ArtistName'] == 'Various Artists': - term = cleanalbum + ' ' + year - else: - term = cleanartist + ' ' + cleanalbum - - # Replace bad characters in the term and unicode it - term = re.sub('[\.\-\/]', ' ', term).encode('utf-8') - artistterm = re.sub('[\.\-\/]', ' ', cleanartist).encode('utf-8', 'replace') - - logger.debug(u'Using search term: "%s"' % helpers.latinToAscii(term)) - - resultlist = [] - - # Deezer only provides lossy - if headphones.CONFIG.DEEZLOADER and not losslessOnly: - resultlist += deezloader.searchAlbum(album['ArtistName'], album['AlbumTitle'], album['SearchTerm'], int(albumlength / 1000)) - - # attempt to verify that this isn't a substring result - # when looking for "Foo - Foo" we don't want "Foobar" - # this should be less of an issue when it isn't a self-titled album so we'll only check vs artist - results = [result for result in resultlist if verifyresult(result[0], artistterm, term, losslessOnly)] - - # Additional filtering for size etc - if results and not choose_specific_download: - results = more_filtering(results, album, albumlength, new) - - return results - - def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, choose_specific_download=False): global apolloobj # persistent apollo.rip api object to reduce number of login attempts @@ -2166,10 +2036,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, def preprocess(resultlist): for result in resultlist: - if result[3] == deezloader.PROVIDER_NAME: - return True, result - - elif result[4] == 'torrent': + if result[4] == 'torrent': # rutracker always needs the torrent data if result[3] == 'rutracker.org': diff --git a/headphones/webserve.py b/headphones/webserve.py index 648e293b..2b0f2699 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1185,10 +1185,6 @@ class WebInterface(object): "utorrent_username": headphones.CONFIG.UTORRENT_USERNAME, "utorrent_password": headphones.CONFIG.UTORRENT_PASSWORD, "utorrent_label": headphones.CONFIG.UTORRENT_LABEL, - "aria_host": headphones.CONFIG.ARIA_HOST, - "aria_password": headphones.CONFIG.ARIA_PASSWORD, - "aria_token": headphones.CONFIG.ARIA_TOKEN, - "aria_username": headphones.CONFIG.ARIA_USERNAME, "nzb_downloader_sabnzbd": radio(headphones.CONFIG.NZB_DOWNLOADER, 0), "nzb_downloader_nzbget": radio(headphones.CONFIG.NZB_DOWNLOADER, 1), "nzb_downloader_blackhole": radio(headphones.CONFIG.NZB_DOWNLOADER, 2), @@ -1197,7 +1193,6 @@ class WebInterface(object): "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), - "ddl_downloader_aria": radio(headphones.CONFIG.DDL_DOWNLOADER, 0), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, @@ -1259,8 +1254,6 @@ class WebInterface(object): "use_tquattrecentonze": checked(headphones.CONFIG.TQUATTRECENTONZE), "tquattrecentonze_user": headphones.CONFIG.TQUATTRECENTONZE_USER, "tquattrecentonze_password": headphones.CONFIG.TQUATTRECENTONZE_PASSWORD, - "download_ddl_dir": headphones.CONFIG.DOWNLOAD_DDL_DIR, - "use_deezloader": checked(headphones.CONFIG.DEEZLOADER), "pref_qual_0": radio(headphones.CONFIG.PREFERRED_QUALITY, 0), "pref_qual_1": radio(headphones.CONFIG.PREFERRED_QUALITY, 1), "pref_qual_2": radio(headphones.CONFIG.PREFERRED_QUALITY, 2), @@ -1306,7 +1299,6 @@ class WebInterface(object): "prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0), "prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1), "prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2), - "prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3), "magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0), "magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1), "magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2), @@ -1465,7 +1457,7 @@ class WebInterface(object): "launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer", "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_nzbsorg", "use_omgwtfnzbs", "use_kat", "use_piratebay", "use_oldpiratebay", - "use_mininova", "use_waffles", "use_rutracker", "use_deezloader", + "use_mininova", "use_waffles", "use_rutracker", "use_apollo", "use_redacted", "use_strike", "use_tquattrecentonze", "preferred_bitrate_allow_lossless", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art", @@ -1599,9 +1591,6 @@ class WebInterface(object): # Reconfigure musicbrainz database connection with the new values mb.startmb() - # Reconfigure Aria2 - searcher.reconfigure() - raise cherrypy.HTTPRedirect("config") @cherrypy.expose From 6c0b4518828b230ded4ab91e4f79b56f48ef50c6 Mon Sep 17 00:00:00 2001 From: Noam Date: Thu, 18 May 2017 17:02:53 +0300 Subject: [PATCH 108/137] rutracker optional session cookie --- data/interfaces/default/config.html | 4 ++++ headphones/config.py | 1 + headphones/rutracker.py | 5 ++++- headphones/webserve.py | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 841df0d1..02f40718 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -649,6 +649,10 @@ +
+ + +
diff --git a/headphones/config.py b/headphones/config.py index 57af4c16..98b23785 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -248,6 +248,7 @@ _CONFIG_DEFINITIONS = { 'RUTRACKER_PASSWORD': (str, 'Rutracker', ''), 'RUTRACKER_RATIO': (str, 'Rutracker', ''), 'RUTRACKER_USER': (str, 'Rutracker', ''), + 'RUTRACKER_COOKIE': (str, 'Rutracker', ''), 'SAB_APIKEY': (str, 'SABnzbd', ''), 'SAB_CATEGORY': (str, 'SABnzbd', ''), 'SAB_HOST': (str, 'SABnzbd', ''), diff --git a/headphones/rutracker.py b/headphones/rutracker.py index 658d490e..7d094906 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -49,7 +49,10 @@ class Rutracker(object): # try again if not self.has_bb_session_cookie(r): time.sleep(10) - r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) + if headphones.CONFIG.RUTRACKER_COOKIE: + r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session':headphones.CONFIG.RUTRACKER_COOKIE}) + else: + r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) if self.has_bb_session_cookie(r): self.loggedin = True logger.info("Successfully logged in to rutracker") diff --git a/headphones/webserve.py b/headphones/webserve.py index 2b0f2699..ee353fe2 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1240,6 +1240,7 @@ class WebInterface(object): "rutracker_user": headphones.CONFIG.RUTRACKER_USER, "rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD, "rutracker_ratio": headphones.CONFIG.RUTRACKER_RATIO, + "rutracker_cookie": headphones.CONFIG.RUTRACKER_COOKIE, "use_apollo": checked(headphones.CONFIG.APOLLO), "apollo_username": headphones.CONFIG.APOLLO_USERNAME, "apollo_password": headphones.CONFIG.APOLLO_PASSWORD, From ecadf8cdabdd01820edebb01e4e5045fab832744 Mon Sep 17 00:00:00 2001 From: Noam Date: Thu, 18 May 2017 18:26:16 +0300 Subject: [PATCH 109/137] log cookie attempt --- headphones/rutracker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/headphones/rutracker.py b/headphones/rutracker.py index 7d094906..1c689504 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -50,6 +50,7 @@ class Rutracker(object): if not self.has_bb_session_cookie(r): time.sleep(10) if headphones.CONFIG.RUTRACKER_COOKIE: + logger.info("Attempting to log in using predefined cookie...") r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session':headphones.CONFIG.RUTRACKER_COOKIE}) else: r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) From 3110659633de1d3cada334bf4714f24b1c0b781c Mon Sep 17 00:00:00 2001 From: Noam Date: Thu, 18 May 2017 18:28:01 +0300 Subject: [PATCH 110/137] travis space --- headphones/rutracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/rutracker.py b/headphones/rutracker.py index 1c689504..7d18ca1e 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -51,7 +51,7 @@ class Rutracker(object): time.sleep(10) if headphones.CONFIG.RUTRACKER_COOKIE: logger.info("Attempting to log in using predefined cookie...") - r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session':headphones.CONFIG.RUTRACKER_COOKIE}) + r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE}) else: r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False) if self.has_bb_session_cookie(r): From 50de38ce824d6ea79c3cb69326862f34e82c251c Mon Sep 17 00:00:00 2001 From: Travis Golliher Date: Wed, 31 May 2017 21:04:30 -0700 Subject: [PATCH 111/137] Join notification support Initial Join by Joaoapps API Notification Support. See issue #2712. --- data/interfaces/default/config.html | 38 +++++ headphones/config.py | 4 + headphones/notifiers.py | 213 ++++++++++++++++++++-------- headphones/postprocessor.py | 5 + headphones/searcher.py | 4 + headphones/webserve.py | 15 +- 6 files changed, 219 insertions(+), 60 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 02f40718..3859d4a7 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -1372,6 +1372,23 @@ +
+
+ +
+
+
+ +
+
+ Comma separated list. Leave blank to send to all devices +
+
+ +
+
+
+ @@ -2168,6 +2185,27 @@ } }); + if ($("#join").is(":checked")) + { + $("#joinoptions").show(); + } + else + { + $("#joinoptions").hide(); + } + + + $("#join").click(function(){ + if ($("#join").is(":checked")) + { + $("#joinoptions").slideDown(); + } + else + { + $("#joinoptions").slideUp(); + } + }); + if ($("#twitter").is(":checked")) { $("#twitteroptions").show(); diff --git a/headphones/config.py b/headphones/config.py index 98b23785..83c73415 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -145,6 +145,10 @@ _CONFIG_DEFINITIONS = { 'IGNORED_FILES': (list, 'Advanced', []), # path 'INCLUDE_EXTRAS': (int, 'General', 0), 'INTERFACE': (str, 'General', 'default'), + 'JOIN_APIKEY': (str, 'Join', ''), + 'JOIN_DEVICEID': (str, 'Join', ''), + 'JOIN_ENABLED': (int, 'Join', 0), + 'JOIN_ONSNATCH': (int, 'Join', 0), 'JOURNAL_MODE': (str, 'Advanced', 'wal'), 'KAT': (int, 'Kat', 0), 'KAT_PROXY_URL': (str, 'Kat', ''), diff --git a/headphones/notifiers.py b/headphones/notifiers.py index f8f1b3f8..5664c2e9 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with Headphones. If not, see . -from urllib import urlencode +from urllib import urlencode, quote_plus import urllib import subprocess import json @@ -148,7 +148,8 @@ class PROWL(object): http_handler.request("POST", "/publicapi/add", - headers={'Content-type': "application/x-www-form-urlencoded"}, + headers={ + 'Content-type': "application/x-www-form-urlencoded"}, body=urlencode(data)) response = http_handler.getresponse() request_status = response.status @@ -203,20 +204,25 @@ class XBMC(object): url = host + '/xbmcCmds/xbmcHttp/?' + url_command if self.password: - return request.request_content(url, auth=(self.username, self.password)) + return request.request_content(url, + auth=(self.username, self.password)) else: return request.request_content(url) def _sendjson(self, host, method, params={}): - data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}] + data = [ + {'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}] headers = {'Content-Type': 'application/json'} url = host + '/jsonrpc' if self.password: - response = request.request_json(url, method="post", data=json.dumps(data), - headers=headers, auth=(self.username, self.password)) + response = request.request_json(url, method="post", + data=json.dumps(data), + headers=headers, auth=( + self.username, self.password)) else: - response = request.request_json(url, method="post", data=json.dumps(data), + response = request.request_json(url, method="post", + data=json.dumps(data), headers=headers) if response: @@ -247,7 +253,8 @@ class XBMC(object): logger.info('Sending notification command to XMBC @ ' + host) try: version = self._sendjson(host, 'Application.GetProperties', - {'properties': ['version']})['version']['major'] + {'properties': ['version']})[ + 'version']['major'] if version < 12: # Eden notification = header + "," + message + "," + time + "," + albumartpath @@ -256,9 +263,11 @@ class XBMC(object): request = self._sendhttp(host, notifycommand) else: # Frodo - params = {'title': header, 'message': message, 'displaytime': int(time), + params = {'title': header, 'message': message, + 'displaytime': int(time), 'image': albumartpath} - request = self._sendjson(host, 'GUI.ShowNotification', params) + request = self._sendjson(host, 'GUI.ShowNotification', + params) if not request: raise Exception @@ -323,22 +332,27 @@ class Plex(object): url = host + '/xbmcCmds/xbmcHttp/?' + command if self.password: - response = request.request_response(url, auth=(self.username, self.password)) + response = request.request_response(url, auth=( + self.username, self.password)) else: response = request.request_response(url) return response def _sendjson(self, host, method, params={}): - data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}] + data = [ + {'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}] headers = {'Content-Type': 'application/json'} url = host + '/jsonrpc' if self.password: - response = request.request_json(url, method="post", data=json.dumps(data), - headers=headers, auth=(self.username, self.password)) + response = request.request_json(url, method="post", + data=json.dumps(data), + headers=headers, auth=( + self.username, self.password)) else: - response = request.request_json(url, method="post", data=json.dumps(data), + response = request.request_json(url, method="post", + data=json.dumps(data), headers=headers) if response: @@ -352,7 +366,8 @@ class Plex(object): hosts = [x.strip() for x in self.server_hosts.split(',')] for host in hosts: - logger.info('Sending library update command to Plex Media Server@ ' + host) + logger.info( + 'Sending library update command to Plex Media Server@ ' + host) url = "%s/library/sections" % host if self.token: params = {'X-Plex-Token': self.token} @@ -369,7 +384,8 @@ class Plex(object): for s in sections: if s.getAttribute('type') == "artist": - url = "%s/library/sections/%s/refresh" % (host, s.getAttribute('key')) + url = "%s/library/sections/%s/refresh" % ( + host, s.getAttribute('key')) request.request_response(url, params=params) def notify(self, artist, album, albumartpath): @@ -381,10 +397,12 @@ class Plex(object): time = "3000" # in ms for host in hosts: - logger.info('Sending notification command to Plex client @ ' + host) + logger.info( + 'Sending notification command to Plex client @ ' + host) try: version = self._sendjson(host, 'Application.GetProperties', - {'properties': ['version']})['version']['major'] + {'properties': ['version']})[ + 'version']['major'] if version < 12: # Eden notification = header + "," + message + "," + time + "," + albumartpath @@ -393,15 +411,18 @@ class Plex(object): request = self._sendhttp(host, notifycommand) else: # Frodo - params = {'title': header, 'message': message, 'displaytime': int(time), + params = {'title': header, 'message': message, + 'displaytime': int(time), 'image': albumartpath} - request = self._sendjson(host, 'GUI.ShowNotification', params) + request = self._sendjson(host, 'GUI.ShowNotification', + params) if not request: raise Exception except Exception: - logger.error('Error sending notification request to Plex client @ ' + host) + logger.error( + 'Error sending notification request to Plex client @ ' + host) class NMA(object): @@ -433,7 +454,8 @@ class NMA(object): if len(keys) > 1: batch = True - response = p.push(title, event, message, priority=nma_priority, batch_mode=batch) + response = p.push(title, event, message, priority=nma_priority, + batch_mode=batch) if not response[api][u'code'] == u'200': logger.error(u'Could not send notification to NotifyMyAndroid') @@ -463,7 +485,8 @@ class PUSHBULLET(object): headers = {'Content-type': "application/json", 'Authorization': 'Bearer ' + headphones.CONFIG.PUSHBULLET_APIKEY} - response = request.request_json(url, method="post", headers=headers, data=json.dumps(data)) + response = request.request_json(url, method="post", headers=headers, + data=json.dumps(data)) if response: logger.info(u"PushBullet notifications sent.") @@ -492,7 +515,8 @@ class PUSHALOT(object): http_handler.request("POST", "/api/sendmessage", - headers={'Content-type': "application/x-www-form-urlencoded"}, + headers={ + 'Content-type': "application/x-www-form-urlencoded"}, body=urlencode(data)) response = http_handler.getresponse() request_status = response.status @@ -512,6 +536,49 @@ class PUSHALOT(object): return False +class JOIN(object): + def __init__(self): + + self.enabled = headphones.CONFIG.JOIN_ENABLED + self.apikey = headphones.CONFIG.JOIN_APIKEY + self.deviceid = headphones.CONFIG.JOIN_DEVICEID + self.url = 'https://joinjoaomgcd.appspot.com/_ah/' \ + 'api/messaging/v1/sendPush?apikey={apikey}' \ + '&title={title}&text={text}' \ + '&icon={icon}' + + def notify(self, message, event): + if not headphones.CONFIG.JOIN_ENABLED or \ + not headphones.CONFIG.JOIN_APIKEY: + return + + icon = "https://cdn.rawgit.com/Headphones/" \ + "headphones/develop/data/images/headphoneslogo.png" + + if not self.deviceid: + self.deviceid = "group.all" + l = [x.strip() for x in self.deviceid.split(',')] + if len(l) > 1: + self.url += '&deviceIds={deviceid}' + else: + self.url += '&deviceId={deviceid}' + + response = urllib2.urlopen(self.url.format(apikey=self.apikey, + title=quote_plus(event), + text=quote_plus( + message.encode( + "utf-8")), + icon=icon, + deviceid=self.deviceid)) + + if response: + logger.info(u"Join notifications sent.") + return True + else: + logger.error(u"Join notification failed.") + return False + + class Synoindex(object): def __init__(self, util_loc='/usr/syno/bin/synoindex'): self.util_loc = util_loc @@ -539,7 +606,8 @@ class Synoindex(object): cmd = [self.util_loc, cmd_arg, path] logger.info("Calling synoindex command: %s" % str(cmd)) try: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=headphones.PROG_DIR) out, error = p.communicate() # synoindex never returns any codes other than '0', highly irritating @@ -580,7 +648,8 @@ class PUSHOVER(object): headers = {'Content-type': "application/x-www-form-urlencoded"} - response = request.request_response(url, method="POST", headers=headers, data=data) + response = request.request_response(url, method="POST", + headers=headers, data=data) if response: logger.info(u"Pushover notifications sent.") @@ -614,7 +683,8 @@ class TwitterNotifier(object): def notify_snatch(self, title): if headphones.CONFIG.TWITTER_ONSNATCH: self._notifyTwitter( - common.notifyStrings[common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now()) + common.notifyStrings[ + common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now()) def notify_download(self, title): if headphones.CONFIG.TWITTER_ENABLED: @@ -623,11 +693,13 @@ class TwitterNotifier(object): def test_notify(self): return self._notifyTwitter( - "This is a test notification from Headphones at " + helpers.now(), force=True) + "This is a test notification from Headphones at " + helpers.now(), + force=True) def _get_authorization(self): - oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret) + oauth_consumer = oauth.Consumer(key=self.consumer_key, + secret=self.consumer_secret) oauth_client = oauth.Client(oauth_consumer) logger.info('Requesting temp token from Twitter') @@ -635,32 +707,41 @@ class TwitterNotifier(object): resp, content = oauth_client.request(self.REQUEST_TOKEN_URL, 'GET') if resp['status'] != '200': - logger.info('Invalid respond from Twitter requesting temp token: %s' % resp['status']) + logger.info( + 'Invalid respond from Twitter requesting temp token: %s' % + resp['status']) else: request_token = dict(parse_qsl(content)) headphones.CONFIG.TWITTER_USERNAME = request_token['oauth_token'] - headphones.CONFIG.TWITTER_PASSWORD = request_token['oauth_token_secret'] + headphones.CONFIG.TWITTER_PASSWORD = request_token[ + 'oauth_token_secret'] - return self.AUTHORIZATION_URL + "?oauth_token=" + request_token['oauth_token'] + return self.AUTHORIZATION_URL + "?oauth_token=" + request_token[ + 'oauth_token'] def _get_credentials(self, key): request_token = {} request_token['oauth_token'] = headphones.CONFIG.TWITTER_USERNAME - request_token['oauth_token_secret'] = headphones.CONFIG.TWITTER_PASSWORD + request_token[ + 'oauth_token_secret'] = headphones.CONFIG.TWITTER_PASSWORD request_token['oauth_callback_confirmed'] = 'true' - token = oauth.Token(request_token['oauth_token'], request_token['oauth_token_secret']) + token = oauth.Token(request_token['oauth_token'], + request_token['oauth_token_secret']) token.set_verifier(key) - logger.info('Generating and signing request for an access token using key ' + key) + logger.info( + 'Generating and signing request for an access token using key ' + key) - oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret) + oauth_consumer = oauth.Consumer(key=self.consumer_key, + secret=self.consumer_secret) logger.info('oauth_consumer: ' + str(oauth_consumer)) oauth_client = oauth.Client(oauth_consumer, token) logger.info('oauth_client: ' + str(oauth_client)) - resp, content = oauth_client.request(self.ACCESS_TOKEN_URL, method='POST', + resp, content = oauth_client.request(self.ACCESS_TOKEN_URL, + method='POST', body='oauth_verifier=%s' % key) logger.info('resp, content: ' + str(resp) + ',' + str(content)) @@ -669,14 +750,18 @@ class TwitterNotifier(object): logger.info('resp[status] = ' + str(resp['status'])) if resp['status'] != '200': - logger.info('The request for a token with did not succeed: ' + str(resp['status']), + logger.info('The request for a token with did not succeed: ' + str( + resp['status']), logger.ERROR) return False else: - logger.info('Your Twitter Access Token key: %s' % access_token['oauth_token']) - logger.info('Access Token secret: %s' % access_token['oauth_token_secret']) + logger.info('Your Twitter Access Token key: %s' % access_token[ + 'oauth_token']) + logger.info( + 'Access Token secret: %s' % access_token['oauth_token_secret']) headphones.CONFIG.TWITTER_USERNAME = access_token['oauth_token'] - headphones.CONFIG.TWITTER_PASSWORD = access_token['oauth_token_secret'] + headphones.CONFIG.TWITTER_PASSWORD = access_token[ + 'oauth_token_secret'] return True def _send_tweet(self, message=None): @@ -688,7 +773,8 @@ class TwitterNotifier(object): logger.info(u"Sending tweet: " + message) - api = twitter.Api(username, password, access_token_key, access_token_secret) + api = twitter.Api(username, password, access_token_key, + access_token_secret) try: api.PostUpdate(message) @@ -741,7 +827,8 @@ class OSX_NOTIFY(object): ) NSUserNotification = self.objc.lookUpClass('NSUserNotification') - NSUserNotificationCenter = self.objc.lookUpClass('NSUserNotificationCenter') + NSUserNotificationCenter = self.objc.lookUpClass( + 'NSUserNotificationCenter') NSAutoreleasePool = self.objc.lookUpClass('NSAutoreleasePool') if not NSUserNotification or not NSUserNotificationCenter: @@ -756,9 +843,11 @@ class OSX_NOTIFY(object): if text: notification.setInformativeText_(text) if sound: - notification.setSoundName_("NSUserNotificationDefaultSoundName") + notification.setSoundName_( + "NSUserNotificationDefaultSoundName") if image: - source_img = self.AppKit.NSImage.alloc().initByReferencingFile_(image) + source_img = self.AppKit.NSImage.alloc().initByReferencingFile_( + image) notification.setContentImage_(source_img) # notification.set_identityImage_(source_img) notification.setHasActionButton_(False) @@ -818,8 +907,9 @@ class SubSonicNotifier(object): self.host = self.host + "/" # Invoke request - request.request_response(self.host + "musicFolderSettings.view?scanNow", - auth=(self.username, self.password)) + request.request_response( + self.host + "musicFolderSettings.view?scanNow", + auth=(self.username, self.password)) class Email(object): @@ -827,13 +917,15 @@ class Email(object): message = MIMEText(message, 'plain', "utf-8") message['Subject'] = subject - message['From'] = email.utils.formataddr(('Headphones', headphones.CONFIG.EMAIL_FROM)) + message['From'] = email.utils.formataddr( + ('Headphones', headphones.CONFIG.EMAIL_FROM)) message['To'] = headphones.CONFIG.EMAIL_TO try: if headphones.CONFIG.EMAIL_SSL: - mailserver = smtplib.SMTP_SSL(headphones.CONFIG.EMAIL_SMTP_SERVER, - headphones.CONFIG.EMAIL_SMTP_PORT) + mailserver = smtplib.SMTP_SSL( + headphones.CONFIG.EMAIL_SMTP_SERVER, + headphones.CONFIG.EMAIL_SMTP_PORT) else: mailserver = smtplib.SMTP(headphones.CONFIG.EMAIL_SMTP_SERVER, headphones.CONFIG.EMAIL_SMTP_PORT) @@ -847,7 +939,8 @@ class Email(object): mailserver.login(headphones.CONFIG.EMAIL_SMTP_USER, headphones.CONFIG.EMAIL_SMTP_PASSWORD) - mailserver.sendmail(headphones.CONFIG.EMAIL_FROM, headphones.CONFIG.EMAIL_TO, + mailserver.sendmail(headphones.CONFIG.EMAIL_FROM, + headphones.CONFIG.EMAIL_TO, message.as_string()) mailserver.quit() return True @@ -858,7 +951,6 @@ class Email(object): class TELEGRAM(object): - def notify(self, message, status): if not headphones.CONFIG.TELEGRAM_ENABLED: return @@ -876,14 +968,17 @@ class TELEGRAM(object): # Send message to user using Telegram's Bot API try: - response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload) + response = requests.post(TELEGRAM_API % (token, "sendMessage"), + data=payload) except Exception, e: logger.info(u'Telegram notify failed: ' + str(e)) # Error logging sent_successfuly = True if not response.status_code == 200: - logger.info(u'Could not send notification to TelegramBot (token=%s). Response: [%s]', (token, response.text)) + logger.info( + u'Could not send notification to TelegramBot (token=%s). Response: [%s]', + (token, response.text)) sent_successfuly = False logger.info(u"Telegram notifications sent.") @@ -891,7 +986,6 @@ class TELEGRAM(object): class SLACK(object): - def notify(self, message, status): if not headphones.CONFIG.SLACK_ENABLED: return @@ -902,7 +996,8 @@ class SLACK(object): channel = headphones.CONFIG.SLACK_CHANNEL emoji = headphones.CONFIG.SLACK_EMOJI - payload = {'channel': channel, 'text': status + ': ' + message, 'icon_emoji': emoji} + payload = {'channel': channel, 'text': status + ': ' + message, + 'icon_emoji': emoji} try: response = requests.post(SLACK_URL, json=payload) @@ -911,7 +1006,9 @@ class SLACK(object): sent_successfuly = True if not response.status_code == 200: - logger.info(u'Could not send notification to Slack. Response: [%s]', (response.text)) + logger.info( + u'Could not send notification to Slack. Response: [%s]', + (response.text)) sent_successfuly = False logger.info(u"Slack notifications sent.") diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 38e4bba5..ed3c136b 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -574,6 +574,11 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, pushbullet = notifiers.PUSHBULLET() pushbullet.notify(pushmessage, statusmessage) + if headphones.CONFIG.JOIN_ENABLED: + logger.info(u"Join request") + join = notifiers.JOIN() + join.notify(pushmessage, statusmessage) + if headphones.CONFIG.TELEGRAM_ENABLED: logger.info(u"Telegram request") telegram = notifiers.TELEGRAM() diff --git a/headphones/searcher.py b/headphones/searcher.py index b0652f7f..17619977 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1076,6 +1076,10 @@ def send_to_downloader(data, bestqual, album): logger.info(u"Sending PushBullet notification") pushbullet = notifiers.PUSHBULLET() pushbullet.notify(name, "Download started") + if headphones.CONFIG.JOIN_ENABLED and headphones.CONFIG.JOIN_ONSNATCH: + logger.info(u"Sending Join notification") + join = notifiers.JOIN() + join.notify(name, "Download started") if headphones.CONFIG.SLACK_ENABLED and headphones.CONFIG.SLACK_ONSNATCH: logger.info(u"Sending Slack notification") slack = notifiers.SLACK() diff --git a/headphones/webserve.py b/headphones/webserve.py index ee353fe2..959af6ce 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1412,7 +1412,11 @@ class WebInterface(object): "slack_url": headphones.CONFIG.SLACK_URL, "slack_channel": headphones.CONFIG.SLACK_CHANNEL, "slack_emoji": headphones.CONFIG.SLACK_EMOJI, - "slack_onsnatch": checked(headphones.CONFIG.SLACK_ONSNATCH) + "slack_onsnatch": checked(headphones.CONFIG.SLACK_ONSNATCH), + "join_enabled": checked(headphones.CONFIG.JOIN_ENABLED), + "join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH), + "join_apikey": headphones.CONFIG.JOIN_APIKEY, + "join_deviceid": headphones.CONFIG.JOIN_DEVICEID } for k, v in config.iteritems(): @@ -1480,7 +1484,8 @@ class WebInterface(object): "osx_notify_enabled", "osx_notify_onsnatch", "boxcar_enabled", "boxcar_onsnatch", "songkick_enabled", "songkick_filter_enabled", "mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch", - "customauth", "idtag", "deluge_paused" + "customauth", "idtag", "deluge_paused", + "join_enabled", "join_onsnatch" ] for checked_config in checked_configs: if checked_config not in kwargs: @@ -1749,6 +1754,12 @@ class WebInterface(object): telegram = notifiers.TELEGRAM() telegram.notify("it works!", "lazers pew pew") + @cherrypy.expose + def testJoin(self): + logger.info("Testing Join notifications") + join = notifiers.JOIN() + join.notify("it works!", "Test message") + class Artwork(object): @cherrypy.expose From 334493d81e86e1cc9dcf4ca9c246eb5724e5dcc6 Mon Sep 17 00:00:00 2001 From: Travis Golliher Date: Wed, 31 May 2017 21:40:01 -0700 Subject: [PATCH 112/137] Join notification support Fix pep8 errors. --- headphones/notifiers.py | 80 +++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/headphones/notifiers.py b/headphones/notifiers.py index 5664c2e9..f98b129d 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -149,7 +149,8 @@ class PROWL(object): http_handler.request("POST", "/publicapi/add", headers={ - 'Content-type': "application/x-www-form-urlencoded"}, + 'Content-type': + "application/x-www-form-urlencoded"}, body=urlencode(data)) response = http_handler.getresponse() request_status = response.status @@ -216,9 +217,10 @@ class XBMC(object): url = host + '/jsonrpc' if self.password: - response = request.request_json(url, method="post", - data=json.dumps(data), - headers=headers, auth=( + response = request.request_json( + url, method="post", + data=json.dumps(data), + headers=headers, auth=( self.username, self.password)) else: response = request.request_json(url, method="post", @@ -229,8 +231,8 @@ class XBMC(object): return response[0]['result'] def update(self): - # From what I read you can't update the music library on a per directory or per path basis - # so need to update the whole thing + # From what I read you can't update the music library on a per + # directory or per path basis so need to update the whole thing hosts = [x.strip() for x in self.hosts.split(',')] @@ -257,9 +259,11 @@ class XBMC(object): 'version']['major'] if version < 12: # Eden - notification = header + "," + message + "," + time + "," + albumartpath + notification = header + "," + message + "," + time + \ + "," + albumartpath notifycommand = {'command': 'ExecBuiltIn', - 'parameter': 'Notification(' + notification + ')'} + 'parameter': 'Notification(' + + notification + ')'} request = self._sendhttp(host, notifycommand) else: # Frodo @@ -346,9 +350,10 @@ class Plex(object): url = host + '/jsonrpc' if self.password: - response = request.request_json(url, method="post", - data=json.dumps(data), - headers=headers, auth=( + response = request.request_json( + url, method="post", + data=json.dumps(data), + headers=headers, auth=( self.username, self.password)) else: response = request.request_json(url, method="post", @@ -360,8 +365,8 @@ class Plex(object): def update(self): - # From what I read you can't update the music library on a per directory or per path basis - # so need to update the whole thing + # From what I read you can't update the music library on a per + # directory or per path basis so need to update the whole thing hosts = [x.strip() for x in self.server_hosts.split(',')] @@ -405,9 +410,11 @@ class Plex(object): 'version']['major'] if version < 12: # Eden - notification = header + "," + message + "," + time + "," + albumartpath + notification = header + "," + message + "," + time + \ + "," + albumartpath notifycommand = {'command': 'ExecBuiltIn', - 'parameter': 'Notification(' + notification + ')'} + 'parameter': 'Notification(' + + notification + ')'} request = self._sendhttp(host, notifycommand) else: # Frodo @@ -422,7 +429,8 @@ class Plex(object): except Exception: logger.error( - 'Error sending notification request to Plex client @ ' + host) + 'Error sending notification request to Plex client @ ' + + host) class NMA(object): @@ -440,7 +448,8 @@ class NMA(object): message = "Headphones has snatched: " + snatched else: event = artist + ' - ' + album + ' complete!' - message = "Headphones has downloaded and postprocessed: " + artist + ' [' + album + ']' + message = "Headphones has downloaded and postprocessed: " + \ + artist + ' [' + album + ']' logger.debug(u"NMA event: " + event) logger.debug(u"NMA message: " + message) @@ -483,7 +492,8 @@ class PUSHBULLET(object): data['device_iden'] = self.deviceid headers = {'Content-type': "application/json", - 'Authorization': 'Bearer ' + headphones.CONFIG.PUSHBULLET_APIKEY} + 'Authorization': 'Bearer ' + + headphones.CONFIG.PUSHBULLET_APIKEY} response = request.request_json(url, method="post", headers=headers, data=json.dumps(data)) @@ -516,7 +526,8 @@ class PUSHALOT(object): http_handler.request("POST", "/api/sendmessage", headers={ - 'Content-type': "application/x-www-form-urlencoded"}, + 'Content-type': + "application/x-www-form-urlencoded"}, body=urlencode(data)) response = http_handler.getresponse() request_status = response.status @@ -591,7 +602,8 @@ class Synoindex(object): if not self.util_exists(): logger.warn( - "Error sending notification: synoindex utility not found at %s" % self.util_loc) + "Error sending notification: synoindex utility " + "not found at %s" % self.util_loc) return if os.path.isfile(path): @@ -600,7 +612,8 @@ class Synoindex(object): cmd_arg = '-A' else: logger.warn( - "Error sending notification: Path passed to synoindex was not a file or folder.") + "Error sending notification: Path passed to synoindex " + "was not a file or folder.") return cmd = [self.util_loc, cmd_arg, path] @@ -610,7 +623,8 @@ class Synoindex(object): stderr=subprocess.STDOUT, cwd=headphones.PROG_DIR) out, error = p.communicate() - # synoindex never returns any codes other than '0', highly irritating + # synoindex never returns any codes other than '0', + # highly irritating except OSError, e: logger.warn("Error sending notification: %s" % str(e)) @@ -684,12 +698,14 @@ class TwitterNotifier(object): if headphones.CONFIG.TWITTER_ONSNATCH: self._notifyTwitter( common.notifyStrings[ - common.NOTIFY_SNATCH] + ': ' + title + ' at ' + helpers.now()) + common.NOTIFY_SNATCH] + ': ' + title + ' at ' + + helpers.now()) def notify_download(self, title): if headphones.CONFIG.TWITTER_ENABLED: self._notifyTwitter(common.notifyStrings[ - common.NOTIFY_DOWNLOAD] + ': ' + title + ' at ' + helpers.now()) + common.NOTIFY_DOWNLOAD] + ': ' + + title + ' at ' + helpers.now()) def test_notify(self): return self._notifyTwitter( @@ -733,7 +749,8 @@ class TwitterNotifier(object): token.set_verifier(key) logger.info( - 'Generating and signing request for an access token using key ' + key) + 'Generating and signing request for an access token using key ' + + key) oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret) @@ -846,13 +863,14 @@ class OSX_NOTIFY(object): notification.setSoundName_( "NSUserNotificationDefaultSoundName") if image: - source_img = self.AppKit.NSImage.alloc().initByReferencingFile_( - image) + source_img = self.AppKit.NSImage.alloc().\ + initByReferencingFile_(image) notification.setContentImage_(source_img) # notification.set_identityImage_(source_img) notification.setHasActionButton_(False) - notification_center = NSUserNotificationCenter.defaultUserNotificationCenter() + notification_center = NSUserNotificationCenter.\ + defaultUserNotificationCenter() notification_center.deliverNotification_(notification) del pool @@ -873,7 +891,8 @@ class BOXCAR(object): def notify(self, title, message, rgid=None): try: if rgid: - message += '

MusicBrainz' % rgid + message += '

MusicBrainz' % rgid data = urllib.urlencode({ 'user_credentials': headphones.CONFIG.BOXCAR_TOKEN, @@ -977,7 +996,8 @@ class TELEGRAM(object): sent_successfuly = True if not response.status_code == 200: logger.info( - u'Could not send notification to TelegramBot (token=%s). Response: [%s]', + u'Could not send notification to TelegramBot ' + u'(token=%s). Response: [%s]', (token, response.text)) sent_successfuly = False From c612a228b66805b7df69ce6d03ffb1c8bda0a39a Mon Sep 17 00:00:00 2001 From: Travis Golliher Date: Wed, 31 May 2017 21:59:48 -0700 Subject: [PATCH 113/137] Fix pep8 error --- headphones/notifiers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/headphones/notifiers.py b/headphones/notifiers.py index f98b129d..63f2f2fb 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -749,8 +749,8 @@ class TwitterNotifier(object): token.set_verifier(key) logger.info( - 'Generating and signing request for an access token using key ' - + key) + 'Generating and signing request for an access token using key ' + + key) oauth_consumer = oauth.Consumer(key=self.consumer_key, secret=self.consumer_secret) From ae328a15aa6889ed1ba1de5090ba5e0ca7ef7aad Mon Sep 17 00:00:00 2001 From: Fish2 Date: Sun, 4 Jun 2017 11:41:07 +0100 Subject: [PATCH 114/137] lossless compression of images saved 93KB --- data/images/back_disabled.jpg | Bin 612 -> 576 bytes data/images/back_enabled.jpg | Bin 807 -> 772 bytes data/images/forward_disabled.jpg | Bin 635 -> 601 bytes data/images/forward_enabled.jpg | Bin 852 -> 817 bytes data/images/headphoneslogo.png | Bin 11540 -> 8948 bytes data/images/ui-bg_flat_0_aaaaaa_40x100.png | Bin 212 -> 79 bytes data/images/ui-bg_flat_0_eeeeee_40x100.png | Bin 220 -> 86 bytes data/images/ui-bg_flat_55_c0402a_40x100.png | Bin 206 -> 86 bytes data/images/ui-bg_flat_55_eeeeee_40x100.png | Bin 220 -> 86 bytes data/images/ui-bg_glass_100_f8f8f8_1x400.png | Bin 262 -> 125 bytes data/images/ui-bg_glass_35_dddddd_1x400.png | Bin 262 -> 121 bytes data/images/ui-bg_glass_60_eeeeee_1x400.png | Bin 262 -> 121 bytes .../ui-bg_inset-hard_75_999999_1x100.png | Bin 253 -> 114 bytes .../ui-bg_inset-soft_50_c9c9c9_1x100.png | Bin 281 -> 120 bytes data/images/ui-icons_3383bb_256x240.png | Bin 4549 -> 3770 bytes data/images/ui-icons_454545_256x240.png | Bin 6992 -> 3770 bytes data/images/ui-icons_70b2e1_256x240.png | Bin 4549 -> 3771 bytes data/images/ui-icons_999999_256x240.png | Bin 6986 -> 3771 bytes data/images/ui-icons_fbc856_256x240.png | Bin 4549 -> 3771 bytes .../default/images/MusicBrainz_Album_Icon.png | Bin 4266 -> 1364 bytes .../images/MusicBrainz_Artist_Icon.png | Bin 3823 -> 860 bytes data/interfaces/default/images/NoAlbumArt.png | Bin 29433 -> 25234 bytes data/interfaces/default/images/button.png | Bin 95 -> 89 bytes data/interfaces/default/images/icon_add.png | Bin 2845 -> 93 bytes .../interfaces/default/images/icon_delete.png | Bin 3148 -> 299 bytes data/interfaces/default/images/icon_extra.gif | Bin 757 -> 749 bytes data/interfaces/default/images/icon_gear.png | Bin 3228 -> 406 bytes .../default/images/icon_getextra.png | Bin 3007 -> 205 bytes .../default/images/icon_history.png | Bin 2324 -> 860 bytes data/interfaces/default/images/icon_like.png | Bin 1095 -> 176 bytes data/interfaces/default/images/icon_logs.png | Bin 3761 -> 251 bytes .../interfaces/default/images/icon_manage.png | Bin 3108 -> 264 bytes data/interfaces/default/images/icon_pause.png | Bin 2855 -> 99 bytes .../default/images/icon_refresh.png | Bin 3444 -> 485 bytes .../default/images/icon_removeextra.png | Bin 3123 -> 287 bytes .../interfaces/default/images/icon_search.gif | Bin 345 -> 335 bytes .../interfaces/default/images/icon_search.png | Bin 3342 -> 405 bytes .../default/images/icon_sprite_black.png | Bin 4369 -> 3699 bytes .../default/images/icon_sprite_white.png | Bin 4369 -> 3699 bytes .../default/images/icon_upcoming.png | Bin 3035 -> 228 bytes .../interfaces/default/images/icon_wanted.png | Bin 3403 -> 386 bytes .../default/images/loader_black.gif | Bin 9427 -> 8685 bytes .../interfaces/default/images/loader_blue.gif | Bin 9427 -> 8685 bytes .../default/images/loader_refresh_black.gif | Bin 847 -> 688 bytes .../default/images/loader_refresh_blue.gif | Bin 847 -> 688 bytes .../default/images/no-cover-art.png | Bin 25265 -> 16945 bytes .../default/images/no-cover-artist.png | Bin 8988 -> 3301 bytes data/interfaces/default/images/songkick.png | Bin 4128 -> 1838 bytes .../default/images/songkick_ribon.png | Bin 3266 -> 1127 bytes data/interfaces/default/images/sort_asc.png | Bin 968 -> 87 bytes data/interfaces/default/images/sort_both.png | Bin 135 -> 101 bytes data/interfaces/default/images/sort_desc.png | Bin 975 -> 83 bytes data/interfaces/default/images/toTop.gif | Bin 838 -> 56 bytes data/interfaces/default/images/trashcan.png | Bin 3317 -> 116 bytes .../default/js/fancybox/fancy_close.png | Bin 1517 -> 1050 bytes .../default/js/fancybox/fancy_loading.png | Bin 10195 -> 8138 bytes .../default/js/fancybox/fancy_nav_left.png | Bin 1446 -> 1003 bytes .../default/js/fancybox/fancy_nav_right.png | Bin 1454 -> 1010 bytes .../default/js/fancybox/fancy_shadow_e.png | Bin 107 -> 106 bytes .../default/js/fancybox/fancy_shadow_n.png | Bin 106 -> 98 bytes .../default/js/fancybox/fancy_shadow_s.png | Bin 111 -> 107 bytes .../default/js/fancybox/fancy_shadow_se.png | Bin 352 -> 326 bytes .../default/js/fancybox/fancy_shadow_w.png | Bin 103 -> 98 bytes .../default/js/fancybox/fancy_title_over.png | Bin 70 -> 68 bytes .../default/js/fancybox/fancybox-x.png | Bin 203 -> 158 bytes .../default/js/fancybox/fancybox-y.png | Bin 176 -> 128 bytes .../default/js/fancybox/fancybox.png | Bin 15287 -> 14284 bytes .../static/made_with_cherrypy_small.png | Bin 7455 -> 6347 bytes 68 files changed, 0 insertions(+), 0 deletions(-) diff --git a/data/images/back_disabled.jpg b/data/images/back_disabled.jpg index 1e73a546e3609636f9cf4c543c2a4fe4050866c3..11b1c688c2e5d8d262cfd3002d3de10007abeb8c 100644 GIT binary patch delta 384 zcmaFDa)4!mD9dez7KVwAY7+xkC#L7eGBPkTAOSXZRt_!!1_l;p78Xukm;@se3oA35 zAOpLQu!yLlA%~cfp)})UV@A#Tlqai%nkQ_0 z!RXtdxWT@RL#OjF*Q@nzAJk57+ueCrBx&xuFkcFK1Vhs z=|ns`=QhoxVx^H^C?m^*APK3T&PC^5GWr=j4Vt&!@!9+D+ivX0uKn(1boA4y{P0SP z)wQj9wh!B$GsVxiyL2lbr^7OpwCnX8Z@DhH6r@Mr}(qJ8h8qh~~~&|J7((Z}WJNi&INkz=~Wd~8htGH!x*ZzjYaiwi&3AMyI!+YA48 zzgcvt{I7Q8iq~firOTEt`_JICJM(_Zq*_5U{kkB*V_ delta 420 zcmX@W@`PoAsQMcQL6_3x>`Dek1{MYeh7<;d|L++198>a>QW;VX07V%8-)3lGn5d{c z(ST#3OMWZ^Gb2zG4%k@PIk*@ZQN)0fi~s zU_rwI-?D_WhTSsGjZ3DmeQYT$iV*)Xee18qM+#pTy{W4$3(T7PpCRjBmc=vi-qpp2 y<9Es$Jm2JbOQ388zf4uRy}kdos}-_uAO8-o|5N+=x)qn%jGFB=+tkMGTk%^gw zl}(VHfkQ}GMA1-GOxei9EKuCo(aG5@C^5CLabnZKhl~u2EHDiaeT>YkY%J^?f(%SR z^H^BeVfHXGF#ydIWD{bqS2T1KQ3~8Btn8H7H1Wg#TMRrv_b>@E3o_UQ`^Lw#{gkjtiY4<8*23B1^VQpL-JR`cQtD?K;PPX}QVCAICe~dO5H@!Q4L%{Y-#=L+hTkkH=JtrHw_RTlB_43_yp(jo}WzBhP z@NV`wX8u<;+N;%Hz0i>AXqfyxBsNgl`{WK6j*=_=HCw7qF4t!J(IwG$!%}N)%rY}e zJyydUtE7J>XP<@MxfB$xviY2^m-v*!^&eA96TfWQpzy1w>8MM;pvozplk;8`xPM>R z2XjSfnIOj{1v&yBO0rI$9A zm&r`)M&%3AVfeDTu=`n95PU?zV&aFmqG<+XpEWWg!prV#>}RD?7hxr2CjW4w4y;_N#NCN|Jx?k#VY zqg$x-59L)Ek;aZD$|k{Iho(Yt4~)fCvAlZK8}^61UdFx^LmclMk%b66@e*Qh?>>0_3AVsg)H+_?orSy@^7S2NG& z2>-9KR&v9eassj^fpzdGD3a-v3;9V~=UIN2Z%>t$4jb?2^a_+uD2tH9ZeM^-vXl zy7cC$xS3aLeU=(rS}n%id^Eem(#&VK^=^SMOy%y&AZJ=3@;GO6=OkfxVN)h-8z zfEmpK0uBy~Zz&(o;BVQpey!Zb>vL~U{_}oQcT&Z_?93gn&l*aX9bfjJA!$#^`%Rvg p9M1++ef)jn@4x!np)X^fzWx~Q|GQ2n!e+C{hljsz%-jF}CIE44pilq+ delta 458 zcmcb~@|$IXsQMcQL6_3x>`Dek1{MYeh7<;d|L++198>a>QW;VX07V%8-)3lGn5d{c z(ST#3OKUwNBO_20888YkFt9MQuyAq1L>ZZwSp-?x7=%RF6%BI;GImaN6BlG;U}8owicwI}&~f7bTMRrvaV9}#K?Zwg{S^76K&wI%uylH)9 z&%SfFBDqd$ug;8(R*K*Xosv=-P_JE7l*`!i&F81s=1!I~D?22jZ5&w-EMhKWVp2H% zOXhU$Ehag|n@;li%xABE&fHjV_3guzl4T{Se;udQPfl;T6&c{OQk{RQ!W93u<=I+Q z%R`N(9OhUPse5kfx(Ow@lG7^76xIrRd!^(TG@Lziag(O1@r)%wQxv|IG&C&KXMLd1 z(CFUc?z_NV=OT?~rI#?zbu(@APYvVo8AeQASR`evuF14YI1Q#*wgo68>5~U97$oel_fhoM zSb(S~C?tIt9QjCVY$h7-Ka^_`VIO$*pnzrxN>sSm6TX-mbLmaI>R4>LzX<2%DoxPh zlI<_*Qbu;VN%{*q%j)DKg<-3mID{pC(uwIp9WX&&q}nqtMGBaKg{$W6*8+$eCQ$(s zn!x5dcJSl#cJABVk0pqa`$^$L)g+1yJk#jb%7T;r=8btniD|mPQl@FT#*~x+l^8f$< delta 656 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
Uj1o}-3EM-{qEW}h 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 OVDNPHb6Mw<&;$Um5Hw-{ diff --git a/data/images/ui-bg_flat_0_eeeeee_40x100.png b/data/images/ui-bg_flat_0_eeeeee_40x100.png index 7d5e8992d650037502e77371f05b0051cef6eed9..ded77b966fffb2d55b6427e16d5ae6b4b52f6f3e 100755 GIT binary patch delta 68 zcmcb^7&bw|j**#xf#KY$2bMsJIlw2x_1(L7&7K!?fm}XM7srqa#^eQz60D0E7?>g$ V7-Jsp2m;D5c)I$ztaD0e0sy#o6TAQb literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F0wfqj{vTKlq?nSt-CY>?t&Hpz19_YU9+AaB z+5?Q;PG;Ky8FHR3jv*e$lPlU;4SKlxP~aja2Dul`ryR1bKLV;$Epd$~Nl7e8wMs5Z z1yT$~28JfO29~;pCLxBFR;H#_rpCGk=2iv$vY>tY55rU(Ycn1?%p7=Xaj)z4*}Q$iB}<}C{i delta 162 zcmWGL$2h^bp0mIsvY3H^!2^ulPG;Ky8T_6ujv*T7lM^IZ7dQN3U|@=1U@SFjD+fxc zmbgZgq$HN4S|t~y0x1R~149#C14~^)lMq8oD^pV|QzKmib1MUbO_KALqiD#@PsvQH ZMAaaJtmCkYnlw-cgQu&X%Q~loCIEsODhmJr diff --git a/data/images/ui-bg_flat_55_eeeeee_40x100.png b/data/images/ui-bg_flat_55_eeeeee_40x100.png index bb8ee0439a33fa6761857d7f0e54bfd0fd2146eb..ded77b966fffb2d55b6427e16d5ae6b4b52f6f3e 100755 GIT binary patch delta 68 zcmcb^7&bw|j**#xf#KY$2bMsJIlw2x_1(L7&7K!?fm}XM7srqa#^eQz60D0E7?>g$ V7-Jsp2m;D5c)I$ztaD0e0sy#o6TAQb literal 220 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F0wfqj{vTKlq?nSt-CY>?t&Hpz19_YU9+AaB z+5?Q;PG;Ky8FHR3jv*e$lPlU;4SKlxP~aja2Dul`ryR1bKLV;$Epd$~Nl7e8wMs5Z z1yT$~28JfO29~;pCLxBFR;H#_rbfC3=2ivcptHiD0u#1{BPy>Uf LtDnm{r-UW|0Kzt# diff --git a/data/images/ui-bg_glass_100_f8f8f8_1x400.png b/data/images/ui-bg_glass_100_f8f8f8_1x400.png index 86f59cbf1d962fb0176993ef8082f0c05d56f26b..3c07fdc38477283cffa3742d17c05c6b83fc4ed1 100755 GIT binary patch delta 95 zcmZo;s-2(`>Eh|)7*fIb_RvD!1_uF#gL_R)99cE27+P!Ee-)c}`?s)I9Z<+Oid+8S zbJY8r5~)*tXHC4+>wGV>KRk&)EV~~*=WdJF?)FK#IZ0z|ch3z*5)HB*f6t%GA`#)JWIB+{(aUljOYRC>nC}Q!>*kacem2q9zU0 Oz~JfX=d#Wzp$PybH&H+U diff --git a/data/images/ui-bg_glass_35_dddddd_1x400.png b/data/images/ui-bg_glass_35_dddddd_1x400.png index 8847220b3265dd1b744bfa72ab69831e04120366..047f4dc7bc418b3fc167f0dac12a136e235bc164 100755 GIT binary patch delta 91 zcmZo;s+^z_>fq_(7-DfcxoS&GXJevBuaef{`_p`S*nD^_eRxEDdUSnyif2eXGca72 ukQBj})VxF3;gJHXoKKI?)FK#IZ0z|ch3z*5)HB*f6t%GA`#)JWIB+{(aUljOYRC>nC}Q!>*kacem2q9zU0 Oz~JfX=d#Wzp$Pz0Ur%xX diff --git a/data/images/ui-bg_glass_60_eeeeee_1x400.png b/data/images/ui-bg_glass_60_eeeeee_1x400.png index 57760b4d1c19a264f76e9af1bfbe27345cf08c29..331975b993c6718f039f947b03e3cfece06b99a8 100755 GIT binary patch delta 91 zcmZo;s+^z_>fq_(7-Dfc`PZ3qM-CWtxd~3a`2M+1&nzFFjXpe?K0U5JJZpV=a(#G& vXGo|YxN+5|r&WQqr_6^ZX0f7KSBE2mNg|KtoX+3+3_#%N>gTe~DWM4f&`cv5 literal 262 zcmeAS@N?(olHy`uVBq!ia0vp^j6gI&0LWmFTHNUZq?nSt-Ch3w7g=q17Rci)@Q5r1 z(jH*!b~4)z#PD=+46!(!{Nv0ygDyA0s~2~d^SnPQd2hPGp8ZEZ9BhAh*tMlg z;~K5>A79!Z77HJKzMm)llf=F`X+KV#{193p(yNptz~H}Xrrw0rvJ-)3s+PD$l%yn< zq*^5xr2;7iBLhPdT?0#9Lz56gODj`TD^nv~19K|_gH4k2mZNCM%}>cptHiD0u#1{B PPy>UftDnm{r-UW|E}c{+ diff --git a/data/images/ui-bg_inset-hard_75_999999_1x100.png b/data/images/ui-bg_inset-hard_75_999999_1x100.png index b078d868b162602f366dee5c917a09568014d043..a4eeaa7c9e075f745d833c58c4f4d39ad04add4e 100755 GIT binary patch delta 84 zcmey%STsS!&(hPyF+}2W@(jz#osEeNf{QtYKbxtgrKvq*P@8t$M`!(vh%+-IbRBro o6sqMsWL7l1lo4)z&#+6Fp{k6_y7t&hVFn=ZboFyt=akR{07}6gssI20 literal 253 zcmeAS@N?(olHy`uVBq!ia0vp^j6j?s03;ZUuHXC*q?nSt-Ch3w7g=q17Rci)@Q5r1 z(jH*!b~4)z$guZxaSV~ToIJyFa^iuDYc}%~&z2P1VyLt4*oHp}hhJUgc`a|4GcoDL z!9yE<%O3tG*XCR<(dU4G zag0a8j*O!Po-GeAMYo+kF1b!8Wk=M}g0qtz-YRcC2YKdz^NlIc#s#S7PDv)9@GB7mJHL%n*Gzl@Zv@$idGBwgQFt;)= j*d#e`If{nd{FKbJO57R_yQoP6H86O(`njxgN@xNA{LfnS diff --git a/data/images/ui-icons_3383bb_256x240.png b/data/images/ui-icons_3383bb_256x240.png index c2eb45be75d5d556f668253c6bc4e081a1319c22..a305f6a83c6e235a9b31c2454f5ad1fbee63ee21 100755 GIT binary patch delta 3386 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 diff --git a/data/images/ui-icons_454545_256x240.png b/data/images/ui-icons_454545_256x240.png index b6db1acdd433be80a472b045018f25c7f2cf7e08..5fc455f22c4619a4eec4af020492e1fb9cce4e35 100755 GIT binary patch literal 3770 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 kY10(^b literal 6992 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 bmWhdVNGQusgP)^^fL|Q7c05vgm=ga#XQzmD delta 4189 zcmZu!XHXMd(@jDW2mz!?5s)Z~AT2aOYAC^A=paSlAxN>%i=q%nkg9Yk0t&(-RS+W5 zq(~@&Gyy|VLx~8|i*!QCr@k}a%sca*Kf8Bk&+NVD&Yan86!{=V(gHHu1zMO`0t8bH zfHs7E)IYxf008g?_KH3r79xWAeSrC5>;itrKRE_q(CwiG0Gx0&)Yq}P|82z)?n15Q zf_7tGEo44%e23*JelzO&>F4>cWlIee3Ew=u8VVM;gB;fwj1(aLyKhX4Z4a;4k5ri@ z7EW@g1~CXA0q5f4k_VkjUT%P?6GlZ1KB3lro~Mmzx3ZP=wR7uAbMhL2ZiU1Lrf1W{ z0Og={E7T`BZH_zK!=TGRm{(GYNhLyznEOczmz4?>#I4ko(G2IU@Z z;XjQF_%rQecUc4A>q@x$a8hbp_PiJ|hrLfK78t z^#7tG)^8z~mUxsGiS|I{>_+D5ycGh5wOw5AXC}eU*wORSY)_F{aDun)DLNS0Lec=H zfu&bP6$Eaq)YW}S^~^Fi)F_T4^CHr~Y?nmHGPYklM`K4XWuCzFea36v8RNsDr*Hc) z_5qW6_tc<~eiMW>&~G*VqESz~OM5;W+Pfxle@hy$=YKKq<3wX9ULD|JmZ!8X`h79Q zm1fsRX-#*pBvS#{S1n=oFs+BSYEtpH$z@DWSQ0y;&8<&g20Ax_f24*-_Up3z$RJRT#7|Y^#|5`)d4JUdA8(-^&G@6% zAA&CeoL^IN8kQQ#=U<4vH#Yc>JmXedQyJeV{TS)Sp$YW2!$F$GwLH2v`oeA=|!+3wv{rY65QPNDJ5M|5#38f1|^+TxuO{xUhwfW3M;82`2Ha z|IAvbj*EhkRLBotDp%csZ=V5{zNtCo2mJ#Wg<$w$_kbaCrq3@6v`_W}HIhD%BkhPd zsVyMrBLZXbmBgFfn!jydk)r)|lEBIw{tUbZgwwLRG(|~Eb1rB9!XTlyIWFKzl*UDm z%Px(H=z7)pjOLfe*copzw-TTCMmC%_xRs8Fn#g%qXdid~LQJnzLXgysK22HA!M zbQz$N+d=bTQPHbI^8>1QR?6|PWuq=`zW_-K za=V8kQifK}4u#~M=w^Y<$H~&K{CUK@_r6JReq0%|sfUSCx1y>A%x606kM^uQb>ALdehD zqn?1=^0GH_tF5YWDpq-&JA=$ZX~~~L*dldyzg>@7B#&FW2o;<}r&uE*``AF7OnF9u z|9XrovLvHLuU1zmDe=*+uT?yzKumD{fm>QQP)2Vd1I88(;{<|s*g-m_i5wVRZX?VH zDs|lj@)Sbm9TdIhrwIkz$s3yfv9aYsFeVQ%AG9d2)CbIQP*1OAbOWyvKQr`NpO`(ESIrKbf1HUg?DS zP2OofF#$d|L)3V3E(XTxOq-eM(1eonT~UikFbTqSP>&8Lm#3niETD$COwtc{l*x7{KIh@k zh0UgB_>A$GoRLQtY(C)q8ITLiD!nZM8w`Bty;cHzpEosaRj*{nQR$fE z1>ars2hW0sWABnCI=^gQ&#jxYuIN7Qtm14rvm)8Ft1Rdxl1k9qs7=@&Q0ONcJxIB^ z&kIqn>fUS~O?hjPvhXA6j!ly@Dz_YEoozHAZc{<)^uMclot<^IuW+SV^V;~QU&^6u z>zVVF!i7AqTQ>Ts^D1xDZ%90knd%t>dYg$?21IWNc|<7Ha<=fyP4B9{?=MUiszT2G z=$3zS?SLO{!<0|Bx8w0nUe(pF>P?(gW8z3WTFB84gi&Ri?mj9WSe>{+G03W3W2@`J zl0|7r8`~Gk^Zbt<#YdbF6f*T+JgD_w=_}r$pT2bV6tlZ8DOuR6?H@&#w{vInPD7F* z!sHj<^SzUYMUB5k$PMfh?c;T^&2h~rsyo79_ zo2vH!%FtU!ZD5Uej3K7|swJn8#Y`e^k6p3Bu@GL|iEu?-JYMUco;4o9!ly?z)8P?B z@dj)Hl-G_!nA6^wNhFA91<9vJ1BS zC2rDMVdZWFw}TaYaXLUJZ-=F@O;Zx)`sBcT0vF0}W*;9}hAmk&dA!;CaDISkk9SEf zH%v07W&f$EJ;;?|syU~Kp7Z2XzF_zDoc1rK`D&FEuPMNfRDLT$un_M^k!+4b11{YN zWA?@wX;-_;k1y#ckcuYv9_=(8m#)jLJgz9NB0}Ka-vXwkrD_e(pR6nZW4J&4V?$)=}+?YTE*yY|eoNK;5LE)>Bb$d;?^E2l2=q{Dw)Kehn zZ{w_c$u)EC=~A8qQwiC!GXP{y#wYHU5$4L$S>=?07KHF3vcwN1TT)y{2X#l~5^<}t z0;dSYLl3yutNP7&JNJE;FVHYy8W!5dRSG1@=Lk4rXzA_Em;r0wQNBIh8t+<7PzeNY z)6-w>@Vdns>GV`p-gGRo8yk$rdvcmNeO(-Q)G@aa<9Xj!y^A<<^)5hz<$Wf>1MCUFE86Lu`!$(^T+G5bafm_bDHoh!4NZf_mz{h-GP#0Icsx|fZxT4w@- zg~oRrX>YPKM^^Ilsli%fJl6i;H0@G%K)iN;*U?WvGkXS~d7C_xYz5@OHAaWBATYfi zjQU?p(jnk}F6-;PaaIXw1B7S81Z~5<%DY^%CFa;vBjW|P{^*3&F!HM3*5U{kH$U^^ zvaJWb0$xb!Wx?MjGX%G6Qm1e4-ih5m-mVZ*Moa>lZbyk`oA1Urs(2vBuPsi#jv5;g zxzg+*WZTC5VqG*&QLMIZ)6;IFW!Y)4=>AJkNhf&n4rsFWax4|MNf2W-hS>+_F_*Am z@0vOV_qOPdzvg`(cd@vF*~9ee3EpoKAR(3XL+_1G4moZk?1n^PpcTteXUVu{yRcmR zHTu%*hqmFV+Pgt1vZS!J8LGNiyMGLrw|`r99L!tL%k;chm3_%a454gPG=57SeF++s3h&yf z5BEMnu+jceo+ywJs}bl`b#4FbY|7#g8x*LT!-I-IYYF1KJ4GS^O@f)+W+sH;1ZhT} z@3SSgOkx(gnA90v-_H;waRHgGlMTRZp1}4*Ag{V86ZAcNeLqoT3SvN+rpkPX|42Z=x@CCo&5vVD_2psf!uR@ zkIVd=y_QW3zwHA{`9{$2!*!#C_+V$9JUHQks3(k#RG z@}(J{ne7Jw=U-$e#s0=TI~|Y06g?|x=y69>f_QJGe|U^$mJ{mLMJx|5)qX#n zPB^ydg3_;}(iw}3Q)8ftrsL2x5XVvFiU22Zl(F~)H3(`zEleC5=*J5)n5)T;vmbgk z3jE&7&j+Gy16`d1-PBzC-F^pv0#aU42B{!}l(#}Et0^j~$s=Wus%l82B#!*#zX)DF zuD9JE{D)xpzZkZf_ho-G1pl$bxUH$|Wf|Ef`&O8W5m8wpWGUuLWvOmU6yj!(EE&s0 zQ8eQwiL!6mlgPfyGRvd;c|HHd^Zwy_opXM;UZ3+h=lbDFu(dYh;}+uv0D$it-q;=h zAO|6^ii94VL+8wQ4+7gb-|R?Er|f(P}WHEiEQfRr-7& z&Wk8^=+qZJDP57Xcnj=Ng3Mnh9h4ua$AL{ZT02{`O~mVKKiqv)Q~0>Fm?BA4U_x^2 zj;(NWazK;vucVoiEBq$1%{&Zll2WcwWuB@W%!T8ev7uuaWO0-yaa0q1-+f!_H!TQ| z!Tqb^%axkB)#kzqBlN4>K3syJR1JPdiK9HC!r1=HV0hg}MKLcP&52r$MiKO(W<4Gn2}&|oyR9m}B~o7Kq_yH4->>Oo*dQCY5mDF_EfX_TC^GmU4OlXhNLG>6aTq^|?b z4<6j&#EPU}x*XG_*;%@-XQ&Lu;cuw$_soU=HNHve9;MNev_) zw5|BG!bIw9+FBrVvib9cMz|ls6t>qEZo8bsUL}Wi_4q)(VJi`parZE6u19-e#F=yHu~Gi9UE{7+*C0%BcM8?$)88awq#+Mt%M z6P;V$uJ9g#&T{#@X#cUAue!!m>x9|dk575mu+^@>qNGUXWgn&Z5>DRqEi-%&I7~xt zG5pRv`YQdZ0_(f$Ua5QK@?mVBwxJ_tH_t4pEk5SrJ$E`?*8?YnUOPo@FW#ShIv38B zCUxOGEH->bkUso*?P*qqFSb>IN5MbFFR{()1o~}Zt6TJ3P~Xp|#e=K!)xyaD>DQyy zKXOrs1tn2tqq17%IP!woeOi)K(uo@OR}CAR%&jroa}%CQt$IXC zY})Fe0BTW%^+fZZ*3Ok5ZI4|N`0GwFAMs+S3Rx(7zubyf{#UCPE8%wv;4(_pbCVmQ zPJHpa?KgI>zU>o9e1Tk}k#JAlA>O@UILK)D zcxu6>4l%uHO8vARe+Sq6X^XT{gnR#Ug&!&85bC!Boq(3Fh7FQlt~#BWZJc(prH#ZCl)iybU*wBImy?Mh%JPCw%XE!k+o3A}6f~@nnJh|v!S|phGXCKuY$8T0DVJyVc zuXa52!%jz~HOEgA-#jcUCPYa^j17~|hz0ez7(TP}{7e%bsOE1?;KYnwPuhaDQF(nX zVVf=hauBL)26rU{Y~g_&x$Fca0IdVI2;A*k>ljV&v!`?#4CeX*s(k~Fccwo)oowjr zfvS|)nS0_0Pxgqy_vw)b;yLAik#?mT5F}B{TPs6Ymsza`musV19_mM@OU|O`J2b`@hdF@L zKL*3g`J)l&Kk<)suO7Ij9e39kn(PY*#LIz7fKz#8_g@QgVN;7ZGhiLk3STV~`&|5v zH?0IbVG}p*ie~`>rzPn98f8DplWX}IcDw|LaW6dAEz+5m$&U?! zbd`bvwu%xU595Jw`7~AeJS4DQ`bpO`&UztR`Qg%eGSTR}A{`!3?f?+>)mTLAABgf{(OA6Xke?I59stC4n&Q2_K!ik%+lKMJ#*B_~BMSO`ZxIDa*F00+tXyF=O z*xf$tJtPc@e$~An8(~84p?W@*ARD1eNKw>(}3IANLDeLC?7v;5j zlaAQY>jqLb%*%PijM<4AL69woGs%l-E7KhuO=>B+`1Q9%RB>U(tWYW0ay0S1LH|29 zq()zF!b#~j3k3sTMU1X@gf6N8(pf@H z!nC7P32|yzZ?W2qHM8-kJB;neYJQVvW_kihkR9$FIOtSH@~O$m246d3(n?N_cnu1Bl>@Yypx@p(lUTSWg z*T!?ZAtr(tsj2&UMMXt_b^cu&1)XdOxh(GguT}r(l_r(TLOGma5i;daJYdCVnbEpR z!YP=QM4p5YZ?$qD-7j*`G$4#O8x0k_vR9{ETP^X|6mm((2hVRM~{QFQG3HS)y&tw_db6JV~HHV2rS zyG(8oXZu+Wkoy-tC!cG(!B-1Nr+&U)4DH5PBPJm~Bpg^6B!>{;Y;``r$)z((im zGmOGr2%6c37$tz}XCLV%3PUmfbYDUnBmDxDG+><o@cO9j(?Xhn-LvYYg1DzsasjVxZ4T%( zX{PC8DNG~B*}>a);(D$#7NEC%*qE{1nz3>B&Ew^-fA0A&Nxsmfy%{-kf4POGPaYs{ zt#B|ubPobBdJUz;M|Ng#By>M@Ft{TZPZg_xP`3OTP2K8!ggs^`*HG2nh!2DKl3i1= zC6crYv(td>%!gO29Rc3Zj;YfuXz!sS67B6r=3U1Tl^bEO;JI(ZY!*lH3q@hvl=pDg zt_D84fWwQh?@cPgoez=d^5?T&;YJVHNl85aHf%6#HP)Brf5Ylf{WD+tFiJDSI5lnX z#q6LW#eLwHH7Zhc3z14V{`VgDuR8$qJo(^-9WeXvDI7mHGUV*BoC)?p1m$A|0njHb zRU8~cyI_zZOeeGOFr3whY6rV%1bW44giYV~4uB}qcmDd$=5WOIuubZ?k1^b;1Hm;m k@Eg>llXu^B&du$T*n-SdunIA8@aF=~nOGZ_pC!ip4|QEa@Bjb+ literal 6986 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 diff --git a/data/images/ui-icons_fbc856_256x240.png b/data/images/ui-icons_fbc856_256x240.png index 69480efcac982e90105e43d2a08157656d1cfe9e..d2643ba374c92303095e3e271d14f2fa90662481 100755 GIT binary patch delta 3405 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 bmWhdVNGQusgP)^^fL|Q7c05vgm=ga#XQzmD delta 4189 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 Ef;1+hxBvhE literal 4266 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 aAO8Sr5O!*(fe{1%00004r literal 3823 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 diff --git a/data/interfaces/default/images/NoAlbumArt.png b/data/interfaces/default/images/NoAlbumArt.png index 146e1ccbe52cceedd4a1a610db69346cac90118c..a52551c29cd3d829b2b6d6bc6736dc0797a66feb 100644 GIT binary patch literal 25234 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 literal 29433 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 z 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 Qo&W#<07*qoM6N<$g6(rq4*&oF diff --git a/data/interfaces/default/images/button.png b/data/interfaces/default/images/button.png index a5fd783044772a546b3e1de91276077828a82a94..b5357ff756a8c8c22275737634c1e7c870b51c51 100644 GIT binary patch delta 69 zcma!#oFM7I!oa{_{;84?NGW)_IEHY{O#b!$z>yE~JrW5#7QfmLs(A>lU=eocKDP%VR}tDnm{r-UW|0NECU delta 75 zcma!ypCIYR#=yWZ_xHvwAf@K%;uyklJ^9D~|NEJRnVApQTO4dS<*NMep{VlYul-tX e56e|{u`?Fi8zoNP^y3y#2ZN`ppUXO@geCwmV;uJY diff --git a/data/interfaces/default/images/icon_add.png b/data/interfaces/default/images/icon_add.png index 9e5ccc471e47fb4a2f0ae10774d10d509666af4e..5cf8d66518969ea4203682c3f0c9cf3d20e9b0a7 100644 GIT binary patch delta 63 zcmbO$7CS-3QpMB7F~s6@a>4@96VVla?TzG28yXm2bafs*e6rzUiGwn82P=a`j_3y^ St?L;KK;Y@>=d#Wzp$Py5)fYwp delta 2837 zcmV+w3+nV;ofeQJe+h6%S#tmY3ljhU3ljkVnw%H_018iOLqkwdXm50Hb7*gHAW1_* zAaHVTW@&6?004N}ol|F2Q|T5x_ulkEONfA!OK(yY2q02Ii+~i7CMqEb5K4$4q1hEt z!4XA81RKbphy#v}fQ%JUEDVYY*azexqK<>3h>FVl;d`TNf7ZPB=FR@K*FNX0^PRKL z2fzpnmPj*EHGmAMLLL#|gU7_i;p8qrfeIvW01ybXWFd3?BLM*Temp!YBESc}00DT@ z3kU$fO`E_l9Ebl8>Oz@Z0f2-7z;ux~O9+4z06=<09Y^p6lP1rIRMx# z05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p00esgV8|mQcmRZ%02D^@S3L16t`O%c004NI zvOKvYIYoh62rY33S640`D9%Y2D-DpKGaQJ>aJVl|9x!Kv};eCNs@5@0A55SE>z01KgS3Fe*i?Ffhw>;8}z{#EWidF!3EsG3;bX< zghC|5!a@*23S@vBa$qT}fU&9EIRU@z1_9W=mEXoiz;4lcq~xDGvV5BgyUp1~-* zfe8db$Osc*A=-!mVv1NJjtCc-h4>-CNCXm#Bp}I%6j35eku^v$Qi@a{RY)E3J#qp$ ze}`N~x{*7`05XF7hP+2Hl!3BQJ=6@fL%FCo8iYoo3(#bAF`ADSpqtQgv>H8(HlgRx zt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>Xu_CMttHv6zR;&ZNiS=X8v3CR#fknUx zHUxJb=$GgN^mhym zh82Uyh-WAnn-~WeXBl@Gub51x8Pkgy$5b#kG3%J;nGcz7Rah#vDtr}@$_kZAl_r%N zDlb&2s-~*mstZ-~Rm)V5sa{ikf38MVGgITK3DlOWRjQp(>r)$3XQ?}=hpK0&Z&W{| zep&sA23f;Q!%st`QJ}G3I zcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya?2D1z#2HOnI7(B%_ac?{wFUQ;Q zQA1tBKtrWrm0_3Rgps+?e>|hrMvX=fjA_PP<0Rv4#%;!qeU1G+2MveW4yzqn9e#7PauhmNI^LSjobEq;#q^fxFK1ZK5YN~%R|78Dq|Iq-afF%KE1Brn_ zfm;Im_iKB_Ki zJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$3*&nim@mj(aCxE5!t{lw z7O5^0EIO7ze@uu@IF#@~5Gtq^j3x3DcO{MrdBPpSXCg1rHqnUKLtH8zPVz`9O?r~- zk-Rl|B*inOEaka`C#jIUObtxkn>wBrnsy*W_HW0 zWrec-#cqqYFCLW#$!oKatOZ#u3bsO~=u}!L*D43He`jS^X1~pe$~l&+o-57m%(Ked zkT;y~pa1O=!V=+2Q(!ODWcwE=7E3snl`g?;PX*X>OX6feMEuLErma3QLmkw?X+1j)X-&VBk_4Y;EFPF_I z+q;9dL%E~BJh;4Nr^(LEJ3myURP#>OB6F(@)2{oV%K? zxm;_x?s~noduI3P8=g1L-SoYA@fQEq)t)&$-M#aAZ}-Lb_1_lVesU-M&da;mcPH+x zyidGe^g!)F*+boj)jwPQ+}Q8je`>&Yf1vZx+(-8Yg@e!jk@b%cLj{kSkIRM)hU=a< zJ~=t!KXU!){HH_DWX~p^7yhFD%dQs|FMjyd>(|cFn9-q^@|TmpZG5Hu>cHz6uiM7L z#vZ=Ocr!6x^j7=r!FSwu9q*&x4^QNLAb%+TX!)`AQ_!dTlNpnf{{#b=^Za8oe=XYp z001CkNK#Dz0D2_=0Dyx40Qvs_0D$QL0Cg|`0P0`>06Lfe02gnPU&TfM002lyL_t(| z+G70w|33pIfCZOC{-PV`CNrX|nHZq nK(PUglxAlD00030{{sN>Cl3h94_N_t00000NkvXXu0mjf4~j`! diff --git a/data/interfaces/default/images/icon_delete.png b/data/interfaces/default/images/icon_delete.png index e019d2905fc0b8dc3b316f0fc929deaa59e459ce..b879ff8c159fc19308555897b12e210181ca0388 100644 GIT binary patch delta 272 zcmV+r0q_3I7^?!1B!BWrL_t(|0b-yUC|YzwzhnuZGP*EsAc&zboDNjVHWOxMtRN*g z%*jqFa49lF6(fDI@lrDq&+^WO@v8G7xTy%6>; zrl+tlXi!)Lz4FZ)_93Qz(+i+UF8&${%VGLEa6!&~8wyLuaDUV~{cXtccU@s|P6xxW z>+q|714|&W!+Re!hX6~Um=D=~uQ>!*28waW_FK&%z;aOZLpENj0XA#%wdx@pD=b2> z7qIqR^{^SEuT7(HuCN(9qt{q@s(9GIXn3M7P}l&&Gi&LgT)=(YUs`^w2=yXH!yR3i W$TNkB#ialM00{s|MNUMnLSTYK>UmNC delta 3143 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 delta 114 zcmaFM`ju6{-P6s&GEss-l;Ifz!~g&PHwwfvnoOQN*~G+z0S8e0$pV(q0g)h`4DA0K r7$(nVv}I(Ne3Ma`v25~RMq?&shRH@uI!r7LlM|RU7XMh=|y! zQ;g^ov1_OPfQS(h5ixceqf?C0H9E!UG$JA*A|hf$jK_$__$8~BJ#8K8*7xzBe`Qtf z`9r7e%h;S_oVDSOc>|l0teH1-*M@l$9!o~Xk|cY+NY+hQk$)sfk|eop!qA>1Ns=Tx zW=$9xOS0vXGp=|e$;hlD&U+-umLE=eEy)At{9;p*WWk}Uk|Zm>o3<}WGV9PeNs{+| zy5)-`S#)Sgl8jvQi#cnOJU8XQjQ5hUB>8OGgww{7BuSEt3{6;( zBuSDaxo^UzWPjI4l4RFNvSz}-s%3L#tx0mvjCuF1ns$t#v5d|6VbP9(-&(forUN^k z_$04wd*wvik|gU+aM6z3#VvVZ!7-+cBs0F*mv@eJ-bY!+4cRtuf|rsc>rOD`jfdXK kGfzGDM=skn^q;@zD=`Dydr^flAOHXW07*qoM6N<$g8R$5xBvhE literal 3228 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 O000025c>h~WFWo`#Q%{o zAv delta 3001 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 axcvr)>iaVu4M#Ts0000h9MW{E?clA@I4 zsFRSq>*+;gold8EN;<_TsaAR+u(8J z1Ob6SmdpSeN54W1FW6N7m8Yb<)i3r6I#&@aiBTl;WB`aPkVFGerid2{Z~&fQ%jQmi z2m%=|7lv>ZT-F)_Um}9@3^{P6NUBGJKtz&K%Hzia3TQMCD-`>{UNyGDphAHUY#o+` zWJ#$&oG>6o1_Y<94dJK6^YH>0$rnmg67&QjK*575MG0a#LFohg%uCSk4PgWf`Z+}r z?*sc=6qm(@QYA6~iiIQH`A8fJit~V@Jdk(~{7NVqi9#Y!Xav&J9fc%dJqbt@^veU& zSCa{12pk&YOD+A$2NtJLNC^l;a&j^}83UKdVi71j9*;nx5ool#KEqwUMXcZ{-Nkap z83r04=gWjrg-{}f8W?%el0=0MOt19s7DUpov|{;}YtmmBLdlaNP;jK7rI|z)>;I>U zL|?Pz3J&m(-v3D~57{CG5F9`*NtE&RgNt!Al#&vtGJvO$$U-ENgqd4p$4L|td7MNF zr3Pc+C@71^7m5v$&j=Qaz!b|BJTV_&(tKcg4!BS#AmC6Qo@6qXhNF67P$)Xe3rD5k z=@>K)gJIAZDC7*5CgCTF0I^~QEBFUX|0dR829Z=>nFh#&Nq~SMlZc?7n0G3cMW^R~*}okJ6T;H;=~Bku&)-prwpSGz4B|Fo$xun=-lXqJ-`U=~-tkx`L)xjz zTw@kLZm>7o#0{sVzh#{6awHs`w1apb@J9|O4w~i+aAF>k8wB{M&dxGHcJHK%qod=} zv$$5bx>b$w;N6c$RrSib*O8kS53#qKM3?M~aflu(Hg5PnbN$Lr@aVjzxfn>)^Nke8 ztQOANxVE#*G%>hItJRtnuC&qan6@{+ePiOy$Vh_A=$<;;9wlYe&#f@!un;n=31qRF zkGXpvS2pftXKHKb3=VTo8|4*x*+L+Y`_3*dM~X{Id^`Gl1};DDRc=!6@&?^63|NlC z%*}6F)@g+w#bmS$jvE(ZPYQnevFst%Z2kK6>9IBVtBngFS*7Pn8m0;6g-$)&qAx%i zB1_*nai15E2Qx?h$Yx%#LJJR>$9@Mc@;5~;O3tvt%mZq|jR=eFWvI56UG-<8kVnS< z#r4kE*ZAA~&}cW(y@?`eI@p^uh=$HZv-@`B==O`-DDa1!EvmzPQ z)hA92s#G<1Gyxq~s(YJwByS96bdI{u?9WT(<>iu&4vXh-$I3S7zV1eH6ni-W(f=Q( zici_Ksd3-ACgh*0sv<&kEcEU**5zBJN3Vt@H6hC6%ic!L@@wR-fWy<(MXi_K;dVM- zdTD9bOvxpAczKOQ5gkZnqW33CbsOd^q)zqYUa%W2uJjl^`JOVq{SV~{-Q+w9K4S#$ zRDYNX5=O2o=A7sO=p-?ttY12GeRa&UYfxDFz@D(c_?CtR94Y+Aq(`kd1!`bkty=G2m2wckTVdF`)MhYjuJUbi$`SEM*N9ybena8SNr|rY--J%rXUC^*u#=)z;Wx_)lTd*V3vfQ9J$uXTZsX diff --git a/data/interfaces/default/images/icon_like.png b/data/interfaces/default/images/icon_like.png index e3f638d17ed4bc8228020aeda4780a73e35e19f8..91a3a3bf99697f24bd6763812fba45a51665fda1 100644 GIT binary patch delta 148 zcmV;F0Birp2(ST=B!72FL_t(2Q)6I20*RB({10e7{-1#q0MY~k#d9wbuMuPm2#{nO zOjFyc8~;}xevDgV$J!fUlVKPJ@Y;r~5FH>J2E$QZ{Z4H7)28ZfHa~j*mvm_Jjl^Q4*oOl|QJUBu0CR5a*4u5` zDtA>91W}c07t_v;m#1P2{y%yUp2piwlP^6{`)WHUcmCOX_*V>0I z&^6qVQHJwMM5UrFWOI?n0y;o4S=0v%n=cB~iY|}O&NV}kD-e_ysMVl)q)svcEkHKV ztWRM>EE(cxE)Wcd!+S_S%drgWXE?u)3-bX#&$8s&M`3T4n&Z=A>zXfY2~XlV5@TqF{4H2i)aM)>R@1Ib07VS7soBCr)pGm!=j(ovMNa1aR;j&wBz-CUP7 z?6orCf-yzeWH_32QYrx@>Hkn&Uq{<04L0Kar?8zFG69nYHXO7R+_;?Aab@xe3&;ps z83+eTRqX7C2-^M7Bokc?q$Dev;n*v9N#aw6jbuXsDN&%X0f}nwyjDJz zKv}&tu3A2pU|BAMgJGP;{?+Kx6`mgFxIVVnSRZ^~;Ayw;SdT6}tj14bG9|_`#kbk{ z<>jc`Q`f=&h)&GSA6L17%VVypTX*jE)?X>iMBOzb*Jl<#zVH3@^&Iqw`B7J(J9fIe z?b?U?<2UVhua@hK$8FngdAj>52AZa-bKVn&wzs=q?0&Pb<@H(l>fOyd+*?~7JoC7x zKjmv(IoL9{=}E0R`+af$f-6>W;pSMf^`tv7`}0e|bCiCZYaIM79FWdWs27_)KV7ba s$0nX%I(54G#Eska-XOW;JyWxH#6<{W+3|ZLQ-0@Zr;;7w?Rf9#A98qWYXATM diff --git a/data/interfaces/default/images/icon_logs.png b/data/interfaces/default/images/icon_logs.png index 4943de1aa2ed0c8a10796a021506c3515f6d24ed..9fc1dfac93e6a18b55b21c78311fcb3bccc277d7 100644 GIT binary patch delta 234 zcmV08Gir(0016a@qPdR00MMUPE!E?TahBQ0002ANkl;=^ z5XJF-@4e^O+0J&Zo6dE%vz_f+XS?l8=ik}RcJo`#anGE2?fLlr+1JLhd!PKD)bpq! z^-}Ex=*$~4UQ_0Ef;QjWaIt24b44!1jd+6dw1}-h3dh8ppJQgsCJ=e_SdJBFti(Gn zcV5kypS*7rwNJJK<*bCnyPZtLmQf?_NGsr-w}_j|iC4lE@XoVm%6q_8kRzTzcTD-= km~(YZxkRi$#5M-qADZRpJvIvQqW}N^07*qoM6N<$g8nLYt^fc4 literal 3761 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 diff --git a/data/interfaces/default/images/icon_manage.png b/data/interfaces/default/images/icon_manage.png index 25c1a7e8d2bf392bdb41a2dbb7f8c7c1b914b242..d677a2417fe6b614419ba0f8fb5ef3dc1fd001b8 100644 GIT binary patch delta 247 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 diff --git a/data/interfaces/default/images/icon_refresh.png b/data/interfaces/default/images/icon_refresh.png index 1395372889d057551710acac3e29f8f196958047..24cf600905ee1bfc6c56ebf371f15b9404c4e290 100644 GIT binary patch delta 460 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 JPDHLkV1jHod%yqy delta 3118 zcmV+}4AJwS0<##9B!3BTNLh0L01FcU01FcV0GgZ_000V4X+uL$P-t&-Z*ypGa3D!T zLm+T+Z)Rz1WdHzp+MQEpR8#2|J@?-9LQ9B%luK_?6$l_wLW_VDktQl32@pz%A)(n7 zQNa;KMFbnjpojyGj)066Q7jCK3fKqaA)=0hqlk*i`{8?|Yk$_f_vX$1wbwr9tn;0- z&j-K=43f59&ghTmgWD0l;*TI7}*0BAb^tj|`8MF3bZ02F3R#5n-i zEdVe{S7t~6u(trf&JYW-00;~KFj0twDF6g}0AR=?BX|IWnE(_<@>e|ZE3OddDgXd@ znX){&BsoQaTL>+22Uk}v9w^R97b_GtVFF>AKrX_0nSU8Ffiw@`^UMGMppg|3;Dhu1 zc+L*4&dxTDwhmt{>c0m6B4T3W{^ifBa6kY6;dFk{{wy!E8h|?nfNlPwCGG@hUJIag z_lst-4?wj5py}FI^KkfnJUm6Akh$5}<>chpO2k52Vaiv1{%68pz*qfj`F=e7_x0eu z;v|7GU4MZ`1o+^>%=Ap99M6&ogks$0k4OBs3;+Bb(;~!4V!2o<6ys46agIcqjPo+3 zB8fthDa9qy|77CdEc*jK-!%ZRYCZvbku9iQV*~a}ClFY4z~c7+0P?$U!PF=S1Au6Q z;m>#f??3%Vpd|o+W=WE9003S@Bra6Svp>fO0Dk~Ppn)o|K^yeJ7%adB9Ki+L!3+Fg zHiSYX#KJ-lLJDMn9CBbOtb#%)hRv`YDqt_vKpix|QD}yfa1JiQRk#j4a1Z)n2%fLC6RbVIkUx0b+_+BaR3cnT7Zv!AJxWizFb)h!jyGOOZ85F;a?DAXP{m@;!0_ zIe&*-M!JzZ$N(~e{D!NlT@zqLtGcXcuVrX|L#Xx)I%#9!{6gSJKPrN9dR61N3(c z4Tcqi$B1Vr8Jidf7-t!G7_XR2rWw%BIv?Wdily+ylO`+*KY$4Vz$Cr4+G&IO(4Q`uA9rwXSQO+7mGt}d!;r5mBU zM0dY#r|y`ZzFvTyOmC;&dA;ZQ9DOhSRQ+xGr}ak+SO&8UBnI0I&KNw!HF0k|9WTe* z@liuv!$3o&VU=N*;e?U7(SJOn)kcj*4~%KXT;n9;ZN_cJqb3F>Atp;r>P_yNQcbz0 zDW*G2J50yT%*~?B)|oY%Ju%lZ=bPu7*PGwBU|M)uEVih&xMfMQu79>|wtZn|Vi#w( z#jeBdlf9FDx_yoPJqHbk*$%56S{;6Kv~mM9!g3B(KJ}#RZ#@)!h;8Eq#KMS9gFl*neeosSBfoHYnBQIkwkyowPu(zdm zs`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMeBmZRodjHV?r+_5^X9J0W zL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0?0=B0A@}E)&XLY(4uw#D z=+@8&Vdi0r!+s1Wg@=V#hChyQh*%oYF_$%W(cD9G-$eREmPFp0XE9GXuPsV7Dn6<% zYCPIEx-_~!#x7=A%+*+(SV?S4962s3t~PFLzTf=q^M~S{;tS(@7nm=|U2u7!&cgJC zrxvL$5-d8FKz~e#PB@hCK@cja7K|nG6L%$!3VFgE!e=5c(KgYD*h5?@9!~N|DouKl z?2)`Rc_hU%r7Y#SgeR$xyi5&D-J3d|7MgY-Z8AMNy)lE5k&tmhsv%92wrA>R=4N)w ztYw9={>5&Kw=W)*2gz%*kgNq+Eef_mrsz~!DAy_nvVUh~S7yJ>iOM;atDY;(?aZ^v z+mJV$@1Ote62cPUlD4IWOIIx&SmwQ~YB{nzae3Pc;}r!fhE@iwJh+OsDs9zItL;~p zu715HdQEGAUct(O!LkCy1<%NCg+}G`0PgpNm-?d@-hMgNe6^V+j6x$b<6@S<$ z+<4_1hktL%znR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX4c}I@?e+FW+b@^R zDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i&_B8C(+grT%{XWUQ z+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?SIDu(gXbmBM!FLxzyDi(mhmCkJc;e zM-ImyzW$x>cP$Mz4ONYt#^NJzM0w=t_X*$k9t}F$c8q(h;Rn+nb{%IOFKR-X@|s4Q zQ=0o*Vq3aT%s$c9>fU<%N829{oHRUHc}nwC$!Xf@g42^{^3RN&m7RTlF8SPG+oHC6 z=YM0)-)awU@466l;nGF_i|0GMJI-A4xODQe+vO8ixL2C5I$v$-bm~0*lhaSfyPUh4 zuDM)mx$b(swR>jw=^LIm&fWCAdGQwi*43UlJ>9+YdT;l|_x0Zv-F|W>{m#p~*>@-I zt-MdXU-UrjLD@syht)q@{@mE_+<$7ocYmPs(cDM(28Dyq{*m>M4?_iynUBkc4TkHU zI6gT!;y-fz>HMcd&t%Ugo)`Y2{>!cx7B7DI)$7;J(U{Spm-3gBzioV_{p!H$8L!*M z!p0uH$#^p{Ui4P`?ZJ24cOCDe-w#jZd?0@)|7iKK^;6KN`;!@ylm7$*nDhK&Gk-1H z0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F}0004ANklY5Qd-i@&RI#2sS2-Sg3_fL=bEvc49C}8aovNBKZ+PD=0J_Lw{2!qc2><{907*qo IM6N<$f@!7T-P6s&JdvM)pW&+k!;Bd-CJJ=1{r~^}@87=@7wXhA=r8~QNY;U^z4#sGKknodvP;(=yHZkyClkxFztcr8b?_bVV+e|4NS@mTsJ;3DzwJ0Zg#l( zg2_v!yj)R|vxrTcD@2r|oST73Kuu06M9f=uf{T|r1EaSvD+9k5Tc%I~AA5F|vPY9A SUq6FF(FRvv1{MKF25SJ=OE6Xd delta 177 zcmX@lbdyQI-P6s&GLfHwpW!M4!{5JuCkk}2&6qLc|Ns9J7wXhADE?$&00A8k0aC@l zR^||KgtfSnW5W_7&IU=nuIsCtBD92gR(@;UbVy8}@yL^l9=hI5Q}#tN}m)HDdq( diff --git a/data/interfaces/default/images/icon_search.png b/data/interfaces/default/images/icon_search.png index 9845cc95dff02df022adf4648f3824b4eec3f8fb..7a6cd0799a24ea8952ac236b1fe3c263294668ed 100644 GIT binary patch delta 389 zcmV;00eb$98kGZ(8Gi%-006b8-m(Ay0bEH$K~y-))s#VJ13?tWhfqRTO6j%NTtobN z3QH+HgV-iY=BUjelLm^-TJdh=@mKnZ3H- zb|Pt`Y`27rWk2z>koNq-(H1H4`aJ_zaZM8D1PVQ2A-txSM}jXL^4G#69miNK@6Of= za(=Ai_1K;~qwTK8r=gNqyZ4m0B&VcVTVb)ZcAmbXpuq_i%WP-w6CJn3TIjsaYu?sz znBKUeLIZKR`IMxXs}0-J^wEqtbqm~NUe^a5BD(2i%_VRv zAJDhF0wE)%q`%?9CtwVhJn+T>%ZvecRE?qX3jtO1zyk6A7Z@6|pTO}x0xk2Gpu!x> j^Y%|Yzd*p5IYfK`OC?rpfF?i^00000NkvXXu0mjf!tA&E literal 3342 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 Hu0mjfK#g8a delta 4052 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 diff --git a/data/interfaces/default/images/icon_sprite_white.png b/data/interfaces/default/images/icon_sprite_white.png index 42f8f992c727ddaa617da224a522e463df690387..701b147cfaec629807ac90dbf1aaa257acd6ad46 100644 GIT binary patch delta 3377 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 Hu0mjfK#g8a delta 4052 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 diff --git a/data/interfaces/default/images/icon_upcoming.png b/data/interfaces/default/images/icon_upcoming.png index 2a7acbe622195576aade6d6f4007f0958c42d91d..6e421eb5f54c8401b6dfd11b45c380556320ad7d 100644 GIT binary patch delta 211 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 diff --git a/data/interfaces/default/images/loader_black.gif b/data/interfaces/default/images/loader_black.gif index e2a116c7280b4d9786ddb960fff4439a542866ec..17c51b1459db367209222e8444f625509b824ef8 100644 GIT binary patch literal 8685 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 Hdd`0W=OYQG literal 9427 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 OhM#~I&wK(i`}3bHw&zm- diff --git a/data/interfaces/default/images/loader_blue.gif b/data/interfaces/default/images/loader_blue.gif index 956c1cd08b2cc504de5d122b09086a8c9a2c8226..40d87f680966ca66841cc1020cd0943aa5ce3e7b 100644 GIT binary patch literal 8685 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 literal 9427 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 diff --git a/data/interfaces/default/images/loader_refresh_black.gif b/data/interfaces/default/images/loader_refresh_black.gif index 0ea146c0253f2e500ca022050f4353be456e6d4d..551a665509df1fb24ab9f297913350b91d0e0c41 100644 GIT binary patch literal 688 zcmZ?wbhEHb6krfw_{_lY=FOX`s;V_>)-E$->D6q;-H8Xs7@K1B;Kq z3D4DgOIr`N=NwAPoLj=B@U7<8C`7Vv;A2%T`UXv&@$69t3Px(j$x z8bzfo6%-iqT8v~42njrFFmC+7*L2{3f})t^5`GWU2n|oC$A>Z=aFm>T;J-m(1A{W_ znU_flGgvM!U0_vU&#CBG87On5hXZO8FS1R+8&2M2SiECp;tQdJ1~QM8L|ayLIc;9z zGQGoHou}pIoesG&A;*KMJ9v#Ii549+n!%;pnJ6$pN1*wI_HBa+Y@Mx)X-4Y`oW);C z3%ppu%vyAaK|vW2WK0Z<47@C(x&A0k(-rA z_#mTj0?YC)IR-aDhed+x!!@(+fGuL>0y-TS?J$dgVWoMD zBkAnUmaMi^!J}z1hc{*@7z*ep^PSkXqlrahMX2Ax1IH!_JZWQA@lNuIFl%f~3>J_T kVR)8()8UN7q}!(#L@wmJ?0HaimXy)#4GLBkc}fh{0O9@c?EnA( literal 847 zcmZ?wbhEHb6krfw_`<;O|NsBc(9mDMepOXfty!}sA|fI$FYoc=$48DFNls2)ym;}9 z88hCzd9!QRt{XRQw6wJJ_4P530u=vq`?-b$J39ur8tEA@GXnK0{*!VpN=+BBCMNfRtuR+y^ER&<(X zE7%B&e5#Z^`o7-@nEg_P~LI*N!rA^h{|;%@7gdIe0+9 zL6FPmK*NXRK=)=Qr!7yn1(?oIuvujqni9B8`-RfJ#pzQFR*0=zw6NbXTj0F1BLlZh zfVly?4JS*30beAWg%&SoyRsrS%M*Y;RQ^0c<0TpwHaFY zz6r{%O%_#7fe{VKyK@(naSJIeRCsx4aYAE48*ixFGRdMll6*@GUTc0%``>`o8j#V4 zg9*c-O~)LSJSTE&n6)s0!!ECt)zd(wL!wvC+k6wRhYtI}PTi7bt)^yIDG`AshjlL| qtmsHM!1PnVu#-b#!*qv!K4!i4t@n3ad|kNsdh@zF&)yaDp ze*OMCZTF)MXI{=b@Z|sh|BC;){aizWogD*Qjr0td85tND6o0aCasg=_AO;#Lz`(%b zBXGiV_1@CfgY7wok}~I(@VHDAR882>V!`>sOm~e#ZkwBAGec{$qF;}sN`t%OjwX?) zHytMe3pARS7%vrxXxCL&*v%#Ns8I57p1%^*0v-k(CZGj8U<*PgoGhBMXU0UqptSA+ zo|HyWX-fqKhP)OdnFB%s4;zddKkzjjIG~^?X1Rpl!!$y})9LY{j0YSg=N|ZPP}snr z%zEZ!lEMs@%S#tn71(nsI#veCT2@XxOwbW%exZHaU;Zu4?WSHL6k%YKwW;4>cAt=BczPxqakj z?Gs`7V1NRGlSdG<$=Bl|`NsgEaufyZ7(_ literal 847 zcmZ?wbhEHb6krfw_`<;O|NsBIh1Y-m{@c6t{*H^U3m0E+UU%pD`|oG(eW+S_Yr~nB z%a1*K|K-<_8}IJD{5oy-qj?9OFpvTi|8x7fh6Fo12DlpO889;f^(y|8axO|uEJ;mK zD9+}1yrR2vIXQw2G#%tg}#)`c}opMaul^%5>~C9 zA#!Y@lxf2UwwoOd(Y-7-Oqs0#69rlsJ6jj^txP# znr1852#b8Gls)>s-wB%~843zr?w5Kpw4{zcSh2!E#kKapfrHnMGI8`wX-Lfw5#l*` zK*2$f%jZDDhvY!_W+taCPqzh_&QP#fWg40ixJ~Oe;?`>ncX7DG7s;^5z=Kn)RmY&u=?Yh0wt&~O zw>+Wtrsd2mH7eP>F`E25-B>LRf&^=W!W1S$k)sYPnw{m?)`%a=?rxgHsOa=0!JeUU zJJY@i%C1coRZf8s4avK67nN}fDJ@iZd1!G$V?rBmsM|8hqC1j&OAB6Weop(}fYln1 z-+-~V!hy%tP~gx>iQHI;<2xlhdM>ox&|nwnVliP-c&?!%vCK^|(Sso(cm_vfuIZAk zj7csYTi6&@?MzJ4;p$UuC@>aFKBBxq{Ri(GC3X`t^8hui2>k|)o?aEKmc~OZ^#FQ| zW0`{q!=X*b9F;sLa%`BjFoDA^ua(u)K&3;XSI*mf6R(F3`@v4#l4h-@W>+Z@fhC7^ sFD9($NI1asQ^2s3Lt?{phkib1^9&AsLzXnw7|re;HAMz%Q1S!=06B^hng9R* diff --git a/data/interfaces/default/images/no-cover-art.png b/data/interfaces/default/images/no-cover-art.png index 40334da48d2d939ce04424855127d3dd6953fe88..b654dc71b620b3c44c242850c070472ba38dc7e1 100644 GIT binary patch literal 16945 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 diff --git a/data/interfaces/default/images/no-cover-artist.png b/data/interfaces/default/images/no-cover-artist.png index 40fe7903abb853dbcc15a3ab648b434e7902aa9d..ae97e0492206a736fd61a9f3c44aceaac0f6f297 100644 GIT binary patch literal 3301 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 diff --git a/data/interfaces/default/images/songkick.png b/data/interfaces/default/images/songkick.png index c18e440d7e55803baad2c801b5db1ba31695e5a9..c5261bd8c3d10d8a4f742ce71b827bc5e03e1476 100644 GIT binary patch literal 1838 zcmb7_X*3&%8ipgpkWgtX*U-_#ax2!VptUBJMy#<`$FwpSREelmY713SyVANi)SjR! zmPXLpn`)_j8)|JWRr^p%Y~!AL&iy~{_n!Cp{(axSHy&?;6$VRz0RVt7&fL@<0N??g z?ZyJUXL-k6^X(Zt@K%myXZ)wy2VDP0o;-EQKD&V++uJw*F1Mb-qc&&p{b*=@6^W2~ z8r6CkO~S(o(9PM@{Qj!FE(;0(z<(2GigA3vvzGBWy#y}AZ}KTBO0DH|cm_5~pK3f@ zcbbSySL#S|6Or4t#-lHNbBRHPVK?Xn%_E?J0oQZPVBv_hwv>L$fA`g~Y-F#e2f*C6 zQO5F093@%E0$;BxA(JMBk<)lUzMrCv$(1kjm9JnMN^6-M)XX__8F@+EIk_d`p>U%- z-dcYDRRCui_?bm0!TEaaE`Q1Ox6UGyMP|&MN?4y&8dh6;%i{Wtgij+p$E-Q}j0++{ z2dn~W?4y+cNXuOoq5cVoIVy$R5lxM5lkXEpQy_wCwfNWr*EjCPH1(oC-MkU|I%V$L z`e;K}yqrzGijQ|duZoGJ>WIKHj33f>PmRHKyZI!ET03{dqLvDo48wT3FG1V~79OIH zb7+9o*3u`-6R!nq14mUGpz4qn%=X!%kt}>AHzIqJ!YNkmDPTbL$x{+83oQH=44%`mrqT0P z@I_=XNP))SL4(7uzNyh7x1!-wF0E;cR>Z_q1j;xBstFgP``1UucEZ^-!T0orzN= z?_b}2;cHN=WEB5ghpozSke;thv^LT`E=Lgu6Nb)ZINVYC>CpA8+Pk3oCQBYYLoDiKbS?=jtYwI3bBf&Nc z^Ha#r53LDt&hM2UC2sLu38zAx09VXHk(n}MntXM;7}Ge0+C0Vx3clbw=!dv$zy!SFw1OB^ zqO>01~1|zwqE3j9kJYkcG|XL|Yk~RTmv-&rk<4E`FxDT~uyk(qrURtUwtMU~`^%7xTE55I zid=Ae_hE+tn7aMiAm}(c`dwQZaJ~2IszGvD(yqoR`bAS$=+7PvuJoB?a$?80+OS`f zDFlqDL_+76$M;L}t+K%FD#Hmr^KUxGlbf-KKI+Q(<+&AcP+Xn^>AWkC&wS6_<;Ysj zNU+3cV0j8DYr~E-?c*fM!yi>$6g4OZI{0k~XeOv35M$>1?353{i(bC`jPw+E6j6Bi zhtV@4OB{6XeLCTVj~XM4QDUfo@{5TG#+IimAM^t71#=^j%q5y4sWJqFcQPR@$Xm+ zEn>jZA^WBDm=2v)EKk5dN{jP@*%Pm>Nb&dv$g72(X;3xO==m7_=y_~ d*3#p)ep7Gdcy`rSWS9N7^>Ai3rc7f(^xx%LRdoOW literal 4128 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 I&MBb@0GsIPj{pDw literal 3266 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 diff --git a/data/interfaces/default/images/sort_asc.png b/data/interfaces/default/images/sort_asc.png index e61cf34c5032269a26ba09a95f43d250700b3372..bb2bc57cc2b584259394bb09a3054961af4f318b 100644 GIT binary patch delta 68 zcmX@X9zH?Rj)j4N;ZMz!93Um<>Eak7aXC3*f%%Vq?;rMNEB_aCI7_&`vS(YO#=ziJ WEAc{9kWUAwlEKr}&t;ucLK6T7K^5Qt literal 968 zcmaJ=Jx|*}7`7CtLI~7_p)j2e34zr3?wl`d;f5qfN+gU>h)RYIIQE5DV4txsjbVZg zRq52f(Eflf?N*5n?b5mG(m&9#f1naQha_}pYUz9*dYOV2z6lC3Bk^WBzne(dNj zEI$Gpi6I4iCTx)SCqZl^CLHP-a-AL{2!;@GY{FYpZEFwIc*Hp ze+V>8Fd`blr7Xclp=4kJMjw>XBDZTaX?+w+?o8MdA~X;h3n^9ELZcQjTkxpG`N_D7dp$0A+~Xmr?H5!6 z4!pqSgLsIyETa*`!VVnPpeB?Gs^_^zsZ!I|OByK^Ym|^JvRaaDL3C z-0Ax)5M!=;$JM5C(^l|98JV)kd&b;)#CS|pXonz2wlg|00f?{elF{r5}E*NnHXvS delta 105 zcmYdoXPlst%vsc`5N65_$47mh6b!XKQ&7Hq2H>A9TY%LZxIriSHDfrc=6y85}S Ib4q9e0HKm2U;qFB diff --git a/data/interfaces/default/images/sort_desc.png b/data/interfaces/default/images/sort_desc.png index 980a7ecb6d0792bd440a20337ac3f40e7b4059d0..c48f18ac811d189d22bfeb5dccf24f5cd3a436dc 100644 GIT binary patch delta 64 zcmX@l9y~$PiiLrJ;ZMz!93Um->Eak7aXC3*f%uPp#zK#W7g`TH$dp}VVqnm_DDmRx S*3dsdRScf4elF{r5}E+Od=|w3 literal 975 zcmaJ=&ubGw7+ok-Ee0%ju?UVE5QJuDCcm0pvc_~bwF_=3X|OqX=w^4)Ea~p7yA!jC z9#p*asz;&pFYqP^9t6E83f{eX@aUySOPx)V)Pr$hc7E{Q``-7>H~X6#cTx*W3xXh| zjCH-v=ZncWKhOX7diQ_w=?beg*%ocHz7-Oo>d+PehG*@PI#_XtJhx zSFx!lSqVmpy@0a?p|xiI z9kFr?lw*Ypc!XIX_V)ZpiB)l=tMGMl3`H=4u&yeenrfPxK%*f61qo$rRK%c|mvA1* zd3hOR5k?SaAzH~`q!d;Zgu(a|d9=`JD|LNs9EPk`5F|OfiPS$iShiWCQs>ynB z!qutW^N7zTT<46tI+L5Uf*0_}Itkr}#90fe2SzC??rblmnR;WcGuw+ilMA^Sm|W~% z7d<)R-IFY*ZOb>)#wR}S_K>&racb^>KMBpytBv@3>-Wh?W$xya{ipivN2z5rpH8Q% z--P2YpPqlcet59>(TMZ-!|KZmuim`9{NpJ9;>Oxh+`9UB@!X{yq29VSzjWxdlP6}> LHuTr4JJ0?AAZ0G_ diff --git a/data/interfaces/default/images/toTop.gif b/data/interfaces/default/images/toTop.gif index cde291a44056ae03f528c32d69fdacb8d35d0181..110534a2f23b6161d49c76f487c2707c5de3ddd5 100644 GIT binary patch literal 56 zcmZ?wbh9u|6krfw_`twmn0)U4|NlA+KmZb9U=r`?UwQiNe1-RZaks1Oz3_t+N3k)3E49pxd9vc=MY~~QwiirRU hwK2+A#aI|Va_*4O4w|wfaDiKof^(OQrhx*3H2|G>3ylB( diff --git a/data/interfaces/default/images/trashcan.png b/data/interfaces/default/images/trashcan.png index de10cbda45ea7a635d328f5e058272c0ab850cd0..595328d89d35af31cc4a4ce3495e2f772295464d 100644 GIT binary patch delta 87 zcmV-d0I2`<8FY{%Q2;whL_t(|0b>|=;Ns$<P?YIN~%2|}l7r;(zlz@=&60RS1pV>+dhR9FB2002ovPDHLkV1m*kCN=;7 literal 3317 zcmZ`*cRbYpA3t2#qq0-3i<59CqcYAq=bYro$jLnO?6{nfnGsHQheAeXMp0Hr_WZhJ zr9!f@Q?icy=sSMn@%X(TkN0|hp3mp&{d&FMe|)0#^-%0Af-C?4fE}%^dE;>AIO1txW#M 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 Z=1;c{KFf(jitZh~B{Wh`vs}YE>|Zg*4G#bS diff --git a/data/interfaces/default/js/fancybox/fancy_close.png b/data/interfaces/default/js/fancybox/fancy_close.png index 07035307ad435f8f2f8eedf0bce50f7ec8a858c2..ca41f1bd1a3c125aa6365b941583ebfab48c4906 100644 GIT binary patch delta 1040 zcmV+r1n>Lp3z`U!8Gi%-005CWW+?yw1K~+TK~#7Fbd&>_YfB8r|F5T~zW1zc@9Wyv zwr$(CZQFN0J=?Zz+eWR;&DWk;Z?c#s`E@4AOwdejf(DxI+H%>d%hDe*pRrhVR$aN= zT?@9ew=H3p1Q~|hcJpJ7rR~dFl6kb^LUm>GjO6V}Goqs{Fn_RD;;2?pE%ecrzA+~X zo*wwe0q-lMitnAjFFrM7WNK0be}Nv_mbiwE#7@zb z3XI?sn(t~#ikO^r-_XFfMBcg!-o8%QLR#rK;&s)r_1&i?l&2`+Fp1p-YHT0TFYERZ znpP0CAS+}MCioQYDDez3p)JECh|$%a-`vhDE@%efH=$xxV( z80Q}umZ*eW{{s!# z4RVI?|AXk=zJ^M?$7Nf$q+K4OU;t!GjJ0z1QliiOur{~55s4UqYMc!GmM0oj$M`z{pD|r{u)I6YVHl5*IYzv zb{VUXsKRn}`YiA7M50lO{Z}>DA!_c6-QueJ$IU8QAPX4ra;0_JSI-kIQ$uQ;B3Dtm z+kGKVU;gmx2bh3zaqJkmBxl^w%WU?UY@(lw-G5U3!`R^wXL{a2^uPlc3AJF{D&*@2 zCawMH9nsIWbUNp2`#EwQ`XfKoDhUeGwOt}+&Avb^(_L+$ot(Y^SNa~zKo@aI_&XuT5y(PUXqB_2lbNg@sf=qJrp_RqiX$Ipqy9eB@k#eK#7hMSwi^4Cv?pTU02ML&)d zPf`p6B%>4qrMaeExO)7>FF&P5^4(Y0UYD<77o!6?s$m$2F@$iWa4!A$SZj@kaaFmy z*9)6I-1OnVmlE&8RUF1z_VhyuM>QHl0)IELF&PEug5j8nmDsA?r#*y2+I^a>{Angz zyRbJIVgNQDy-+xtOq6ig0F1&!OkthKs{w2&VNV>Ik6y8RFn2S;IV2T1DB$oi))HRj zuqBQ?=7wBb;H-QC$Q)vX3FaGvKp=|~D8dS|5v+y)V8jBk008wci}<0n7ry`i00{s| KMNUMnLSTY;O$b~7 delta 1510 zcmVLWoQh#1Lm>CQF1RCQNV!<)Rn@1TzQww~!2i+aG`E0vjU;nVOA+ z5DAdTV&)}LC@sC{p0|0w=Ici*;+H&WzjNOAea?Ar=X^dsZ-0M$0U04LFV8O{BSW!w z?_PhsUaxFxYYXh|?hc}N0?m<(AkVs6en?3UD1dEANlC${PMy+RyLK(ErKLq*S68?D z>eZ_s_x1Jd%goG-sjjY$xp3h^G=4w86GS?!E8ofjNRU>pBqN6o9nxICe%)~Q?p@>4 zr%$g~t=5S*l7Gi$v)zC3;>C?SckUFGmX^jwM@NV80+eiPQ*s##s^a3}Ldwd@cHO*r z^T5i=%Fj}=Cr_R@78e&C((#usU;YFS>C)2Dw4tG)YO=*PWt;6ZfL46;=u!R1$jGM- zhhvcpVyCa+S}Q!T2ALHx;BHe#M~BsHHos=s2iW})#D9I4TCENxo8ERVkgB$C-yW8q zpRXSr94vv2bux7O_HBD?Z0xF(P>ST_WYhHYv|ZRe^uEAY9Fwa&k;^$A>XeieU2AJ= zf!%IjBIW-6eydzAU)hXQ0LfmubZK26@9pg^(Q36ZWSco$3Fpfh7!(l^p*?;2^Z_Kw zufk5ZLVqeoPfw4PY#=}W#QTT94&q=gnUGN1$i=z2xqAi%2C7NHWHLDrYd80%r>C2; zv$HqESfkOb*3{Hk0s;bDWB&gBE6dBvHnKH3I{GbaC15;^OHD2i2SQ1UEIzQXu<#=Z zcXf4H#l{T=gGq2-Q&ZErFdiQtZvufcsVMoxI)AmbwH6wG_UzejcuxU#@~-`a_#*!5 zc%#u+u(r1LfP}NMvfOq?$%VN$V)BH91Q#hi=w|C;o$&DRRpg3uE+ix*7h^gg1n~D^ z_VCEaNJDXPaRmvzdiBcTDQ<;@g==DR$hk;|hK6jO^5Nmbht5Q6XlSSl4h~MnSOmM< zM}H9%6r@4F%{+PXGcz;xx78|Q_F7Eb+}ynGO@4TI*h!27r4#SzfR=K~htpe&%-o-olT$}R z&!0cHdm}}wbdd`2lO~)PlarHXnm>2$+5q z+N@2iLw%hOl)v$Q1jvxOS_9(L#KgpMoU`Avuua?$gu0|%NfU_<339d6OJieW&VSnf z1rOd!0k`CGmJ_f?sZ<)FT#wqk@2M`gf~u-2Gq=otQO^w+D-_xua>ByG zYEV{vf?LVJR!OO?;&R~NY#zo~HIO51ATstjwX;;Xu>{TJ8#H;>;osQU_|7}8tgJMn z&Yh&=wkeJ~d~_Lre2~|ogf8QfH}d%L<2k_~?S|SQu0$5X)YOzkC>ibn z%kAy$bSPW~YRMMc+;A+qT?7C+w9^11kzK{5gJO6m}=+W#OFVE7_(tkWI4fjwT z{k&5mHL@*_7Xi1c4?x$HT^y5qc2zyPPCG3CUKl!f@Zj&~&!7K?fD>&zDk^G(=74sN z=`q$#Wm{gaK5myi7K~vRQ8s=CoB+NC8j<}iKpXzI(SMmt*2r@wST=`sW7t-}X4hPq zXy*JwS=LYV{q$bnP@WdE8Q$snAG8lDW^pZTX#fBK M07*qoM6N<$f+XVMBme*a diff --git a/data/interfaces/default/js/fancybox/fancy_loading.png b/data/interfaces/default/js/fancybox/fancy_loading.png index 2503017960b3972499d3aa92f89953935ae40934..f208508e36257d6b305c7ce58a0c11fdbed915e9 100644 GIT binary patch literal 8138 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 literal 10195 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 diff --git a/data/interfaces/default/js/fancybox/fancy_nav_left.png b/data/interfaces/default/js/fancybox/fancy_nav_left.png index ebaa6a4fd34e51575a01da366312c20618985cbc..017d3b5392545340054ca323745aa69edc68d0a8 100644 GIT binary patch delta 992 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 tcJSdjDa(58`F?t%ZzxBbaTo=++P@wq>aY)RV3 diff --git a/data/interfaces/default/js/fancybox/fancy_nav_right.png b/data/interfaces/default/js/fancybox/fancy_nav_right.png index 873294e969db9160f5ddd4e1ab498ff60b080e3f..03f64f417523e75f41143e149e5a3d8d1c49e4e9 100644 GIT binary patch delta 999 zcmVoA+*=IA&zSA3&9-ZMYumPM z+qUiY)3a^cwrz}=cZ&b|>q{0hx#xG81Px3DHPoH7Wx70lE5lyPaciDFPhY0%qy^&~ zZNd~#109T6&1S_MOxxLNe#-;pr>iQGCns-8nj9T%g%LU`RDVsQS{S0OJz@^$KiTKE z-M*LPy7}Mw^UM?dhomM&Z~_Cgjd3-DrnIo(Dd*eS?@+LxgI@=g&;Q(+fNfF~(-7_HgwTLft+SkJc`!%m{?)SMvjK2NE=&}@BILylG3TkQ|(X-X9gT*WI zNFddrfQn~3Uw;pXaBOUL8gWxCuHi{+h3R{G1-=ov=+oHIglp{Ey+S)VI3XD}5*p(C z(ZL#(FsI$0_X}68MZ+E84R&zCCt`mQxrl?6lp6doHjn6#b7``OGniCIzSa9~us*K0 z|KJySW@AzYB4Fg&ge~TT;qAVH>gN%gulaUTRFd3Hlknzbk>-> zifIc(J`FU!<8ez=`}0A;ucxH)l`YZ>#(jWeX8$Q5k6Eyw84#Q?Em;h3TNvS z81cG_yop~tE3(jYLzLcwhxqwDj7FKV?HFBg=Ewu*81|e0=bfK*irjY}hT!^z@u`qw z=##YKhqpxbdTWJCJ@@(Rqmy`E`;U<;(F-}(o_~r-f^=>Bh^f;~3vH4c_NKk9%OB)T z--XF&uOtc66Y(h4431rO;trv0@HT~xcYBDw@G4edAc~drWMLoztyJKpo2^Pbl{d>q;z zKY#3d3cD}^J*iYmQfUTAMhW_8W|+6)!lCCs|5)nSH(y+QRd*3viT0tYwizgG2;oRU zF?w=aD@^-v!FFfY=hnWz_WeFDB;JDy*oPGy>4{>hlr~hi0Wz43e6+_POu-Ut(C*ak z#a``B^#*>M!f1QsQ=v4#hOHMio~9*=DPZf3p%{bl^f4I9Z|xav$Nu002ovPDHLkV1hq0_f`M^ delta 1447 zcmV;Y1z7s>2d)c{8Gi-<0028MuA%?{1$9Y8K~zY`rB`i8n^zRJi7}1Q#zYru{LGrE zox%ArSE*G>3u*(KtD{JrwzVzd1Sd??LBSPVvGu!%ild$G-w-yj+n@f>Q8y?3V6AR~ zh@wIrGOJKEAI7BJbHaH=)5N6h3lGV?@43%&@44rkdtV=)y?>v5fQWGX_;KIV)Kp1A zLV}-8r;|1{HOV_WI|Haqpm$^=*z?%eerS*wkO2GR;^G3!%F5JjZEZ0P4Gp@Aii$(6 zt*syS^z_P(V=tYat$(ep$E&NW-wh27eVm`49~}`9q2dWhnaiWNhz(^iF)=}B&YX$5ef##Y z_4W0ioW+)wmhAKM^LFR{=g*)22oGtKlan<{rBXp$%qjA;Qh%CpQBjd@aB%PwyWKud zgjnedxYh~}m|(NM6L7b&rKQDcHk)6t?Gvp2EcQK0p?^>)iPPQ61yR|70|!(&IXSxi z{{GY8*d(I6ckkMwqoX&R1)UKeA8&m0=#foWJ;r^Gz8Dslxe^!iekc+X6Sa+vjkz|P zZGi^&_V!xEV)42sQ2}Io?b@|XLA<-W`?N-*i6m~;*bcaT9DxBLAt9Rb^73N{lwXCF zx)R0SZGRB)yS4-+=2q^s6}4#DcKg-``(@C_XkfH}?Y>cXV`E-H{@4 z9YN$tytK5`LUzxdJ^KyqL?Db;?JMjr_FwU?!GB=LHJMCL$T&Sc-QNbcTC{LY;_n;hLYN)loV~@yTiE-z%Hxrd5hL0Cf(Pw$l}Zh8aSbZYV;X>4#-=O6 zs;VjrxVH8Zy>Q`znM>xs$md%0<&wtEbP{s`*ZTVUW~x-GOGv98AySFJ+Xxqsk$;h4 z!sWou(L8{@5+KvLfQZ=VRL+tR5T{X0zDAMXKK$$I>NdRp&@S zG3P7C;;xGTK! z9veooizpgD1ug<#0dgA&}5TyVB002ovPDHLkV1fom B*qi_W diff --git a/data/interfaces/default/js/fancybox/fancy_shadow_e.png b/data/interfaces/default/js/fancybox/fancy_shadow_e.png index 2eda0893649371f8d92b92976d8542cdd1b601ed..fff7223203f34e9d2905c356a453f74bf794daa9 100644 GIT binary patch delta 54 zcmd1KnxMjN=;`7ZQo%S;TT+c-W#gm&0l)aC{QZA)b-02ZGs9bDk^i5Pywn(gz|+;w JWt~$(696FMGaQo)$az+4y@=w{K~%IRU;#KEQ}z$PeQXwl7kF4b9ph V40k18%GWUffv2mV%Q~loCIE2#6Ab_W delta 75 zcmYewnxNul=;`7Z!f`$H^g&Jr1(CxS(gV!}zR&+s9lS?GC@A!3+-g?QFAG?k{;XsF e{OHf~qwfv*7H(&m(GBVK3_#%N>gTe~DWM4fGdLRP delta 80 zcmd1KpP=Gx=IP=X!g1a8)Ir_`1A!w45?3lN68@FXEmHJH&;ItN>>leC54h7VUo)@R kwKgxhe_nb4n|J6>#mdKI;Vst0Ahn7y#N3J diff --git a/data/interfaces/default/js/fancybox/fancy_shadow_se.png b/data/interfaces/default/js/fancybox/fancy_shadow_se.png index 541e3ffd3e88224b34a4d2097c66a780e6060aeb..0cce09c1114128135f6446d6b44aecaa7ee5a7c9 100644 GIT binary patch delta 299 zcmV+`0o4BB0>%Q6B!3M_L_t(|0b&#v6*UA9P6jFx!ypjV6dnmnl4k!U`g( z_Az7Hh$!XXm2>5<&PVy_tM__WWnY<}p5jN4)<6p2FU*w-FRptA=e~i3=E1oQ9O|X_ zA!Zy{8Z4+gfD!I7QYdArXqKs1wwhq2QIxH!&?qh z(0~D8mH{GgAfG!UB>3DFqQ;WdqCF15erx`YepD#nhGi<FMGaQo)#<(!f+07$`7Xvcu6tIAjLvskFAFG67!E=L{zu4Yfn$r5PkO V7;2{6DpY0w0#8>zmvv4FO#rpK6YT&1 delta 72 zcmYdFpP=Hb=jq}Y!f`$M&;S4S%p4vaorX-BX$ou?eRu>8Fr^wh8(L3c>nwJ7>M%oe bRX;BSn>=$(WwI4B0}yz+`njxgN@xNAe8m>G diff --git a/data/interfaces/default/js/fancybox/fancy_title_over.png b/data/interfaces/default/js/fancybox/fancy_title_over.png index d9f458f4bb8770466e44ba97dd8fe1f2936090db..74b9149e280bc64dd2cc97e6be7825f911d3ceec 100644 GIT binary patch delta 48 zcmZ>BnINgn!oa|=RfdNNNO60*IEHY@CMQJjFfc4*SaOT=B`;8z!PC{xWt~$(69D4a B3eW%m delta 50 zcmZ>9n;@yn#=yWJFM6aCNb!2QIEHXsPfkckh~QyhSi!L5=)xHaKnVs5r9w(1kf+o1-C0rQyiV(vYBBs6hICyiWrH$ zZ7r+7J=p>S9MG9m5!*=7V`4!F4sfyBS!uh1g|m}0Z7taOr(PE3jTmiat|w4QOj7;F j_iLkn7(fLQV8C$$Qo0-Y(M5(W00000NkvXXu0mjfN=`IO delta 175 zcmV;g08sy)0m}i9B!3xnMObuGZ)S9NVRB^vL1b@YWgtmyVP|DhWnpA_ami&o0001O zNkl;uISrWr0000Bmo9@cXxsZhd}V4gS)#+f(L>Hmtk;+!6CRaxDyx%@R8?x z?_KZxch{PpsjfO*U0t(xoqf8hqBYdyG11A;;o#sf6%}N(UVr7_;NUk=QC?Fd6y$wy zaQvx?GLkyp@Z$!E9t_h&e=nEC9ae^yo4>65_u$ch?aiMO#i*zvq?ps}A?Nbp2F>1CrnK-<8^CmZiQF)n&RcF{~ zAcnAl|I2b(#0$R2kVDDwK&K7mY4A`G^>^gCKoY}lb&#|lH|j40#~=dPSRz$nj{}WI zUWbKUqk1dFLd{}1LgP-~uLG~m(yh>`&^ilvde|!v_HttX`^=HR1Cn2LxD^fYh3^TX z(tuYBL}QiUST8L{)2f0OCR=KCwS4?~ypZ1R|LA$WSZl@+8y)R)rRv!~IM@#MyK{Q1 zHfk|!a^4Vgt}$-oLJK*8JKh~;VL8ppOY2fdAaX%e8eX5*B1z#g0|QBgz8N+{Xroo&PBsOn_CmtbjYi7CGFz&gS)rK96rMmWzK+EwH#_C9STnFUk$d-6ku%(4 zASPT`P;lUq%GC_JY7c+6BrVk)TjK>mnj+Y?D=RA(4x6p;&EZ%YOKKqOYEN}%d%Iw^ zRn+fK|3?FZX_tm>2jUNg1Rs(ctj97u1>JUsCu|Wt2Sb1_#5l#H`NAz^H5Dy&b@#gc zKL$DsqA*gJlr!E7MqpE$;!F8zUJ*#fSU9eO1S2Mc#6*b=n)#H%T2N8YaK1rnCRHpiIEEo=-p|BO@ELUF-02#wySyInI`0ZP8MS8-+k1$h?xMIF6Q9w1dCzU+WK7 z-U2wUgY4`(C4v#QylZQX%LT|cxV6v#RJ&g(pM zbiMI|I@N}}$+j9m*Rx!JVE9{NzNB8n`MO|EI`{o?v+ufSP%f)s4RQxqFh)>yFbQ(u zb(Ql5IKZXrZl%RJ5Z>7}!bm8Uq{;BffDQ#Yjg7!8)>nGsT3j-YH%ZW8p{jJb(S9~x z4)}1xBF3^7)FMZc8hVUT@`>9rb%q|~9)hEu&1J3gY7}e!;cZgydkY*f2y=w7%z`GZ zjPZ0Ns5S8AIkLv2=LOe^v#76lp zZTrpc;9~o*mKHCHc%_8AA3vBSBki@KuA!6iu_uifv}QyO2El1IxZIv6za?;R<(_aN zM8k1>MS%AgeHGf}>)nhd-QJwhQU?b=K48$6NcOo`aLa z4>PSzr0H|~R@LUVG3#XMU9rt;))is!A^EMdqjVUCD zm#x*~aK^A~FB<Mgm zq~Rd$O4wAG{?x11!5X>lD%4$a%60lao~lQBqxM zbBDh5O_ot`yPI}Dpe6W}!chGJ2UxnCN&n96|M&0Tv)Lqo3PVhVnVS86lrdmmoC9nm`37g0Skc0r3}=qGDt%ED<4OKJJl5!7#Q`9TInjt_x8x=LImUQ z*;%oe%ZHD3-%)Z>;cVE@Iiqz`YJ#^>*Y-J`xazjs@%x8 zo>7wRgWeNabz0_CB|`+wnoQ}eM(K>)7^W%Cyc|)#=B0w!pZc{5bAo6kBEC1nn&a6k z7fWSE2>9E|8ZyU=wdV%Sjs|OYjUi#2Y_#+3OpKt`jT0s<9`Ep=Wtwtq1=Fa2!#tUU z@^V|9Om-8x_^ObYP;;HOtF-f7Qs8ydoT#}49*?F`Ea zM}Tlv=~d@|-S$R(i2^V<+@q7RD}z=ge7*~g^w_#AdexeA{3)!ht%bH&B|s;?J(~a6 zi<(@g7YQ%nA)ZwFtYMu)QfAB-ui(|g5V<{agDFs4cDY6NNE`7M&FXU*R+H1(a;$y6 z48S@S2QO5w*JO@|6}g{8E>}X1CUGi^iH?A&uUJ2Y1~hhvf=MQl1+a`rkdS{yCHGE% z!kKa#toHehMccAaf6;pL8=;QB7imHj9X~8RU^t2PxhhH#MYPBr3K44^Og9Zl4zs;I znqQMOE1~P5nT$^rL#?GtOvdgDr)`r(okLhgcnZGkc640E`-J>#+S3I<$o>GiC0<;> zyW2-eA&3l6L#p!XVqMWwC4a4el_U zod9VR%`z9BGU!Z#Blyf1ANo1k;cbkcD$u{krEz4fEK?)wP&S2Mp+AV3ck#E_T!r=! z<+if=`2d?5yIR=eHVPNS>L#XgJh}*JvWWj9GRgjT*1=8w6G3B$UhPlV}%So!|{rpAf!_yQN zH3cWi)w7uWo6V^Q_z>yizbg+Vlps?w`>xPs4%?&ubDfZtbz6!407ds0Crn8R|yI0CmT<{ zdN8WxpTe>XhfOHf@cvwL2XT^~a+;yoJ?e^D#Z+FEkdn{OXjHNrk7EWM3_#(iYoNQvThS*BHYm!btwh{uhvVS|h{Fk|l? zH(s9O4g~o^i7gr0Mxpnz?6GE!E%ni-g|k>bv(bA!l&=PNi37VQn_p|(DSr&Pnj<~a zPii$Cbq6C7KmCP@i|4;6ejaz6!OBsJ=AWGZjb|lBGXM)2=FpGjVRSvdKs+w2zD(?# zx*MJ6jNSJdox7SvlHeOG zMZ>?nT1$y=x@X!Sw~sPcNGL&L?Sc<*v{ zQLtLWuTPUbhg}zJ#UGbeEO|P}fp*{Z1UE=@o#;tu?^zL$CH2pe{4In*EiEjYXjKwQ~N)6>U7eaCi1e*IgxTk>061LbEa zeyil+IM0s+P`})jRU1mP={;ay&zF#4{IdP|fuEB2Zs3VVX{xgP=OwH#Qojj`;bHIP zJZH1UzD5DFim#a`jgy{s#P13zBPL*cQ&`0;C}qTPi3rURT1*)Mf$cX+cQ6Z{asnYO`JThFuPn|E#y7Hy^a5Y!6Av|* z%B3knJV|(sZjghs!{0tRR^(o=LEz6z^`s>}7(W60F^-EaO7<7QMS0tJm2?FqpKCm&E1BNjKa{1tv+Js-*sCeB?59b z2#}VTUfWfX6L951L<2?XT{Jfm%#- zw?;8`6b7B^65eVWS~X#5QJsZMv(GsDFG6EjUZ^u$lT?ncf)Lsqqm^3kST|r}yR`M9 zWu9j|*fuElo_@__kVz8tB*t`9EChcW7%ao5C$LJAy~pumcrX@4Y@e*Oed9r(%jg^!cKQNACD*#ItRvX|*By`Zj5h6Q_P$Ux)b@o^G*xKW_olZ%pAX!FsgI zk7Vtlq_?hzDAzI5&)7EX$0&caq28tg56knQX~;>M&oe5-w_JG5UR2Y>42jzP36B=! z94C#H@$tGqG2A@#1K-k_4DA|sEr6ojRoP?u?L8~ks=~@{k6E zzElJ#@=sh6LA=tdMfePVf0AsKGgm=5Cpt`ikv69~q&iDSn-w*3-y%Lg(ahONny9C& zQ!9z79W7=e{nH`$gB|KnMhP=|F$>Af*c+CY_ujo|%*D%f?1=PB_uKD{Bj5NAI8JU% zwv{BguZ%C2;Z(2ZL(EtI!iS1avo&KcY{r)xPP+KC?T7hyu1F|{F)qEm-Fj$&yi>o# zNmW^EJ&n(4^}gEPYteCEm1QHmUI>eGy{MTacg|*8+`ppNBlo+ZcH+jQ_8HKU|NQtE zj^p_yeJz$yc1ft)lG_WcNEgzU!Zg>pZ|o`-!MPTHB1S$wlDab4ajc*on|*m@uZs4H zzUmIAP2ktVLBaSs_iH$gnVp^8WUv0C)o|oo=K`63^m6>w)(UT$A_uw3m&gJt;T-lP z7f`Nz2I~@)C%v@|<513|+)dUe<#_FFcMl~_Z-|!_9vUJdi?~{v>&x?#d#uS1tqjcO z-&P&M(Ru0Dz!&E*&sHe`PjS>1vH{C1PVD)yPQT*1^Tp*72|CC+%_s$sdZIgvPSdv!thG6>a@$kL;H)}4>cd#G&B}(3uw5{Ll4IXqApG6s7k~8mgeYAm z$;~}$4dxePxjNGS@wKvDNLw#nO}VR~BkoD~2iJ3)Z*MKu0dOer3nq#>=?uCr&?%vAEJ~VTaOPFHh$jxx*JjCD*d- zy)v?V18sv0+*HV4Z1K-8@B6ic%-%~iB;X+IV{n7nCDcqULLiw=a6%&cTZPx9wP{g> zU13W2rRBVhXWx&~E?ug>f&r&7V|GEO(qXL=d8xk+`rljA%h@iKa>zTQ4Iri(;C^t! zY-6hl6X{=O6aW24UbYH~7nVf%G@GYpO`cynn2{9xur0=|He7`dfR$&2CjgL&Yb(7^sX1%2oP&7ZjIr_-~zTXa>xbHkD0ZK38QP!Qdtjp@o>p|H7r6D-3 z)Jdy0>%VqCdO6xK4mNVW^P%C$euGIhii}$S+cgK?6el&u!FVX127Vq<$PBffbmuN< zD)7&e?H#SXu?5fX!8D2xwvQfCb!ohPQ@_&FNx(c;m<&}1mo!nH?-wQKU}eV`H}wMU zce&1BA4ejOjRbi7&;SbkCeliyDK8x|yU^nx)ptAgLk+Sa9HeNnh&AgZ6lV!Ot_ zA6%YEpUZ!+U}?Zp*UL)_-XeXs&)D}fVv*_H;D@jmd#cHC=}!1|k`PMF)>9H;!htR-1T4lg|OaSs7sSxcXsc8hkkdZ-@B!|2)yxqAAX2^#b zZY^(oiz*z@=TzZmC$cng##ONY<9R=M0JxOxR*MKBC0K9Gur3YiO8IUVvoANIrJ)s% zQG7SaB&oFMWZ%8I(x`ihaev>OR_;B+w>{B7J>}SG67F{fc1wz+ct$jX)qA|}_C=HU z??;;v4l`TfHH~Y9n?mfED~9Qp$FI{Jc}}yuv=;KSK6n*o(hOAZn!MXfJuyIwCS5`A zC;d#Sll|8?ACj39p4ETgKbQXn3j4f-UE-PT&Tkl+w<%W}{Z?{3@)a`;%4;D?srSA2 z20We-mI`|Ehd^3JLVu>8K93z6%#ce zOSt4?p1?rN+tQnO1>7;(5BOd`qLM2O|K1mDV|}%CV;K%7d5^ly#C-8!PZ(>UQrd-N zwt9#>jQi8)9UA~iW4A)9gMx`>M-Mo?YlDhQi>j;?x;Sf&9%V%!4j2mXSJqGYqRer! z8-e@Yl2_~UGv>~&6$47YEvC!9VI;LQMGNMfSW=RWv(!)1it1^}3F10uQ zZnSWKlSv%Q8zBRU2&X4-_`Ldo8U`vXng zO4=6fMc46_$T+pX)D9s9C2k*(a&cx!^PS_kkMw>u!;5}tXA#7VK1_pnr7e`ku=BRd z{xk`Dtw{-L^zKQcULsD^e=3b6;2;IUZ>YvFP>tO0Q1x99@ZEj8|DG{4S2ww?xj%^g z5SW`Z@9?uL_TPmoAyw-f3+ba>?X}ly(=aD|>eUa1#g9T8%q%=V5fT#a~j4pltjhn!_TMy%Vx(U>~r^((l0pV~cP6N5FSWKT
h26g7ba2p~Op)0O!f+lkGV@JYFz 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 ZMt7F5hocMxav&E)U0Fw|QQ>vO{{ZOG+C=~W diff --git a/lib/cherrypy/scaffold/static/made_with_cherrypy_small.png b/lib/cherrypy/scaffold/static/made_with_cherrypy_small.png index c3aafeed952190f5da9982bb359aa75b107ff079..724f9d72d9ca5aede0b788fc1216286aa967e212 100644 GIT binary patch literal 6347 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 N002ovPDHLkV1nY?RmA`R literal 7455 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 Date: Fri, 16 Jun 2017 13:02:13 +1200 Subject: [PATCH 115/137] rutracker search term fix --- headphones/rutracker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/headphones/rutracker.py b/headphones/rutracker.py index 7d18ca1e..7185984d 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -98,7 +98,11 @@ class Rutracker(object): # sort by size, descending. sort = '&o=7&s=2' - searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort) + try: + searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort) + except: + searchterm = searchterm.encode('utf-8') + searchurl = "%s?nm=%s%s%s" % (self.search_referer, urllib.quote(searchterm), format, sort) logger.info("Searching rutracker using term: %s", searchterm) return searchurl From 914ab655772fdd893184ce1a147404035e95a9aa Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Fri, 16 Jun 2017 22:52:47 -0700 Subject: [PATCH 116/137] Make apollo.rip searches specific to the album type --- headphones/searcher.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index b0652f7f..cb2ad014 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -32,6 +32,7 @@ import re from pygazelle import api as gazelleapi from pygazelle import encoding as gazelleencoding from pygazelle import format as gazelleformat +from pygazelle import release_type as gazellerelease_type import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request @@ -1564,16 +1565,45 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if apolloobj and apolloobj.logged_in(): logger.info(u"Searching %s..." % provider) all_torrents = [] + + # Specify release types to filter by + if album['Type'] == 'Album': + album_type = [gazellerelease_type.ALBUM] + if album['Type'] == 'Soundtrack': + album_type = [gazellerelease_type.SOUNDTRACK] + if album['Type'] == 'EP': + album_type = [gazellerelease_type.EP] + # No musicbrainz match for this type + #if album['Type'] == 'Anthology': + # album_type = [gazellerelease_type.ANTHOLOGY] + if album['Type'] == 'Compilation': + album_type = [gazellerelease_type.COMPILATION] + if album['Type'] == 'DJ-mix': + album_type = [gazellerelease_type.DJ_MIX] + if album['Type'] == 'Single': + album_type = [gazellerelease_type.SINGLE] + if album['Type'] == 'Live': + album_type = [gazellerelease_type.LIVE_ALBUM] + if album['Type'] == 'Remix': + album_type = [gazellerelease_type.REMIX] + if album['Type'] == 'Bootleg': + album_type = [gazellerelease_type.BOOTLEG] + if album['Type'] == 'Interview': + album_type = [gazellerelease_type.INTERVIEW] + if album['Type'] == 'Mixtape/Street': + album_type = [gazellerelease_type.MIXTAPE] + for search_format in search_formats: if usersearchterm: all_torrents.extend( apolloobj.search_torrents(searchstr=usersearchterm, format=search_format, - encoding=bitrate_string)['results']) + encoding=bitrate_string, releasetype=album_type)['results']) else: all_torrents.extend(apolloobj.search_torrents(artistname=semi_clean_artist_term, groupname=semi_clean_album_term, format=search_format, - encoding=bitrate_string)['results']) + encoding=bitrate_string, + releasetype=album_type)['results']) # filter on format, size, and num seeders logger.info(u"Filtering torrents by format, maximum size, and minimum seeders...") From 3e44baaded1f826607caa803a5e6c1f4fa957d54 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 17 Jun 2017 21:36:17 +1200 Subject: [PATCH 117/137] Boxcar icon url --- headphones/notifiers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/headphones/notifiers.py b/headphones/notifiers.py index f8f1b3f8..54f4f4b5 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -790,7 +790,9 @@ class BOXCAR(object): 'user_credentials': headphones.CONFIG.BOXCAR_TOKEN, 'notification[title]': title.encode('utf-8'), 'notification[long_message]': message.encode('utf-8'), - 'notification[sound]': "done" + 'notification[sound]': "done", + 'notification[icon_url]': "https://raw.githubusercontent.com/rembo10/headphones/master/data/images" + "/headphoneslogo.png" }) req = urllib2.Request(self.url) From ee7254ddbee1d40ddcd33b7c2c92580fb1a850c7 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 24 Jun 2017 09:12:17 +1200 Subject: [PATCH 118/137] Version check logging --- headphones/versioncheck.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/headphones/versioncheck.py b/headphones/versioncheck.py index 341f7926..11c4e225 100644 --- a/headphones/versioncheck.py +++ b/headphones/versioncheck.py @@ -39,14 +39,15 @@ def runGit(args): try: logger.debug('Trying to execute: "' + cmd + '" with shell in ' + headphones.PROG_DIR) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, + p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + shell=True, cwd=headphones.PROG_DIR) output, err = p.communicate() output = output.strip() logger.debug('Git output: ' + output) - except OSError: - logger.debug('Command failed: %s', cmd) + except OSError as e: + logger.debug('Command failed: %s. Error: %s' % (cmd, e)) continue if 'not found' in output or "not recognized as an internal or external command" in output: From e96a6fc7c8503546ff1dc7f06a6bccc2ec8e85dc Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 24 Jun 2017 09:27:51 +1200 Subject: [PATCH 119/137] Transmission magnet issue Fixes #2972 --- headphones/transmission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/transmission.py b/headphones/transmission.py index 54241751..47de2c18 100644 --- a/headphones/transmission.py +++ b/headphones/transmission.py @@ -34,7 +34,7 @@ _session_id = None def addTorrent(link, data=None): method = 'torrent-add' - if link.endswith('.torrent') and not link.startswith('http') or data: + if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data: if data: metainfo = str(base64.b64encode(data)) else: From 80fe81e3459d8c0400138df1c2ac0985be5c729c Mon Sep 17 00:00:00 2001 From: David Logie Date: Thu, 16 Mar 2017 20:27:39 +0000 Subject: [PATCH 120/137] Sorted wanted albums by release date. Without this SQLite will order the results by whichever column happens to be the fastest for it. This will usually result in the rows being sorted by the primary key (or rowid). Sorting by release date also matches the sort order of the "upcoming" albums list. --- headphones/webserve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/webserve.py b/headphones/webserve.py index ee353fe2..bef5c315 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -537,7 +537,7 @@ class WebInterface(object): myDB = db.DBConnection() upcoming = myDB.select( "SELECT * from albums WHERE ReleaseDate > date('now') order by ReleaseDate ASC") - wanted = myDB.select("SELECT * from albums WHERE Status='Wanted'") + wanted = myDB.select("SELECT * from albums WHERE Status='Wanted' order by ReleaseDate ASC") return serve_template(templatename="upcoming.html", title="Upcoming", upcoming=upcoming, wanted=wanted) From 0cc2dd7b315cc36b3840a68b282a59b1cc5389c2 Mon Sep 17 00:00:00 2001 From: David Logie Date: Wed, 26 Jul 2017 12:17:29 +0100 Subject: [PATCH 121/137] Fix TypeError in dbcheck(). Fixes #2987 --- headphones/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/headphones/__init__.py b/headphones/__init__.py index 8247e1c2..2bd3bf9c 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -621,8 +621,8 @@ def dbcheck(): c.execute('UPDATE snatched SET TorrentHash = FolderName WHERE Status LIKE "Seed_%"') # One off script to set CleanName to lower case - clean_name_mixed = c.execute('SELECT CleanName FROM have ORDER BY Date Desc').fetchone()[0] - if clean_name_mixed != clean_name_mixed.lower(): + clean_name_mixed = c.execute('SELECT CleanName FROM have ORDER BY Date Desc').fetchone() + if clean_name_mixed and clean_name_mixed[0] != clean_name_mixed[0].lower(): logger.info("Updating track clean name, this could take some time...") c.execute('UPDATE tracks SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName') c.execute('UPDATE alltracks SET CleanName = LOWER(CleanName) WHERE LOWER(CleanName) != CleanName') From 48dd832d4ed5f1ef411906420c22312add1451ac Mon Sep 17 00:00:00 2001 From: Ade Date: Wed, 9 Aug 2017 12:52:11 +1200 Subject: [PATCH 122/137] pep --- headphones/searcher.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index e3c3f591..0dc81b54 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1569,7 +1569,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if apolloobj and apolloobj.logged_in(): logger.info(u"Searching %s..." % provider) all_torrents = [] - + # Specify release types to filter by if album['Type'] == 'Album': album_type = [gazellerelease_type.ALBUM] @@ -1578,7 +1578,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, if album['Type'] == 'EP': album_type = [gazellerelease_type.EP] # No musicbrainz match for this type - #if album['Type'] == 'Anthology': + # if album['Type'] == 'Anthology': # album_type = [gazellerelease_type.ANTHOLOGY] if album['Type'] == 'Compilation': album_type = [gazellerelease_type.COMPILATION] @@ -1596,7 +1596,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, album_type = [gazellerelease_type.INTERVIEW] if album['Type'] == 'Mixtape/Street': album_type = [gazellerelease_type.MIXTAPE] - + for search_format in search_formats: if usersearchterm: all_torrents.extend( From cd57668b92bf33fe3009c3c4760833ca8c05aafa Mon Sep 17 00:00:00 2001 From: Ade Date: Tue, 15 Aug 2017 17:25:37 +1200 Subject: [PATCH 123/137] allow .aif --- headphones/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/__init__.py b/headphones/__init__.py index 2bd3bf9c..c6acc763 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -87,7 +87,7 @@ LATEST_VERSION = None COMMITS_BEHIND = None LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma", "opus"] -LOSSLESS_MEDIA_FORMATS = ["flac", "aiff"] +LOSSLESS_MEDIA_FORMATS = ["flac", "aiff", "aif"] MEDIA_FORMATS = LOSSY_MEDIA_FORMATS + LOSSLESS_MEDIA_FORMATS MIRRORLIST = ["musicbrainz.org", "headphones", "custom"] From bdf55f8662c99cfd32f94a63271368b03500fb7d Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 19 Aug 2017 08:42:04 +1200 Subject: [PATCH 124/137] replace torcache --- headphones/searcher.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 0dc81b54..109901e0 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -43,7 +43,8 @@ from bencode import bencode, bdecode TORRENT_TO_MAGNET_SERVICES = [ # 'https://zoink.it/torrent/%s.torrent', # 'http://torrage.com/torrent/%s.torrent', - 'https://torcache.net/torrent/%s.torrent', + # 'https://torcache.net/torrent/%s.torrent', + 'http://itorrents.org/torrent/%s.torrent', ] # Persistent Apollo.rip API object @@ -878,12 +879,11 @@ def send_to_downloader(data, bestqual, album): services = TORRENT_TO_MAGNET_SERVICES[:] random.shuffle(services) headers = {'User-Agent': USER_AGENT} - headers['Referer'] = 'https://torcache.net/' for service in services: data = request.request_content(service % torrent_hash, headers=headers) - if data and "torcache" in data: + if data: if not torrent_to_file(download_path, data): return # Extract folder name from torrent From 4d575787cd3ae8cf527bbf41e3de7572afd575b6 Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 24 Aug 2017 20:48:15 +1200 Subject: [PATCH 125/137] last.fm import fix Fixes #2753 --- headphones/lastfm.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/headphones/lastfm.py b/headphones/lastfm.py index 8f906d5f..db3e1aec 100644 --- a/headphones/lastfm.py +++ b/headphones/lastfm.py @@ -149,7 +149,10 @@ def getTagTopArtists(tag, limit=50): logger.debug("Fetched %d artists from Last.FM", len(artists)) for artist in artists: - artist_mbid = artist["mbid"] + try: + artist_mbid = artist["mbid"] + except KeyError: + continue if not any(artist_mbid in x for x in results): artistlist.append(artist_mbid) From 9c2bfcf7d87e8873221daa78ae586a5ab6423b8d Mon Sep 17 00:00:00 2001 From: Ade Date: Mon, 28 Aug 2017 20:54:19 +1200 Subject: [PATCH 126/137] Scan Artist exception handling --- headphones/webserve.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/headphones/webserve.py b/headphones/webserve.py index 6d95712a..f1fbbfbe 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -353,9 +353,13 @@ class WebInterface(object): if not os.path.isdir(artistfolder.encode(headphones.SYS_ENCODING)): logger.debug("Cannot find directory: " + artistfolder) continue - threading.Thread(target=librarysync.libraryScan, - kwargs={"dir": artistfolder, "artistScan": True, "ArtistID": ArtistID, - "ArtistName": artist_name}).start() + try: + threading.Thread(target=librarysync.libraryScan, + kwargs={"dir": artistfolder, "artistScan": True, "ArtistID": ArtistID, + "ArtistName": artist_name}).start() + except Exception as e: + logger.error('Unable to complete the scan: %s' % e) + raise cherrypy.HTTPRedirect("artistPage?ArtistID=%s" % ArtistID) @cherrypy.expose From e7062a671054041c7c84f8bb3a38a42294a3f6b5 Mon Sep 17 00:00:00 2001 From: Ade Date: Sat, 2 Sep 2017 19:14:37 +1200 Subject: [PATCH 127/137] Jackett/Torznab v2 api Fixes #2999 --- data/interfaces/default/config.html | 6 +++--- headphones/searcher.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 3859d4a7..28888cae 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -720,8 +720,8 @@
- - e.g. http://localhost:9117/torznab/iptorrents + + e.g. http://localhost:9117/api/v2.0/indexers/demonoid/results/torznab/
@@ -744,7 +744,7 @@
- +
diff --git a/headphones/searcher.py b/headphones/searcher.py index 109901e0..96a45c76 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1318,7 +1318,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, } data = request.request_feed( - url=torznab_host[0] + '/api?', + url=torznab_host[0], params=params, headers=headers ) From 9197f27a505c3f128aa8de0cee77778cc620eec5 Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 7 Sep 2017 22:26:30 +1200 Subject: [PATCH 128/137] Jackett provider formatting --- headphones/searcher.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 96a45c76..692b3378 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1305,8 +1305,12 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, provider = torznab_host[0] + # Format Jackett provider + if "api/v2.0/indexers" in torznab_host[0]: + provider = "Jackett_" + provider.split("/indexers/",1)[1].split('/',1)[0] + # Request results - logger.info('Parsing results from %s using search term: %s' % (torznab_host[0], term)) + logger.info('Parsing results from %s using search term: %s' % (provider, term)) headers = {'User-Agent': USER_AGENT} params = { @@ -1325,7 +1329,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, # Process feed if data: if not len(data.entries): - logger.info(u"No results found from %s for %s", torznab_host[0], term) + logger.info(u"No results found from %s for %s", provider, term) else: for item in data.entries: try: From c8057676db72dad891ea7c44aed1ea62d621cbc8 Mon Sep 17 00:00:00 2001 From: Ade Date: Thu, 7 Sep 2017 22:48:54 +1200 Subject: [PATCH 129/137] pep --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 692b3378..2fc5e78d 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -1307,7 +1307,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, # Format Jackett provider if "api/v2.0/indexers" in torznab_host[0]: - provider = "Jackett_" + provider.split("/indexers/",1)[1].split('/',1)[0] + provider = "Jackett_" + provider.split("/indexers/", 1)[1].split('/', 1)[0] # Request results logger.info('Parsing results from %s using search term: %s' % (provider, term)) From 867f75a681f2fe4b0fa1787e84b9cff184c768e1 Mon Sep 17 00:00:00 2001 From: Noam Date: Sun, 10 Sep 2017 02:15:36 +0300 Subject: [PATCH 130/137] Check _if_ WebUI daemons exist before checking _how many_ exist, fix #3010 --- headphones/deluge.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 78647b0c..fbb0b8e2 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -379,7 +379,8 @@ def _get_auth(): return None delugeweb_hosts = json.loads(response.text)['result'] - if len(delugeweb_hosts) == 0: + # Check if delugeweb_hosts is None before checking its length + if not delugeweb_hosts or len(delugeweb_hosts) == 0: logger.error('Deluge: WebUI does not contain daemons') return None From 0b6f263985b3ea5f46aa3a6cf50eb6f86536849e Mon Sep 17 00:00:00 2001 From: Noam Date: Mon, 11 Sep 2017 19:38:01 +0300 Subject: [PATCH 131/137] Added UTF-8 header for encoding issues --- headphones/deluge.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/headphones/deluge.py b/headphones/deluge.py index fbb0b8e2..e17a6995 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # This file is part of Headphones. # # Headphones is free software: you can redistribute it and/or modify From 4c3b60be68fa4b1732f7553bdf959cbe94cdc8fc Mon Sep 17 00:00:00 2001 From: Noam Date: Sun, 17 Sep 2017 01:40:04 +0300 Subject: [PATCH 132/137] UnicodeDecodeError printing torrent name containing unicode --- headphones/deluge.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index e17a6995..99bd1382 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -170,7 +170,10 @@ def addTorrent(link, data=None, name=None): # remove '.torrent' suffix if name[-len('.torrent'):] == '.torrent': name = name[:-len('.torrent')] - logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) + try: + logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, str(torrentfile)[:40])) + except UnicodeDecodeError: + logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name.decode('utf-8'), str(torrentfile)[:40])) result = {'type': 'torrent', 'name': name, 'content': torrentfile} From b7af246340fa5d5fea30d40d472705ae7b5ae9c2 Mon Sep 17 00:00:00 2001 From: Noam Date: Sun, 17 Sep 2017 02:10:30 +0300 Subject: [PATCH 133/137] Try encoding torrent name utf-8 in params --- headphones/deluge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 99bd1382..07699b0b 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -471,7 +471,8 @@ def _add_torrent_file(result): try: # content is torrent file contents that needs to be encoded to base64 post_data = json.dumps({"method": "core.add_torrent_file", - "params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}], + "params": [result['name'].encode('utf8') + '.torrent', + b64encode(result['content'].encode('utf8')), {}], "id": 2}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, verify=deluge_verify_cert, headers=headers) @@ -484,7 +485,8 @@ def _add_torrent_file(result): # 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'] + '.torrent', b64encode(result['content']), {}], + "params": [result['name'] + '.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) From 68c551c8285942348341f69c640eafd029a63a82 Mon Sep 17 00:00:00 2001 From: Noam Date: Sun, 17 Sep 2017 02:40:09 +0300 Subject: [PATCH 134/137] Try DEcoding torrent name utf-8 in params --- headphones/deluge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/headphones/deluge.py b/headphones/deluge.py index 07699b0b..5eb27849 100644 --- a/headphones/deluge.py +++ b/headphones/deluge.py @@ -471,7 +471,7 @@ def _add_torrent_file(result): try: # content is torrent file contents that needs to be encoded to base64 post_data = json.dumps({"method": "core.add_torrent_file", - "params": [result['name'].encode('utf8') + '.torrent', + "params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}], "id": 2}) response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, @@ -485,7 +485,7 @@ def _add_torrent_file(result): # 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'] + '.torrent', + "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, From 0828643c0943688dd72d60c7e3c78e9a36012810 Mon Sep 17 00:00:00 2001 From: Ade Date: Tue, 26 Sep 2017 11:06:34 +1300 Subject: [PATCH 135/137] Replace windows file/folder problem characters --- headphones/helpers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index 05f6108b..6a117fc1 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -22,6 +22,7 @@ import time import sys import tempfile import glob +from unidecode import unidecode from beets import logging as beetslogging import six @@ -223,7 +224,11 @@ def replace_illegal_chars(string, type="file"): if type == "file": string = re.sub('[\?"*:|<>/]', '_', string) if type == "folder": - string = re.sub('[:\?<>"|]', '_', string) + string = re.sub('[:\?<>"|*]', '_', string) + + # Asciify windows file/folder names + if sys.platform == "win32": + string = unidecode(string) return string From 3cc280666e618bc261725847f75f6685bc3dc9c5 Mon Sep 17 00:00:00 2001 From: Lartza Date: Wed, 18 Oct 2017 22:44:37 +0300 Subject: [PATCH 136/137] Fix encoding selection on Windows --- Headphones.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Headphones.py b/Headphones.py index e4506797..5ad2454d 100755 --- a/Headphones.py +++ b/Headphones.py @@ -54,7 +54,10 @@ def main(): try: locale.setlocale(locale.LC_ALL, "") - headphones.SYS_ENCODING = locale.getpreferredencoding() + if headphones.SYS_PLATFORM == 'win32': + headphones.SYS_ENCODING = sys.getdefaultencoding().upper() + else: + headphones.SYS_ENCODING = locale.getpreferredencoding() except (locale.Error, IOError): pass From f9d73da31b127d57d79144365b3ea89d872cb366 Mon Sep 17 00:00:00 2001 From: Ade Date: Sun, 22 Oct 2017 09:43:55 +1300 Subject: [PATCH 137/137] Remove unidecode for windows file names --- headphones/helpers.py | 6 ------ headphones/webserve.py | 16 ++++++++-------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/headphones/helpers.py b/headphones/helpers.py index 6a117fc1..acc64fd3 100644 --- a/headphones/helpers.py +++ b/headphones/helpers.py @@ -22,7 +22,6 @@ import time import sys import tempfile import glob -from unidecode import unidecode from beets import logging as beetslogging import six @@ -225,11 +224,6 @@ def replace_illegal_chars(string, type="file"): string = re.sub('[\?"*:|<>/]', '_', string) if type == "folder": string = re.sub('[:\?<>"|*]', '_', string) - - # Asciify windows file/folder names - if sys.platform == "win32": - string = unidecode(string) - return string diff --git a/headphones/webserve.py b/headphones/webserve.py index f1fbbfbe..9d703a5c 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -348,17 +348,17 @@ class WebInterface(object): dirs = set(dirs) - for dir in dirs: - artistfolder = os.path.join(dir, folder) - if not os.path.isdir(artistfolder.encode(headphones.SYS_ENCODING)): - logger.debug("Cannot find directory: " + artistfolder) - continue - try: + try: + for dir in dirs: + artistfolder = os.path.join(dir, folder) + if not os.path.isdir(artistfolder.encode(headphones.SYS_ENCODING)): + logger.debug("Cannot find directory: " + artistfolder) + continue threading.Thread(target=librarysync.libraryScan, kwargs={"dir": artistfolder, "artistScan": True, "ArtistID": ArtistID, "ArtistName": artist_name}).start() - except Exception as e: - logger.error('Unable to complete the scan: %s' % e) + except Exception as e: + logger.error('Unable to complete the scan: %s' % e) raise cherrypy.HTTPRedirect("artistPage?ArtistID=%s" % ArtistID)