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 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, 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 () { 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', ''), 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/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 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 ' } 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( diff --git a/headphones/rutracker.py b/headphones/rutracker.py index af8e947e..b80685cf 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -44,28 +44,30 @@ 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(): + if not self.has_bb_data_cookie(r): 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 self.has_bb_data_cookie(r): + 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) 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 diff --git a/headphones/searcher.py b/headphones/searcher.py index c8f16184..cda9a822 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 @@ -24,6 +25,7 @@ import datetime import subprocess import unicodedata import urlparse +from json import loads import os import re @@ -813,6 +815,19 @@ def send_to_downloader(data, bestqual, album): 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: try: @@ -1763,6 +1778,77 @@ 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 = 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: + 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 = 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" diff --git a/headphones/webserve.py b/headphones/webserve.py index f4a06c2b..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 @@ -28,7 +29,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 +70,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() @@ -1224,6 +1230,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), @@ -1420,7 +1429,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", 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" diff --git a/init-scripts/init.ubuntu b/init-scripts/init.ubuntu index c4c0b8dc..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 @@ -105,7 +106,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 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.