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/__init__.py b/headphones/__init__.py index 700caf47..8247e1c2 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -86,7 +86,7 @@ CURRENT_VERSION = None LATEST_VERSION = None COMMITS_BEHIND = None -LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma"] +LOSSY_MEDIA_FORMATS = ["mp3", "aac", "ogg", "ape", "m4a", "asf", "wma", "opus"] LOSSLESS_MEDIA_FORMATS = ["flac", "aiff"] MEDIA_FORMATS = LOSSY_MEDIA_FORMATS + LOSSLESS_MEDIA_FORMATS @@ -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/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/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/helpers.py b/headphones/helpers.py index 2ba72101..05f6108b 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 @@ -257,6 +261,13 @@ _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 @@ -322,9 +333,27 @@ def clean_name(s): # 6. trim u = u.strip() # 7. lowercase + u = u.lower() 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() @@ -951,3 +980,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) 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/mb.py b/headphones/mb.py index faba6b8a..c087d6b9 100644 --- a/headphones/mb.py +++ b/headphones/mb.py @@ -770,3 +770,26 @@ 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 = { 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) diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index e3deee23..38e4bba5 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 @@ -953,14 +954,29 @@ 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: - cur_artist, cur_album, prop = autotag.tag_album(items, - search_artist=helpers.latinToAscii( - release['ArtistName']), - search_album=helpers.latinToAscii( - release['AlbumTitle'])) - candidates = prop.candidates - rec = prop.recommendation + 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_ids=search_ids) + 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 diff --git a/headphones/rutracker.py b/headphones/rutracker.py index 658d490e..7185984d 100644 --- a/headphones/rutracker.py +++ b/headphones/rutracker.py @@ -49,7 +49,11 @@ 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: + 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) if self.has_bb_session_cookie(r): self.loggedin = True logger.info("Successfully logged in to rutracker") @@ -94,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 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: 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: diff --git a/headphones/webserve.py b/headphones/webserve.py index 855fed24..ee353fe2 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 @@ -871,11 +874,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: @@ -1229,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,