From dc22bb006d8b076c16cc5784712ee0b72a03276d Mon Sep 17 00:00:00 2001 From: Ade Date: Tue, 8 Jan 2019 21:09:41 +1300 Subject: [PATCH 01/10] Hotfix index creation from develop Fixes #3175 --- headphones/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/headphones/__init__.py b/headphones/__init__.py index 826658e26..9a0c4fc55 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -443,10 +443,9 @@ def dbcheck(): # General speed up c.execute('CREATE INDEX IF NOT EXISTS artist_artistsortname ON artists(ArtistSortName COLLATE NOCASE ASC)') - exists = c.execute('SELECT * FROM pragma_index_info("have_matched_artist_album")').fetchone() - if not exists: - c.execute('CREATE INDEX have_matched_artist_album ON have(Matched ASC, ArtistName COLLATE NOCASE ASC, AlbumTitle COLLATE NOCASE ASC)') - c.execute('DROP INDEX IF EXISTS have_matched') + c.execute( + """CREATE INDEX IF NOT EXISTS have_matched_artist_album ON have(Matched ASC, ArtistName COLLATE NOCASE ASC, AlbumTitle COLLATE NOCASE ASC)""") + c.execute('DROP INDEX IF EXISTS have_matched') try: c.execute('SELECT IncludeExtras from artists') From 8ad84879f382351ce71165ef33487212f22ce586 Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Sat, 8 Jun 2019 22:52:19 -0700 Subject: [PATCH 02/10] redacted - add album type to search filter --- headphones/searcher.py | 69 +++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 1e51dc58e..590f23002 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -50,7 +50,6 @@ # Persistent RED API object redobj = None - def fix_url(s, charset="utf-8"): """ Fix the URL so it is proper formatted and encoded. @@ -1206,6 +1205,37 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, reldate = album['ReleaseDate'] year = get_year_from_release_date(reldate) + + # Specify release types to filter by - used by Orpheus and Redacted + # Could be added to any Gazelle-based music tracker + album_type = "" + 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] + if album['Type'] == 'Other': + album_type = [gazellerelease_type.UNKNOWN] # MERGE THIS WITH THE TERM CLEANUP FROM searchNZB dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ', '"': '', ',': ' ', @@ -1514,37 +1544,6 @@ def set_proxy(proxy_url): logger.info(u"Searching %s..." % provider) all_torrents = [] - album_type = "" - - # 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] - if album['Type'] == 'Other': - album_type = [gazellerelease_type.UNKNOWN] - for search_format in search_formats: if usersearchterm: all_torrents.extend( @@ -1644,16 +1643,18 @@ def set_proxy(proxy_url): if redobj and redobj.logged_in(): logger.info(u"Searching %s..." % provider) all_torrents = [] + for search_format in search_formats: if usersearchterm: all_torrents.extend( redobj.search_torrents(searchstr=usersearchterm, format=search_format, - encoding=bitrate_string)['results']) + encoding=bitrate_string, releasetype=album_type)['results']) else: all_torrents.extend(redobj.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 fd839347dff397a792a07ea3c6b416ae1ef6da7b Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Sat, 8 Jun 2019 23:34:24 -0700 Subject: [PATCH 03/10] clean up whitespace --- headphones/searcher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 590f23002..432999c58 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -50,6 +50,7 @@ # Persistent RED API object redobj = None + def fix_url(s, charset="utf-8"): """ Fix the URL so it is proper formatted and encoded. @@ -1205,7 +1206,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None, reldate = album['ReleaseDate'] year = get_year_from_release_date(reldate) - + # Specify release types to filter by - used by Orpheus and Redacted # Could be added to any Gazelle-based music tracker album_type = "" From b0585e8c2d2d27b576d47c5308fab784deb4a59a Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Sun, 9 Jun 2019 00:44:49 -0700 Subject: [PATCH 04/10] redacted and orpheus sort results by seeders --- headphones/searcher.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 1e51dc58e..27bd3e8a2 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -436,7 +436,6 @@ def sort_search_results(resultlist, album, new, albumlength): resultlist = temp_list - # if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and result[3] != 'Orpheus.network': if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE: try: @@ -484,15 +483,16 @@ def sort_search_results(resultlist, album, new, albumlength): finallist = sorted(resultlist, key=lambda title: (title[5], int(title[1])), reverse=True) - # keep number of seeders order for Orpheus.network - # if result[3] == 'Orpheus.network': - # finallist = resultlist if not len(finallist): logger.info('No appropriate matches found for %s - %s', album['ArtistName'], album['AlbumTitle']) return None + if (result[3] == 'Orpheus.network') or (result[3] == 'Redacted'): + logger.info('NOTICE: setting finalist for proper order') + finallist = resultlist + return finallist From 3d626be0b4ebb92c38f9b803db2b7bd4c49d3387 Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Sun, 9 Jun 2019 17:16:24 -0700 Subject: [PATCH 05/10] add config option for last.fm api key and musicbrainz useragent --- data/interfaces/default/config.html | 21 +- headphones/cache.py | 5 +- headphones/config.py | 4 +- headphones/lastfm.py | 9 +- headphones/webserve.py | 4 +- lib/musicbrainzngs/musicbrainz.py | 435 ++++++++++++++-------------- 6 files changed, 256 insertions(+), 222 deletions(-) diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index d09f3a7d2..18d6a89c4 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -411,7 +411,7 @@

Settings

-
+
@@ -1722,6 +1722,23 @@

Settings

Get an Account!
+ + +
+ +
+ Lastfm +
+ +
@@ -2456,7 +2473,7 @@

Settings

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,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); diff --git a/headphones/cache.py b/headphones/cache.py index 0e9466777..9500c98b5 100644 --- a/headphones/cache.py +++ b/headphones/cache.py @@ -18,7 +18,10 @@ import headphones from headphones import db, helpers, logger, lastfm, request, mb -LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4" +if headphones.CONFIG.LASTFM_PERSONAL_KEY: + LASTFM_API_KEY = headphones.CONFIG.LASTFM_PERSONAL_KEY +else: + LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4" class Cache(object): diff --git a/headphones/config.py b/headphones/config.py index 7a8b93fc4..7d77386ba 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -314,7 +314,9 @@ def __repr__(self): 'XBMC_PASSWORD': (str, 'XBMC', ''), 'XBMC_UPDATE': (int, 'XBMC', 0), 'XBMC_USERNAME': (str, 'XBMC', ''), - 'XLDPROFILE': (str, 'General', '') + 'XLDPROFILE': (str, 'General', ''), + 'MUSICBRAINZ_USERAGENT': (str, 'General', ''), + 'LASTFM_PERSONAL_KEY': (str, 'General', '') } diff --git a/headphones/lastfm.py b/headphones/lastfm.py index db3e1aec5..e937feae1 100644 --- a/headphones/lastfm.py +++ b/headphones/lastfm.py @@ -28,7 +28,6 @@ # Required for API request limit lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT) - def request_lastfm(method, **kwargs): """ Call a Last.FM API method. Automatically sets the method and API key. Method @@ -37,7 +36,12 @@ def request_lastfm(method, **kwargs): By default, this method will request the JSON format, since it is more lightweight than XML. """ - + + if headphones.CONFIG.LASTFM_PERSONAL_KEY: + API_KEY = headphones.CONFIG.LASTFM_PERSONAL_KEY + else: + API_KEY = "395e6ec6bb557382fc41fde867bce66f" + # Prepare request kwargs["method"] = method kwargs.setdefault("api_key", API_KEY) @@ -45,6 +49,7 @@ def request_lastfm(method, **kwargs): # Send request logger.debug("Calling Last.FM method: %s", method) + logger.debug("API Key is: %s" % API_KEY) logger.debug("Last.FM call parameters: %s", kwargs) data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock) diff --git a/headphones/webserve.py b/headphones/webserve.py index 9e2f1979f..4758faa3a 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1415,7 +1415,9 @@ def config(self): "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 + "join_deviceid": headphones.CONFIG.JOIN_DEVICEID, + "musicbrainz_useragent": headphones.CONFIG.MUSICBRAINZ_USERAGENT, + "lastfm_personal_key": headphones.CONFIG.LASTFM_PERSONAL_KEY } for k, v in config.iteritems(): diff --git a/lib/musicbrainzngs/musicbrainz.py b/lib/musicbrainzngs/musicbrainz.py index e56e67cec..13a910bb9 100755 --- a/lib/musicbrainzngs/musicbrainz.py +++ b/lib/musicbrainzngs/musicbrainz.py @@ -20,6 +20,8 @@ from musicbrainzngs import util from musicbrainzngs import compat +import headphones + # headphones import base64 @@ -166,62 +168,62 @@ class AUTH_IFSET: pass # Exceptions. class MusicBrainzError(Exception): - """Base class for all exceptions related to MusicBrainz.""" - pass + """Base class for all exceptions related to MusicBrainz.""" + pass class UsageError(MusicBrainzError): - """Error related to misuse of the module API.""" - pass + """Error related to misuse of the module API.""" + pass class InvalidSearchFieldError(UsageError): - pass + pass class InvalidIncludeError(UsageError): - def __init__(self, msg='Invalid Includes', reason=None): - super(InvalidIncludeError, self).__init__(self) - self.msg = msg - self.reason = reason + def __init__(self, msg='Invalid Includes', reason=None): + super(InvalidIncludeError, self).__init__(self) + self.msg = msg + self.reason = reason - def __str__(self): - return self.msg + def __str__(self): + return self.msg class InvalidFilterError(UsageError): - def __init__(self, msg='Invalid Includes', reason=None): - super(InvalidFilterError, self).__init__(self) - self.msg = msg - self.reason = reason + def __init__(self, msg='Invalid Includes', reason=None): + super(InvalidFilterError, self).__init__(self) + self.msg = msg + self.reason = reason - def __str__(self): - return self.msg + def __str__(self): + return self.msg class WebServiceError(MusicBrainzError): - """Error related to MusicBrainz API requests.""" - def __init__(self, message=None, cause=None): - """Pass ``cause`` if this exception was caused by another - exception. - """ - self.message = message - self.cause = cause - - def __str__(self): - if self.message: - msg = "%s, " % self.message - else: - msg = "" - msg += "caused by: %s" % str(self.cause) - return msg + """Error related to MusicBrainz API requests.""" + def __init__(self, message=None, cause=None): + """Pass ``cause`` if this exception was caused by another + exception. + """ + self.message = message + self.cause = cause + + def __str__(self): + if self.message: + msg = "%s, " % self.message + else: + msg = "" + msg += "caused by: %s" % str(self.cause) + return msg class NetworkError(WebServiceError): - """Problem communicating with the MB server.""" - pass + """Problem communicating with the MB server.""" + pass class ResponseError(WebServiceError): - """Bad response sent by the MB server.""" - pass + """Bad response sent by the MB server.""" + pass class AuthenticationError(WebServiceError): - """Received a HTTP 401 response while accessing a protected resource.""" - pass + """Received a HTTP 401 response while accessing a protected resource.""" + pass # Helpers for validating and formatting allowed sets. @@ -235,9 +237,9 @@ def _check_includes(entity, inc): _check_includes_impl(inc, VALID_INCLUDES[entity]) def _check_filter(values, valid): - for v in values: - if v not in valid: - raise InvalidFilterError(v) + for v in values: + if v not in valid: + raise InvalidFilterError(v) def _check_filter_and_make_params(entity, includes, release_status=[], release_type=[]): """Check that the status or type values are valid. Then, check that @@ -302,12 +304,12 @@ def _decorator(func): mb_auth = False def auth(u, p): - """Set the username and password to be used in subsequent queries to - the MusicBrainz XML API that require authentication. - """ - global user, password - user = u - password = p + """Set the username and password to be used in subsequent queries to + the MusicBrainz XML API that require authentication. + """ + global user, password + user = u + password = p # headphones def hpauth(u, p): @@ -331,10 +333,13 @@ def set_useragent(app, version, contact=None): global _useragent, _client if not app or not version: raise ValueError("App and version can not be empty") - if contact is not None: - _useragent = "%s/%s python-musicbrainzngs/%s ( %s )" % (app, version, _version, contact) + if headphones.CONFIG.MUSICBRAINZ_USERAGENT: + _useragent = headphones.CONFIG.MUSICBRAINZ_USERAGENT else: - _useragent = "%s/%s python-musicbrainzngs/%s" % (app, version, _version) + if contact is not None: + _useragent = "%s/%s python-musicbrainzngs/%s ( %s )" % (app, version, _version, contact) + else: + _useragent = "%s/%s python-musicbrainzngs/%s" % (app, version, _version) _client = "%s-%s" % (app, version) _log.debug("set user-agent to %s" % _useragent) @@ -422,19 +427,19 @@ def __call__(self, *args, **kwargs): # From pymb2 class _RedirectPasswordMgr(compat.HTTPPasswordMgr): - def __init__(self): - self._realms = { } + def __init__(self): + self._realms = { } - def find_user_password(self, realm, uri): - # ignoring the uri parameter intentionally - try: - return self._realms[realm] - except KeyError: - return (None, None) + def find_user_password(self, realm, uri): + # ignoring the uri parameter intentionally + try: + return self._realms[realm] + except KeyError: + return (None, None) - def add_password(self, realm, uri, username, password): - # ignoring the uri parameter intentionally - self._realms[realm] = (username, password) + def add_password(self, realm, uri, username, password): + # ignoring the uri parameter intentionally + self._realms[realm] = (username, password) class _DigestAuthHandler(compat.HTTPDigestAuthHandler): def get_authorization (self, req, chal): @@ -468,84 +473,84 @@ def get_algorithm_impls(self, algorithm): return H, KD class _MusicbrainzHttpRequest(compat.Request): - """ A custom request handler that allows DELETE and PUT""" - def __init__(self, method, url, data=None): - compat.Request.__init__(self, url, data) - allowed_m = ["GET", "POST", "DELETE", "PUT"] - if method not in allowed_m: - raise ValueError("invalid method: %s" % method) - self.method = method + """ A custom request handler that allows DELETE and PUT""" + def __init__(self, method, url, data=None): + compat.Request.__init__(self, url, data) + allowed_m = ["GET", "POST", "DELETE", "PUT"] + if method not in allowed_m: + raise ValueError("invalid method: %s" % method) + self.method = method - def get_method(self): - return self.method + def get_method(self): + return self.method # Core (internal) functions for calling the MB API. def _safe_read(opener, req, body=None, max_retries=8, retry_delay_delta=2.0): - """Open an HTTP request with a given URL opener and (optionally) a - request body. Transient errors lead to retries. Permanent errors - and repeated errors are translated into a small set of handleable - exceptions. Return a bytestring. - """ - last_exc = None - for retry_num in range(max_retries): - if retry_num: # Not the first try: delay an increasing amount. - _log.info("retrying after delay (#%i)" % retry_num) - time.sleep(retry_num * retry_delay_delta) - - try: - if body: - f = opener.open(req, body) - else: - f = opener.open(req) - return f.read() - - except compat.HTTPError as exc: - if exc.code in (400, 404, 411): - # Bad request, not found, etc. - raise ResponseError(cause=exc) - elif exc.code in (503, 502, 500): - # Rate limiting, internal overloading... - _log.info("HTTP error %i" % exc.code) - elif exc.code in (401, ): - raise AuthenticationError(cause=exc) - else: - # Other, unknown error. Should handle more cases, but - # retrying for now. - _log.info("unknown HTTP error %i" % exc.code) - last_exc = exc - except compat.BadStatusLine as exc: - _log.info("bad status line") - last_exc = exc - except compat.HTTPException as exc: - _log.info("miscellaneous HTTP exception: %s" % str(exc)) - last_exc = exc - except compat.URLError as exc: - if isinstance(exc.reason, socket.error): - code = exc.reason.errno - if code == 104: # "Connection reset by peer." - continue - raise NetworkError(cause=exc) - except socket.timeout as exc: - _log.info("socket timeout") - last_exc = exc - except socket.error as exc: - if exc.errno == 104: - continue - raise NetworkError(cause=exc) - except IOError as exc: - raise NetworkError(cause=exc) - - # Out of retries! - raise NetworkError("retried %i times" % max_retries, last_exc) + """Open an HTTP request with a given URL opener and (optionally) a + request body. Transient errors lead to retries. Permanent errors + and repeated errors are translated into a small set of handleable + exceptions. Return a bytestring. + """ + last_exc = None + for retry_num in range(max_retries): + if retry_num: # Not the first try: delay an increasing amount. + _log.info("retrying after delay (#%i)" % retry_num) + time.sleep(retry_num * retry_delay_delta) + + try: + if body: + f = opener.open(req, body) + else: + f = opener.open(req) + return f.read() + + except compat.HTTPError as exc: + if exc.code in (400, 404, 411): + # Bad request, not found, etc. + raise ResponseError(cause=exc) + elif exc.code in (503, 502, 500): + # Rate limiting, internal overloading... + _log.info("HTTP error %i" % exc.code) + elif exc.code in (401, ): + raise AuthenticationError(cause=exc) + else: + # Other, unknown error. Should handle more cases, but + # retrying for now. + _log.info("unknown HTTP error %i" % exc.code) + last_exc = exc + except compat.BadStatusLine as exc: + _log.info("bad status line") + last_exc = exc + except compat.HTTPException as exc: + _log.info("miscellaneous HTTP exception: %s" % str(exc)) + last_exc = exc + except compat.URLError as exc: + if isinstance(exc.reason, socket.error): + code = exc.reason.errno + if code == 104: # "Connection reset by peer." + continue + raise NetworkError(cause=exc) + except socket.timeout as exc: + _log.info("socket timeout") + last_exc = exc + except socket.error as exc: + if exc.errno == 104: + continue + raise NetworkError(cause=exc) + except IOError as exc: + raise NetworkError(cause=exc) + + # Out of retries! + raise NetworkError("retried %i times" % max_retries, last_exc) # Get the XML parsing exceptions to catch. The behavior chnaged with Python 2.7 # and ElementTree 1.3. if hasattr(etree, 'ParseError'): - ETREE_EXCEPTIONS = (etree.ParseError, expat.ExpatError) + ETREE_EXCEPTIONS = (etree.ParseError, expat.ExpatError) else: - ETREE_EXCEPTIONS = (expat.ExpatError) + ETREE_EXCEPTIONS = (expat.ExpatError) # Parsing setup @@ -716,100 +721,100 @@ def _get_auth_type(entity, id, includes): return AUTH_NO def _do_mb_query(entity, id, includes=[], params={}): - """Make a single GET call to the MusicBrainz XML API. `entity` is a - string indicated the type of object to be retrieved. The id may be - empty, in which case the query is a search. `includes` is a list - of strings that must be valid includes for the entity type. `params` - is a dictionary of additional parameters for the API call. The - response is parsed and returned. - """ - # Build arguments. - if not isinstance(includes, list): - includes = [includes] - _check_includes(entity, includes) - auth_required = _get_auth_type(entity, id, includes) - args = dict(params) - if len(includes) > 0: - inc = " ".join(includes) - args["inc"] = inc - - # Build the endpoint components. - path = '%s/%s' % (entity, id) - return _mb_request(path, 'GET', auth_required, args=args) + """Make a single GET call to the MusicBrainz XML API. `entity` is a + string indicated the type of object to be retrieved. The id may be + empty, in which case the query is a search. `includes` is a list + of strings that must be valid includes for the entity type. `params` + is a dictionary of additional parameters for the API call. The + response is parsed and returned. + """ + # Build arguments. + if not isinstance(includes, list): + includes = [includes] + _check_includes(entity, includes) + auth_required = _get_auth_type(entity, id, includes) + args = dict(params) + if len(includes) > 0: + inc = " ".join(includes) + args["inc"] = inc + + # Build the endpoint components. + path = '%s/%s' % (entity, id) + return _mb_request(path, 'GET', auth_required, args=args) def _do_mb_search(entity, query='', fields={}, - limit=None, offset=None, strict=False): - """Perform a full-text search on the MusicBrainz search server. - `query` is a lucene query string when no fields are set, - but is escaped when any fields are given. `fields` is a dictionary - of key/value query parameters. They keys in `fields` must be valid - for the given entity type. - """ - # Encode the query terms as a Lucene query string. - query_parts = [] - if query: - clean_query = util._unicode(query) - if fields: - clean_query = re.sub(LUCENE_SPECIAL, r'\\\1', - clean_query) - if strict: - query_parts.append('"%s"' % clean_query) - else: - query_parts.append(clean_query.lower()) - else: - query_parts.append(clean_query) - for key, value in fields.items(): - # Ensure this is a valid search field. - if key not in VALID_SEARCH_FIELDS[entity]: - raise InvalidSearchFieldError( - '%s is not a valid search field for %s' % (key, entity) - ) - elif key == "puid": - warn("PUID support was removed from server\n" - "the 'puid' field is ignored", - Warning, stacklevel=2) - - # Escape Lucene's special characters. - value = util._unicode(value) - value = re.sub(LUCENE_SPECIAL, r'\\\1', value) - if value: - if strict: - query_parts.append('%s:"%s"' % (key, value)) - else: - value = value.lower() # avoid AND / OR - query_parts.append('%s:(%s)' % (key, value)) - if strict: - full_query = ' AND '.join(query_parts).strip() - else: - full_query = ' '.join(query_parts).strip() - - if not full_query: - raise ValueError('at least one query term is required') - - # Additional parameters to the search. - params = {'query': full_query} - if limit: - params['limit'] = str(limit) - if offset: - params['offset'] = str(offset) - - return _do_mb_query(entity, '', [], params) + limit=None, offset=None, strict=False): + """Perform a full-text search on the MusicBrainz search server. + `query` is a lucene query string when no fields are set, + but is escaped when any fields are given. `fields` is a dictionary + of key/value query parameters. They keys in `fields` must be valid + for the given entity type. + """ + # Encode the query terms as a Lucene query string. + query_parts = [] + if query: + clean_query = util._unicode(query) + if fields: + clean_query = re.sub(LUCENE_SPECIAL, r'\\\1', + clean_query) + if strict: + query_parts.append('"%s"' % clean_query) + else: + query_parts.append(clean_query.lower()) + else: + query_parts.append(clean_query) + for key, value in fields.items(): + # Ensure this is a valid search field. + if key not in VALID_SEARCH_FIELDS[entity]: + raise InvalidSearchFieldError( + '%s is not a valid search field for %s' % (key, entity) + ) + elif key == "puid": + warn("PUID support was removed from server\n" + "the 'puid' field is ignored", + Warning, stacklevel=2) + + # Escape Lucene's special characters. + value = util._unicode(value) + value = re.sub(LUCENE_SPECIAL, r'\\\1', value) + if value: + if strict: + query_parts.append('%s:"%s"' % (key, value)) + else: + value = value.lower() # avoid AND / OR + query_parts.append('%s:(%s)' % (key, value)) + if strict: + full_query = ' AND '.join(query_parts).strip() + else: + full_query = ' '.join(query_parts).strip() + + if not full_query: + raise ValueError('at least one query term is required') + + # Additional parameters to the search. + params = {'query': full_query} + if limit: + params['limit'] = str(limit) + if offset: + params['offset'] = str(offset) + + return _do_mb_query(entity, '', [], params) def _do_mb_delete(path): - """Send a DELETE request for the specified object. - """ - return _mb_request(path, 'DELETE', AUTH_YES, True) + """Send a DELETE request for the specified object. + """ + return _mb_request(path, 'DELETE', AUTH_YES, True) def _do_mb_put(path): - """Send a PUT request for the specified object. - """ - return _mb_request(path, 'PUT', AUTH_YES, True) + """Send a PUT request for the specified object. + """ + return _mb_request(path, 'PUT', AUTH_YES, True) def _do_mb_post(path, body): - """Perform a single POST call for an endpoint with a specified - request body. - """ - return _mb_request(path, 'POST', AUTH_YES, True, body=body) + """Perform a single POST call for an endpoint with a specified + request body. + """ + return _mb_request(path, 'POST', AUTH_YES, True, body=body) # The main interface! @@ -993,7 +998,7 @@ def search_releases(query='', limit=None, offset=None, strict=False, **fields): @_docstring_search("release-group") def search_release_groups(query='', limit=None, offset=None, - strict=False, **fields): + strict=False, **fields): """Search for release groups and return a dict with a 'release-group-list' key. From 033f52a54ffb65242a950ec767f54fe7c37d415c Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Sun, 9 Jun 2019 17:24:22 -0700 Subject: [PATCH 06/10] extra newline fix --- headphones/searcher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index 27bd3e8a2..f5cf65a26 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -483,7 +483,6 @@ def sort_search_results(resultlist, album, new, albumlength): finallist = sorted(resultlist, key=lambda title: (title[5], int(title[1])), reverse=True) - if not len(finallist): logger.info('No appropriate matches found for %s - %s', album['ArtistName'], album['AlbumTitle']) From 0d87b52da07b9f14fa51dbdf4dbc7fe32948e538 Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Mon, 10 Jun 2019 10:02:37 -0700 Subject: [PATCH 07/10] add debug logging for musicbrainz --- headphones/lastfm.py | 2 +- lib/musicbrainzngs/musicbrainz.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/headphones/lastfm.py b/headphones/lastfm.py index e937feae1..8b7568056 100644 --- a/headphones/lastfm.py +++ b/headphones/lastfm.py @@ -49,7 +49,7 @@ def request_lastfm(method, **kwargs): # Send request logger.debug("Calling Last.FM method: %s", method) - logger.debug("API Key is: %s" % API_KEY) + logger.debug("Last.FM API Key is: %s" % API_KEY) logger.debug("Last.FM call parameters: %s", kwargs) data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock) diff --git a/lib/musicbrainzngs/musicbrainz.py b/lib/musicbrainzngs/musicbrainz.py index 13a910bb9..f477924db 100755 --- a/lib/musicbrainzngs/musicbrainz.py +++ b/lib/musicbrainzngs/musicbrainz.py @@ -21,6 +21,7 @@ from musicbrainzngs import compat import headphones +from headphones import logger # headphones import base64 @@ -507,6 +508,7 @@ def _safe_read(opener, req, body=None, max_retries=8, retry_delay_delta=2.0): return f.read() except compat.HTTPError as exc: + logger.error("Musicbrainz request error: HTTP code %s" % exc.code) if exc.code in (400, 404, 411): # Bad request, not found, etc. raise ResponseError(cause=exc) @@ -689,6 +691,7 @@ def _mb_request(path, method='GET', auth_required=AUTH_NO, # Make request. req = _MusicbrainzHttpRequest(method, url, data) req.add_header('User-Agent', _useragent) + logger.debug("Musicbrainz request url: %s" % url) # Add headphones credentials if mb_auth: @@ -696,6 +699,7 @@ def _mb_request(path, method='GET', auth_required=AUTH_NO, req.add_header("Authorization", "Basic %s" % base64string) _log.debug("requesting with UA %s" % _useragent) + logger.debug("Musicbrainz user-agent: %s" % _useragent) if body: req.add_header('Content-Type', 'application/xml; charset=UTF-8') elif not data and not req.has_header('Content-Length'): From 5820ca1e765f12205f43c652be0e53c1f3861414 Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Mon, 10 Jun 2019 10:23:14 -0700 Subject: [PATCH 08/10] make if statement safer --- headphones/searcher.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index f5cf65a26..a0e32bc65 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -488,9 +488,10 @@ def sort_search_results(resultlist, album, new, albumlength): album['AlbumTitle']) return None - if (result[3] == 'Orpheus.network') or (result[3] == 'Redacted'): - logger.info('NOTICE: setting finalist for proper order') - finallist = resultlist + if result[3]: + if (result[3] == 'Orpheus.network') or (result[3] == 'Redacted'): + logger.info('Keeping torrent ordered by seeders for %s' % result[3]) + finallist = resultlist return finallist From 03b1c86dffceb2e96e943a8e1d25fc13420d9949 Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Mon, 10 Jun 2019 10:25:35 -0700 Subject: [PATCH 09/10] minor typo fix --- headphones/searcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/headphones/searcher.py b/headphones/searcher.py index a0e32bc65..a53a96335 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -490,7 +490,7 @@ def sort_search_results(resultlist, album, new, albumlength): if result[3]: if (result[3] == 'Orpheus.network') or (result[3] == 'Redacted'): - logger.info('Keeping torrent ordered by seeders for %s' % result[3]) + logger.info('Keeping torrents ordered by seeders for %s' % result[3]) finallist = resultlist return finallist From d3e4d5e1197b53d31c44e244d528a0451684b420 Mon Sep 17 00:00:00 2001 From: doucheymcdoucherson Date: Wed, 12 Jun 2019 17:01:50 -0700 Subject: [PATCH 10/10] change artwork lookup to fanart.tv --- data/interfaces/default/index.html | 2 +- headphones/cache.py | 141 ++- lib/fanart/__init__.py | 118 ++ lib/fanart/core.py | 49 + lib/fanart/errors.py | 15 + lib/fanart/immutable.py | 46 + lib/fanart/items.py | 70 ++ lib/fanart/movie.py | 103 ++ lib/fanart/music.py | 80 ++ lib/fanart/tests/__init__.py | 3 + lib/fanart/tests/json/wilfred.json | 327 +++++ lib/fanart/tests/response/50x50.png | Bin 0 -> 180 bytes lib/fanart/tests/response/movie_thg.json | 643 ++++++++++ lib/fanart/tests/response/music_a7f.json | 648 ++++++++++ lib/fanart/tests/response/tv_239761.json | 256 ++++ lib/fanart/tests/response/tv_79349.json | 1408 ++++++++++++++++++++++ lib/fanart/tests/test_core.py | 25 + lib/fanart/tests/test_immutable.py | 49 + lib/fanart/tests/test_items.py | 27 + lib/fanart/tests/test_movie.py | 22 + lib/fanart/tests/test_music.py | 24 + lib/fanart/tests/test_tv.py | 54 + lib/fanart/tv.py | 124 ++ 23 files changed, 4181 insertions(+), 53 deletions(-) create mode 100644 lib/fanart/__init__.py create mode 100644 lib/fanart/core.py create mode 100644 lib/fanart/errors.py create mode 100644 lib/fanart/immutable.py create mode 100644 lib/fanart/items.py create mode 100644 lib/fanart/movie.py create mode 100644 lib/fanart/music.py create mode 100644 lib/fanart/tests/__init__.py create mode 100644 lib/fanart/tests/json/wilfred.json create mode 100644 lib/fanart/tests/response/50x50.png create mode 100644 lib/fanart/tests/response/movie_thg.json create mode 100644 lib/fanart/tests/response/music_a7f.json create mode 100644 lib/fanart/tests/response/tv_239761.json create mode 100644 lib/fanart/tests/response/tv_79349.json create mode 100644 lib/fanart/tests/test_core.py create mode 100644 lib/fanart/tests/test_immutable.py create mode 100644 lib/fanart/tests/test_items.py create mode 100644 lib/fanart/tests/test_movie.py create mode 100644 lib/fanart/tests/test_music.py create mode 100644 lib/fanart/tests/test_tv.py create mode 100644 lib/fanart/tv.py diff --git a/data/interfaces/default/index.html b/data/interfaces/default/index.html index 260c0a1a3..e7becc618 100644 --- a/data/interfaces/default/index.html +++ b/data/interfaces/default/index.html @@ -36,7 +36,7 @@ "aTargets": [0], "mData":"ArtistID", "mRender": function ( data, type, full ) { - return '
'; + return '
'; } }, { diff --git a/headphones/cache.py b/headphones/cache.py index 0e9466777..199afd51f 100644 --- a/headphones/cache.py +++ b/headphones/cache.py @@ -16,10 +16,11 @@ import os import headphones -from headphones import db, helpers, logger, lastfm, request, mb +from headphones import db, helpers, logger, lastfm, request, mb, os +from fanart.music import Artist LASTFM_API_KEY = "690e1ed3bc00bc91804cd8f7fe5ed6d4" - +os.environ.setdefault('FANART_APIKEY', '1f081b32bcd780219f4e6d519f78e37e') class Cache(object): """ @@ -106,22 +107,6 @@ def _is_current(self, filename=None, date=None): else: return False - def _get_thumb_url(self, data): - - thumb_url = None - - try: - images = data[self.id_type]['image'] - except KeyError: - return None - - for image in images: - if image['size'] == 'medium' and '#text' in image: - thumb_url = image['#text'] - break - - return thumb_url - def get_artwork_from_cache(self, ArtistID=None, AlbumID=None): """ Pass a musicbrainz id to this function (either ArtistID or AlbumID) @@ -139,7 +124,7 @@ def get_artwork_from_cache(self, ArtistID=None, AlbumID=None): if self._exists('artwork') and self._is_current(filename=self.artwork_files[0]): return self.artwork_files[0] else: - self._update_cache() + self._update_cache(ArtistID, AlbumID) # If we failed to get artwork, either return the url or the older file if self.artwork_errors and self.artwork_url: return self.artwork_url @@ -165,7 +150,7 @@ def get_thumb_from_cache(self, ArtistID=None, AlbumID=None): if self._exists('thumb') and self._is_current(filename=self.thumb_files[0]): return self.thumb_files[0] else: - self._update_cache() + self._update_cache(ArtistID, AlbumID) # If we failed to get artwork, either return the url or the older file if self.thumb_errors and self.thumb_url: return self.thumb_url @@ -195,7 +180,7 @@ def get_info_from_cache(self, ArtistID=None, AlbumID=None): if not db_info or not db_info['LastUpdated'] or not self._is_current( date=db_info['LastUpdated']): - self._update_cache() + self._update_cache(ArtistID, AlbumID) info_dict = {'Summary': self.info_summary, 'Content': self.info_content} return info_dict @@ -211,39 +196,62 @@ def get_image_links(self, ArtistID=None, AlbumID=None): if ArtistID: self.id_type = 'artist' - data = lastfm.request_lastfm("artist.getinfo", mbid=ArtistID, api_key=LASTFM_API_KEY) + data = Artist.get(id=ArtistID) + logger.debug('Fanart.tv ArtistID: %s', ArtistID) if not data: + logger.debug('Fanart.tv ArtistID not found!') return try: - image_url = data['artist']['image'][-1]['#text'] + for thumbs in data.thumbs[0:1]: + url = str(thumbs.url) + thumb_url = url.replace('fanart/', 'preview/') + image_url = thumb_url + logger.debug('Fanart.tv artist url: %s', image_url) + logger.debug('Fanart.tv artist thumb url: %s', thumb_url) except (KeyError, IndexError): - logger.debug('No artist image found') + logger.debug('Fanart.tv: No artist image found') image_url = None + thumb_url = None - thumb_url = self._get_thumb_url(data) if not thumb_url: - logger.debug('No artist thumbnail image found') + logger.debug('Fanart.tv: No artist thumbnail image found') + + if not image_url: + logger.debug('Fanart.tv: No artist image found') else: self.id_type = 'album' - data = lastfm.request_lastfm("album.getinfo", mbid=AlbumID, api_key=LASTFM_API_KEY) + data = Artist.get(id="ArtistID") + logger.debug('Fanart.tv AlbumID: %s', AlbumID) + for x in data.albums: + if x.mbid == AlbumID: + album_url = str(x.covers[0]) if not data: + logger.debug('Fanart.tv: Album not found') return try: - image_url = data['album']['image'][-1]['#text'] + for x in data.albums: + if x.mbid == AlbumID: + album_url = str(x.covers[0]) + thumb_url = album_url.replace('fanart/', 'preview/') + image_url = thumb_url + logger.debug('Fanart.tv album url: %s', image_url) + logger.debug('Fanart.tv album thumb url: %s', thumb_url) except (KeyError, IndexError): - logger.debug('No album image found on last.fm') + logger.debug('Fanart.tv: No album image found') image_url = None - - thumb_url = self._get_thumb_url(data) + thumb_url = None if not thumb_url: - logger.debug('No album thumbnail image found on last.fm') + logger.debug('Fanart.tv no album thumbnail image found on fanart.tv') + + if not image_url: + logger.debug('Fanart.tv no album image found on fanart.tv') return {'artwork': image_url, 'thumbnail': thumb_url} @@ -277,7 +285,7 @@ def remove_from_cache(self, ArtistID=None, AlbumID=None): except Exception: logger.warn('Error deleting file from the cache: %s', thumb_file) - def _update_cache(self): + def _update_cache(self, ArtistID, AlbumID): """ Since we call the same url for both info and artwork, we'll update both at the same time """ @@ -288,6 +296,28 @@ def _update_cache(self): # Exception is when adding albums manually, then we should use release id if self.id_type == 'artist': + data = Artist.get(id=self.id) + + logger.debug('Fanart.tv ArtistID is: %s', self.id) + + try: + for thumbs in data.thumbs[0:1]: + url = str(thumbs.url) + thumb_url = url.replace('fanart/', 'preview/') + image_url = thumb_url + logger.debug('Fanart.tv artist url: %s', image_url) + logger.debug('Fanart.tv artist thumb url: %s', thumb_url) + except KeyError: + logger.debug('Fanart.tv: No artist image found') + image_url = None + thumb_url = None + + if not thumb_url: + logger.debug('Fanart.tv: No artist thumbnail image found') + + if not image_url: + logger.debug('Fanart.tv: No artist image found') + data = lastfm.request_lastfm("artist.getinfo", mbid=self.id, api_key=LASTFM_API_KEY) # Try with name if not found @@ -311,17 +341,38 @@ def _update_cache(self): except KeyError: logger.debug('No artist bio found') self.info_content = None + + else: + + # get ArtistID from AlbumID lookup - ArtistID not passed into this function otherwise + myDB = db.DBConnection() + ArtistID = myDB.action('SELECT ArtistID FROM albums WHERE ReleaseID=?', [self.id]).fetchone()[0] + + #ALBUM SECTION + logger.debug('Fanart.tv AlbumID: %s', AlbumID) + logger.debug('Fanart.tv ArtistID: %s', ArtistID) + + data = Artist.get(id=ArtistID) + try: - image_url = data['artist']['image'][-1]['#text'] + for x in data.albums: + if x.mbid == AlbumID: + album_url = str(x.covers[0]) + thumb_url = album_url.replace('fanart/', 'preview/') + image_url = thumb_url + logger.debug('Fanart.tv album url: %s', image_url) + logger.debug('Fanart.tv album thumb url: %s', thumb_url) except KeyError: - logger.debug('No artist image found') + logger.debug('Fanart.tv: No album image link found') image_url = None + thumb_url = None - thumb_url = self._get_thumb_url(data) if not thumb_url: - logger.debug('No artist thumbnail image found') + logger.debug('Fanart.tv: No album thumbnail image found') + + if not image_url: + logger.debug('Fanart.tv: No album image found') - else: dbalbum = myDB.action( 'SELECT ArtistName, AlbumTitle, ReleaseID, Type FROM albums WHERE AlbumID=?', [self.id]).fetchone() @@ -362,16 +413,6 @@ def _update_cache(self): except KeyError: logger.debug('No album infomation found') self.info_content = None - try: - image_url = data['album']['image'][-1]['#text'] - except KeyError: - logger.debug('No album image link found') - image_url = None - - thumb_url = self._get_thumb_url(data) - - if not thumb_url: - logger.debug('No album thumbnail image found') # Save the content & summary to the database no matter what if we've # opened up the url @@ -478,7 +519,6 @@ def _update_cache(self): self.thumb_errors = True self.thumb_url = image_url - def getArtwork(ArtistID=None, AlbumID=None): c = Cache() artwork_path = c.get_artwork_from_cache(ArtistID, AlbumID) @@ -492,7 +532,6 @@ def getArtwork(ArtistID=None, AlbumID=None): artwork_file = os.path.basename(artwork_path) return "cache/artwork/" + artwork_file - def getThumb(ArtistID=None, AlbumID=None): c = Cache() artwork_path = c.get_thumb_from_cache(ArtistID, AlbumID) @@ -506,14 +545,12 @@ def getThumb(ArtistID=None, AlbumID=None): thumbnail_file = os.path.basename(artwork_path) return "cache/artwork/" + thumbnail_file - def getInfo(ArtistID=None, AlbumID=None): c = Cache() info_dict = c.get_info_from_cache(ArtistID, AlbumID) return info_dict - def getImageLinks(ArtistID=None, AlbumID=None): c = Cache() image_links = c.get_image_links(ArtistID, AlbumID) diff --git a/lib/fanart/__init__.py b/lib/fanart/__init__.py new file mode 100644 index 000000000..01d686b53 --- /dev/null +++ b/lib/fanart/__init__.py @@ -0,0 +1,118 @@ +__author__ = 'Andrea De Marco <24erre@gmail.com>' +__maintainer__ = 'Pol Canelles ' +__version__ = '2.0.0' +__classifiers__ = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Libraries', +] +__copyright__ = "2012, %s " % __author__ +__license__ = """ + Copyright %s. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either expressed or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" % __copyright__ + +__docformat__ = 'restructuredtext en' + +__doc__ = """ +:abstract: Python interface to fanart.tv API (v3) +:version: %s +:author: %s +:contact: http://z4r.github.com/ +:date: 2012-04-04 +:copyright: %s +""" % (__version__, __author__, __license__) + + +def values(obj): + return [v for k, v in obj.__dict__.items() if not k.startswith('_')] + + +BASEURL = 'http://webservice.fanart.tv/v3' + + +class FORMAT(object): + JSON = 'JSON' + XML = 'XML' + PHP = 'PHP' + + +class WS(object): + MUSIC = 'music' + MOVIE = 'movies' + TV = 'tv' + + +class TYPE(object): + ALL = 'all' + + class TV(object): + ART = 'clearart' + LOGO = 'clearlogo' + CHARACTER = 'characterart' + THUMB = 'tvthumb' + SEASONTHUMB = 'seasonthumb' + SEASONBANNER = 'seasonbanner' + SEASONPOSTER = 'seasonposter' + BACKGROUND = 'showbackground' + HDLOGO = 'hdtvlogo' + HDART = 'hdclearart' + POSTER = 'tvposter' + BANNER = 'tvbanner' + + class MUSIC(object): + DISC = 'cdart' + LOGO = 'musiclogo' + BACKGROUND = 'artistbackground' + COVER = 'albumcover' + THUMB = 'artistthumb' + + class MOVIE(object): + ART = 'movieart' + LOGO = 'movielogo' + DISC = 'moviedisc' + POSTER = 'movieposter' + BACKGROUND = 'moviebackground' + HDLOGO = 'hdmovielogo' + HDART = 'hdmovieclearart' + BANNER = 'moviebanner' + THUMB = 'moviethumb' + + +class SORT(object): + POPULAR = 1 + NEWEST = 2 + OLDEST = 3 + + +class LIMIT(object): + ONE = 1 + ALL = 2 + + +FORMAT_LIST = values(FORMAT) +WS_LIST = values(WS) +TYPE_LIST = values(TYPE.MUSIC) + values(TYPE.TV) + values(TYPE.MOVIE) + [TYPE.ALL] +MUSIC_TYPE_LIST = values(TYPE.MUSIC) + [TYPE.ALL] +TV_TYPE_LIST = values(TYPE.TV) + [TYPE.ALL] +MOVIE_TYPE_LIST = values(TYPE.MOVIE) + [TYPE.ALL] +SORT_LIST = values(SORT) +LIMIT_LIST = values(LIMIT) diff --git a/lib/fanart/core.py b/lib/fanart/core.py new file mode 100644 index 000000000..8e01bab79 --- /dev/null +++ b/lib/fanart/core.py @@ -0,0 +1,49 @@ +import requests +import fanart +from fanart.errors import RequestFanartError, ResponseFanartError + + +class Request(object): + def __init__(self, apikey, id, ws, type=None, sort=None, limit=None): + ''' + .. warning:: Since the migration to fanart.tv's api v3, we cannot use + the kwargs `type/sort/limit` as we did before, so for now this + kwargs will be ignored. + ''' + self._apikey = apikey + self._id = id + self._ws = ws + self._type = type or fanart.TYPE.ALL + self._sort = sort or fanart.SORT.POPULAR + self._limit = limit or fanart.LIMIT.ALL + self.validate() + self._response = None + + def validate(self): + for attribute_name in ('ws', 'type', 'sort', 'limit'): + attribute = getattr(self, '_' + attribute_name) + choices = getattr(fanart, attribute_name.upper() + '_LIST') + if attribute not in choices: + raise RequestFanartError( + 'Not allowed {}: {} [{}]'.format( + attribute_name, attribute, ', '.join(choices))) + + def __str__(self): + return '{base_url}/{ws}/{id}?api_key={apikey}'.format( + base_url=fanart.BASEURL, + ws=self._ws, + id=self._id, + apikey=self._apikey, + ) + + def response(self): + try: + response = requests.get(str(self)) + rjson = response.json() + if not isinstance(rjson, dict): + raise Exception(response.text) + if 'error message' in rjson: + raise Exception(rjson['status'], rjson['error message']) + return rjson + except Exception as e: + raise ResponseFanartError(str(e)) diff --git a/lib/fanart/errors.py b/lib/fanart/errors.py new file mode 100644 index 000000000..95a71e35e --- /dev/null +++ b/lib/fanart/errors.py @@ -0,0 +1,15 @@ +class FanartError(Exception): + def __str__(self): + return ', '.join(map(str, self.args)) + + def __repr__(self): + name = self.__class__.__name__ + return '%s%r' % (name, self.args) + + +class ResponseFanartError(FanartError): + pass + + +class RequestFanartError(FanartError): + pass diff --git a/lib/fanart/immutable.py b/lib/fanart/immutable.py new file mode 100644 index 000000000..d51ce1826 --- /dev/null +++ b/lib/fanart/immutable.py @@ -0,0 +1,46 @@ +class Immutable(object): + _mutable = False + + def __setattr__(self, name, value): + if self._mutable or name == '_mutable': + super(Immutable, self).__setattr__(name, value) + else: + raise TypeError("Can't modify immutable instance") + + def __delattr__(self, name): + if self._mutable: + super(Immutable, self).__delattr__(name) + else: + raise TypeError("Can't modify immutable instance") + + def __eq__(self, other): + return hash(self) == hash(other) + + def __hash__(self): + return hash(repr(self)) + + def __repr__(self): + return '%s(%s)' % ( + self.__class__.__name__, + ', '.join(['{0}={1}'.format(k, repr(v)) for k, v in self]) + ) + + def __iter__(self): + l = list(self.__dict__.keys()) + l.sort() + for k in l: + if not k.startswith('_'): + yield k, getattr(self, k) + + @staticmethod + def mutablemethod(f): + def func(self, *args, **kwargs): + if isinstance(self, Immutable): + old_mutable = self._mutable + self._mutable = True + res = f(self, *args, **kwargs) + self._mutable = old_mutable + else: + res = f(self, *args, **kwargs) + return res + return func diff --git a/lib/fanart/items.py b/lib/fanart/items.py new file mode 100644 index 000000000..77bd85306 --- /dev/null +++ b/lib/fanart/items.py @@ -0,0 +1,70 @@ +import json +import os +import requests +from fanart.core import Request +from fanart.immutable import Immutable + + +class LeafItem(Immutable): + KEY = NotImplemented + + @Immutable.mutablemethod + def __init__(self, id, url, likes): + self.id = int(id) + self.url = url + self.likes = int(likes) + self._content = None + + @classmethod + def from_dict(cls, resource): + return cls(**dict([(str(k), v) for k, v in resource.items()])) + + @classmethod + def extract(cls, resource): + return [cls.from_dict(i) for i in resource.get(cls.KEY, {})] + + @Immutable.mutablemethod + def content(self): + if not self._content: + self._content = requests.get(self.url).content + return self._content + + def __str__(self): + return self.url + + +class ResourceItem(Immutable): + WS = NotImplemented + request_cls = Request + + @classmethod + def from_dict(cls, map): + raise NotImplementedError + + @classmethod + def get(cls, id): + map = cls.request_cls( + apikey=os.environ.get('FANART_APIKEY'), + id=id, + ws=cls.WS + ).response() + return cls.from_dict(map) + + def json(self, **kw): + return json.dumps( + self, + default=lambda o: dict( + [(k, v) for k, v in o.__dict__.items() + if not k.startswith('_')]), + **kw + ) + + +class CollectableItem(Immutable): + @classmethod + def from_dict(cls, key, map): + raise NotImplementedError + + @classmethod + def collection_from_dict(cls, map): + return [cls.from_dict(k, v) for k, v in map.items()] diff --git a/lib/fanart/movie.py b/lib/fanart/movie.py new file mode 100644 index 000000000..1ff2c1238 --- /dev/null +++ b/lib/fanart/movie.py @@ -0,0 +1,103 @@ +import fanart +from fanart.items import LeafItem, Immutable, ResourceItem +__all__ = ( + 'ArtItem', + 'DiscItem', + 'LogoItem', + 'PosterItem', + 'BackgroundItem', + 'HdLogoItem', + 'HdArtItem', + 'BannerItem', + 'ThumbItem', + 'Movie', +) + + +class MovieItem(LeafItem): + + @Immutable.mutablemethod + def __init__(self, id, url, likes, lang): + super(MovieItem, self).__init__(id, url, likes) + self.lang = lang + + +class DiscItem(MovieItem): + KEY = fanart.TYPE.MOVIE.DISC + + @Immutable.mutablemethod + def __init__(self, id, url, likes, lang, disc, disc_type): + super(DiscItem, self).__init__(id, url, likes, lang) + self.disc = int(disc) + self.disc_type = disc_type + + +class ArtItem(MovieItem): + KEY = fanart.TYPE.MOVIE.ART + + +class LogoItem(MovieItem): + KEY = fanart.TYPE.MOVIE.LOGO + + +class PosterItem(MovieItem): + KEY = fanart.TYPE.MOVIE.POSTER + + +class BackgroundItem(MovieItem): + KEY = fanart.TYPE.MOVIE.BACKGROUND + + +class HdLogoItem(MovieItem): + KEY = fanart.TYPE.MOVIE.HDLOGO + + +class HdArtItem(MovieItem): + KEY = fanart.TYPE.MOVIE.HDART + + +class BannerItem(MovieItem): + KEY = fanart.TYPE.MOVIE.BANNER + + +class ThumbItem(MovieItem): + KEY = fanart.TYPE.MOVIE.THUMB + + +class Movie(ResourceItem): + WS = fanart.WS.MOVIE + + @Immutable.mutablemethod + def __init__(self, name, imdbid, tmdbid, arts, logos, discs, posters, + backgrounds, hdlogos, hdarts, banners, thumbs): + self.name = name + self.imdbid = imdbid + self.tmdbid = tmdbid + self.arts = arts + self.posters = posters + self.logos = logos + self.discs = discs + self.backgrounds = backgrounds + self.hdlogos = hdlogos + self.hdarts = hdarts + self.banners = banners + self.thumbs = thumbs + + @classmethod + def from_dict(cls, resource): + minimal_keys = {'name', 'imdb_id', 'tmdb_id'} + assert all(k in resource for k in minimal_keys), 'Bad Format Map' + return cls( + name=resource['name'], + imdbid=resource['imdb_id'], + tmdbid=resource['tmdb_id'], + arts=ArtItem.extract(resource), + logos=LogoItem.extract(resource), + discs=DiscItem.extract(resource), + posters=PosterItem.extract(resource), + backgrounds=BackgroundItem.extract(resource), + hdlogos=HdLogoItem.extract(resource), + hdarts=HdArtItem.extract(resource), + banners=BannerItem.extract(resource), + thumbs=ThumbItem.extract(resource), + ) diff --git a/lib/fanart/music.py b/lib/fanart/music.py new file mode 100644 index 000000000..61e23d231 --- /dev/null +++ b/lib/fanart/music.py @@ -0,0 +1,80 @@ +from fanart.items import Immutable, LeafItem, ResourceItem, CollectableItem +import fanart +__all__ = ( + 'BackgroundItem', + 'CoverItem', + 'LogoItem', + 'ThumbItem', + 'DiscItem', + 'Artist', + 'Album', +) + + +class BackgroundItem(LeafItem): + KEY = fanart.TYPE.MUSIC.BACKGROUND + + +class CoverItem(LeafItem): + KEY = fanart.TYPE.MUSIC.COVER + + +class LogoItem(LeafItem): + KEY = fanart.TYPE.MUSIC.LOGO + + +class ThumbItem(LeafItem): + KEY = fanart.TYPE.MUSIC.THUMB + + +class DiscItem(LeafItem): + KEY = fanart.TYPE.MUSIC.DISC + + @Immutable.mutablemethod + def __init__(self, id, url, likes, disc, size): + super(DiscItem, self).__init__(id, url, likes) + self.disc = int(disc) + self.size = int(size) + + +class Artist(ResourceItem): + WS = fanart.WS.MUSIC + + @Immutable.mutablemethod + def __init__(self, name, mbid, albums, backgrounds, logos, thumbs): + self.name = name + self.mbid = mbid + self.albums = albums + self.backgrounds = backgrounds + self.logos = logos + self.thumbs = thumbs + + @classmethod + def from_dict(cls, resource): + minimal_keys = {'name', 'mbid_id'} + assert all(k in resource for k in minimal_keys), 'Bad Format Map' + return cls( + name=resource['name'], + mbid=resource['mbid_id'], + albums=Album.collection_from_dict(resource.get('albums', {})), + backgrounds=BackgroundItem.extract(resource), + thumbs=ThumbItem.extract(resource), + logos=LogoItem.extract(resource), + ) + + +class Album(CollectableItem): + + @Immutable.mutablemethod + def __init__(self, mbid, covers, arts): + self.mbid = mbid + self.covers = covers + self.arts = arts + + @classmethod + def from_dict(cls, key, resource): + return cls( + mbid=key, + covers=CoverItem.extract(resource), + arts=DiscItem.extract(resource), + ) diff --git a/lib/fanart/tests/__init__.py b/lib/fanart/tests/__init__.py new file mode 100644 index 000000000..957cbe388 --- /dev/null +++ b/lib/fanart/tests/__init__.py @@ -0,0 +1,3 @@ +import os + +LOCALDIR = os.path.dirname(__file__) diff --git a/lib/fanart/tests/json/wilfred.json b/lib/fanart/tests/json/wilfred.json new file mode 100644 index 000000000..07687e090 --- /dev/null +++ b/lib/fanart/tests/json/wilfred.json @@ -0,0 +1,327 @@ +{ + "name": "Wilfred (US)", + "tvdbid": "239761", + "backgrounds": [ + { + "id": 19965, + "url": "https://assets.fanart.tv/fanart/tv/239761/showbackground/wilfred-us-5034dbd49115e.jpg", + "likes": 0, + "lang": "", + "season": 0 + }, + { + "id": 23166, + "url": "https://assets.fanart.tv/fanart/tv/239761/showbackground/wilfred-us-50b0c92db6973.jpg", + "likes": 0, + "lang": "", + "season": 0 + }, + { + "id": 23167, + "url": "https://assets.fanart.tv/fanart/tv/239761/showbackground/wilfred-us-50b0c92dbb46b.jpg", + "likes": 0, + "lang": "", + "season": 0 + }, + { + "id": 23168, + "url": "https://assets.fanart.tv/fanart/tv/239761/showbackground/wilfred-us-50b0c92dbb9d1.jpg", + "likes": 0, + "lang": "", + "season": 0 + }, + { + "id": 36386, + "url": "https://assets.fanart.tv/fanart/tv/239761/showbackground/wilfred-us-52d53d75d4782.jpg", + "likes": 0, + "lang": "", + "season": 0 + } + ], + "characters": [], + "arts": [ + { + "id": 11987, + "url": "https://assets.fanart.tv/fanart/tv/239761/clearart/wilfred-us-4e05f10e87711.png", + "likes": 2, + "lang": "en" + }, + { + "id": 12470, + "url": "https://assets.fanart.tv/fanart/tv/239761/clearart/wilfred-us-4e2f151d5ed62.png", + "likes": 1, + "lang": "en" + } + ], + "logos": [ + { + "id": 11977, + "url": "https://assets.fanart.tv/fanart/tv/239761/clearlogo/wilfred-us-4e04b6495dfd3.png", + "likes": 2, + "lang": "en" + }, + { + "id": 28249, + "url": "https://assets.fanart.tv/fanart/tv/239761/clearlogo/wilfred-us-517ac36e39f67.png", + "likes": 1, + "lang": "en" + }, + { + "id": 31817, + "url": "https://assets.fanart.tv/fanart/tv/239761/clearlogo/wilfred-us-51f557082cfde.png", + "likes": 0, + "lang": "en" + } + ], + "seasons": [ + { + "id": 33752, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-52403782bab55.jpg", + "likes": 1, + "lang": "he", + "season": 1 + }, + { + "id": 33753, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-5240379335232.jpg", + "likes": 1, + "lang": "he", + "season": 2 + }, + { + "id": 33754, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-524037bc83c7d.jpg", + "likes": 1, + "lang": "he", + "season": 3 + }, + { + "id": 19586, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-501bb0a8e60f9.jpg", + "likes": 0, + "lang": "en", + "season": 1 + }, + { + "id": 19587, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-501bb0b4bf229.jpg", + "likes": 0, + "lang": "en", + "season": 2 + }, + { + "id": 19588, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-501bb144e6a46.jpg", + "likes": 0, + "lang": "en", + "season": 0 + }, + { + "id": 30309, + "url": "https://assets.fanart.tv/fanart/tv/239761/seasonthumb/wilfred-us-51c953105ef77.jpg", + "likes": 0, + "lang": "en", + "season": 3 + } + ], + "thumbs": [ + { + "id": 19596, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvthumb/wilfred-us-501cf526174fe.jpg", + "likes": 1, + "lang": "en" + }, + { + "id": 30060, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvthumb/wilfred-us-51bfb4a105904.jpg", + "likes": 1, + "lang": "en" + } + ], + "hdlogos": [ + { + "id": 21101, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdtvlogo/wilfred-us-505f373be58e6.png", + "likes": 2, + "lang": "en" + }, + { + "id": 28248, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdtvlogo/wilfred-us-517ac360def17.png", + "likes": 2, + "lang": "en" + }, + { + "id": 33750, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdtvlogo/wilfred-us-52402df7ed945.png", + "likes": 1, + "lang": "he" + }, + { + "id": 42207, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdtvlogo/wilfred-us-53d12425b2d8c.png", + "likes": 1, + "lang": "ru" + }, + { + "id": 31816, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdtvlogo/wilfred-us-51f556fb4abd3.png", + "likes": 0, + "lang": "en" + } + ], + "hdarts": [ + { + "id": 21112, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdclearart/wilfred-us-505f94ed0ba13.png", + "likes": 1, + "lang": "en" + }, + { + "id": 33751, + "url": "https://assets.fanart.tv/fanart/tv/239761/hdclearart/wilfred-us-52403264aa3ec.png", + "likes": 1, + "lang": "he" + } + ], + "posters": [ + { + "id": 34584, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvposter/wilfred-us-525d893230d7c.jpg", + "likes": 1, + "lang": "he" + } + ], + "banners": [ + { + "id": 33755, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-52403a7185070.jpg", + "likes": 1, + "lang": "he" + }, + { + "id": 57138, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-5607140bac1c6.jpg", + "likes": 1, + "lang": "en" + }, + { + "id": 34716, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-5265193db51f7.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 36389, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-52d578d57cb24.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 36390, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-52d578de27a66.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41457, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53a4da4cd2a21.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41458, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53a4da6b594e5.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41685, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53b7db8014425.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41686, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53b7dbb922332.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 57139, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-56071512b14d6.jpg", + "likes": 0, + "lang": "en" + } + ], + "season_posters": [ + { + "id": 34584, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvposter/wilfred-us-525d893230d7c.jpg", + "likes": 1, + "lang": "he" + } + ], + "season_banners": [ + { + "id": 33755, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-52403a7185070.jpg", + "likes": 1, + "lang": "he" + }, + { + "id": 57138, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-5607140bac1c6.jpg", + "likes": 1, + "lang": "en" + }, + { + "id": 34716, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-5265193db51f7.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 36389, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-52d578d57cb24.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 36390, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-52d578de27a66.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41457, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53a4da4cd2a21.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41458, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53a4da6b594e5.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41685, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53b7db8014425.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 41686, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-53b7dbb922332.jpg", + "likes": 0, + "lang": "en" + }, + { + "id": 57139, + "url": "https://assets.fanart.tv/fanart/tv/239761/tvbanner/wilfred-us-56071512b14d6.jpg", + "likes": 0, + "lang": "en" + } + ] +} \ No newline at end of file diff --git a/lib/fanart/tests/response/50x50.png b/lib/fanart/tests/response/50x50.png new file mode 100644 index 0000000000000000000000000000000000000000..0ba41614ca1b571daa258e917d1a1e6f5143790c GIT binary patch literal 180 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_>3?$zOPHh5G(g8jpu4m4inKo_OoH=tAFJ8QT z`}U(pk8a$!an-6-`}Xa7TsYkfs4T?O#WBR9H#tFqb#X&j!^tBj89g+2uShsyFhhz# zll57Gk1ZS9o0B}J&YgK-wB`&K+nPl@jBXM}I%k%#t&!s4J0`Jg;|fbl18YNzr3pI{ eM0%LsFfhcQVxRr2jC~%^d