From 1d3181fe4e19517860517a6f10bf8244dc237054 Mon Sep 17 00:00:00 2001 From: warwickh Date: Tue, 22 Jun 2021 16:35:39 +1000 Subject: [PATCH] Initial Matrix Compatible commit --- addon.xml | 8 +- lib/libsonic/__init__.py | 12 +- lib/libsonic/connection.py | 356 +++++-- lib/libsonic_extra/__init__.py | 442 --------- lib/simpleplugin/__init__.py | 2 +- lib/simpleplugin/simpleplugin.py | 1248 +++++++++++++++++-------- main.py | 407 ++++++-- resources/language/English/strings.po | 10 + resources/language/French/strings.po | 11 + resources/language/German/strings.po | 13 + resources/settings.xml | 7 +- 11 files changed, 1515 insertions(+), 1001 deletions(-) delete mode 100644 lib/libsonic_extra/__init__.py diff --git a/addon.xml b/addon.xml index c630c93..d89c9a1 100644 --- a/addon.xml +++ b/addon.xml @@ -1,7 +1,7 @@ - + - + @@ -32,6 +32,10 @@ Beihilfe ist Willkommen: https://github.com/gordielachance/plugin.audio.subsonic + + icon.png + icon.jpg + multi all diff --git a/lib/libsonic/__init__.py b/lib/libsonic/__init__.py index 7832d82..a5e1a9b 100644 --- a/lib/libsonic/__init__.py +++ b/lib/libsonic/__init__.py @@ -1,32 +1,24 @@ """ This file is part of py-sonic. - py-sonic is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - py-sonic is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - You should have received a copy of the GNU General Public License along with py-sonic. If not, see - For information on method calls, see 'pydoc libsonic.connection' - ---------- Basic example: ---------- - import libsonic - conn = libsonic.Connection('http://localhost' , 'admin' , 'password') print conn.ping() - """ -from connection import * +from .connection import * -__version__ = '0.6.2' +__version__ = '0.7.9' diff --git a/lib/libsonic/connection.py b/lib/libsonic/connection.py index ec116ef..8046f22 100644 --- a/lib/libsonic/connection.py +++ b/lib/libsonic/connection.py @@ -15,23 +15,28 @@ You should have received a copy of the GNU General Public License along with py-sonic. If not, see """ -from urllib import urlencode -from .errors import * -from pprint import pprint -from cStringIO import StringIO +from libsonic.errors import * from netrc import netrc from hashlib import md5 -import json, urllib2, httplib, logging, socket, ssl, sys, os +import urllib.request +import urllib.error +from http import client as http_client +from urllib.parse import urlencode +from io import StringIO -API_VERSION = '1.14.0' +import json +import logging +import socket +import ssl +import sys +import os +import xbmc + +API_VERSION = '1.16.1' logger = logging.getLogger(__name__) -class HTTPSConnectionChain(httplib.HTTPSConnection): - _preferred_ssl_protos = sorted([ p for p in dir(ssl) - if p.startswith('PROTOCOL_') ], reverse=True) - _ssl_working_proto = None - +class HTTPSConnectionChain(http_client.HTTPSConnection): def _create_sock(self): sock = socket.create_connection((self.host, self.port), self.timeout) if self._tunnel_host: @@ -40,38 +45,21 @@ class HTTPSConnectionChain(httplib.HTTPSConnection): return sock def connect(self): - if self._ssl_working_proto is not None: - # If we have a working proto, let's use that straight away - logger.debug("Using known working proto: '%s'", - self._ssl_working_proto) - sock = self._create_sock() - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, - ssl_version=self._ssl_working_proto) - return + sock = self._create_sock() + try: + self.sock = ssl.create_default_context().wrap_socket(sock, + server_hostname=self.host) + except: + sock.close() - # Try connecting via the different SSL protos in preference order - for proto_name in self._preferred_ssl_protos: - sock = self._create_sock() - proto = getattr(ssl, proto_name, None) - try: - self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, - ssl_version=proto) - except: - sock.close() - else: - # Cache the working ssl version - HTTPSConnectionChain._ssl_working_proto = proto - break - - -class HTTPSHandlerChain(urllib2.HTTPSHandler): +class HTTPSHandlerChain(urllib.request.HTTPSHandler): def https_open(self, req): return self.do_open(HTTPSConnectionChain, req) # install opener -urllib2.install_opener(urllib2.build_opener(HTTPSHandlerChain())) +urllib.request.install_opener(urllib.request.build_opener(HTTPSHandlerChain())) -class PysHTTPRedirectHandler(urllib2.HTTPRedirectHandler): +class PysHTTPRedirectHandler(urllib.request.HTTPRedirectHandler): """ This class is used to override the default behavior of the HTTPRedirectHandler, which does *not* redirect POST data @@ -81,24 +69,30 @@ class PysHTTPRedirectHandler(urllib2.HTTPRedirectHandler): if (code in (301, 302, 303, 307) and m in ("GET", "HEAD") or code in (301, 302, 303) and m == "POST"): newurl = newurl.replace(' ', '%20') - newheaders = dict((k, v) for k, v in req.headers.items() + newheaders = dict((k, v) for k, v in list(req.headers.items()) if k.lower() not in ("content-length", "content-type") ) data = None - if req.has_data(): - data = req.get_data() - return urllib2.Request(newurl, + if req.data: + data = req.data + return urllib.request.Request(newurl, data=data, headers=newheaders, - origin_req_host=req.get_origin_req_host(), + origin_req_host=req.origin_req_host, unverifiable=True) else: - raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) + raise urllib.error.HTTPError( + req.get_full_url(), + code, + msg, + headers, + fp, + ) class Connection(object): def __init__(self, baseUrl, username=None, password=None, port=4040, serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION, - insecure=False, useNetrc=None, legacyAuth=False, useGET=False): + insecure=False, useNetrc=None, legacyAuth=False, useGET=True): """ This will create a connection to your subsonic server @@ -236,6 +230,8 @@ class Connection(object): viewName = '%s.view' % methodName req = self._getRequest(viewName) + #res = self._doInfoReq(req) + #xbmc.log(res,xbmc.LOGINFO) try: res = self._doInfoReq(req) except: @@ -271,6 +267,52 @@ class Connection(object): self._checkStatus(res) return res + def getScanStatus(self): + """ + since: 1.15.0 + + returns the current status for media library scanning. + takes no extra parameters. + + returns a dict like the following: + + {'status': 'ok', 'version': '1.15.0', + 'scanstatus': {'scanning': true, 'count': 4680}} + + 'count' is the total number of items to be scanned + """ + methodName = 'getScanStatus' + viewName = '%s.view' % methodName + + req = self._getRequest(viewName) + res = self._doInfoReq(req) + self._checkStatus(res) + return res + + def startScan(self): + """ + since: 1.15.0 + + Initiates a rescan of the media libraries. + Takes no extra parameters. + + returns a dict like the following: + + {'status': 'ok', 'version': '1.15.0', + 'scanstatus': {'scanning': true, 'count': 0}} + + 'scanning' changes to false when a scan is complete + 'count' starts a 0 and ends at the total number of items scanned + + """ + methodName = 'startScan' + viewName = '%s.view' % methodName + + req = self._getRequest(viewName) + res = self._doInfoReq(req) + self._checkStatus(res) + return res + def getMusicFolders(self): """ since: 1.0.0 @@ -344,7 +386,7 @@ class Connection(object): artists for the given folder ID from the getMusicFolders call ifModifiedSince:int If specified, return a result if the artist - collection has changed since the given + collection has changed since the given unix timestamp Returns a dict like the following: @@ -809,6 +851,57 @@ class Connection(object): self._checkStatus(res) return res + def streamUrl(self, sid, maxBitRate=0, tformat=None, timeOffset=None, + size=None, estimateContentLength=False, converted=False): + """ + since: 1.0.0 + + Downloads a given music file. + + sid:str The ID of the music file to download. + maxBitRate:int (since: 1.2.0) If specified, the server will + attempt to limit the bitrate to this value, in + kilobits per second. If set to zero (default), no limit + is imposed. Legal values are: 0, 32, 40, 48, 56, 64, + 80, 96, 112, 128, 160, 192, 224, 256 and 320. + tformat:str (since: 1.6.0) Specifies the target format + (e.g. "mp3" or "flv") in case there are multiple + applicable transcodings (since: 1.9.0) You can use + the special value "raw" to disable transcoding + timeOffset:int (since: 1.6.0) Only applicable to video + streaming. Start the stream at the given + offset (in seconds) into the video + size:str (since: 1.6.0) The requested video size in + WxH, for instance 640x480 + estimateContentLength:bool (since: 1.8.0) If set to True, + the HTTP Content-Length header + will be set to an estimated + value for trancoded media + converted:bool (since: 1.14.0) Only applicable to video streaming. + Subsonic can optimize videos for streaming by + converting them to MP4. If a conversion exists for + the video in question, then setting this parameter + to "true" will cause the converted video to be + returned instead of the original. + + Returns the file-like object for reading or raises an exception + on error + """ + methodName = 'stream' + viewName = '%s.view' % methodName + + q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate, + 'format': tformat, 'timeOffset': timeOffset, 'size': size, + 'estimateContentLength': estimateContentLength, + 'converted': converted}) + + req = self._getRequest(viewName, q) + res = self._doBinReq(req) + if isinstance(res, dict): + self._checkStatus(res) + return req.full_url + + def getCoverArt(self, aid, size=None): """ since: 1.0.0 @@ -827,11 +920,37 @@ class Connection(object): q = self._getQueryDict({'id': aid, 'size': size}) req = self._getRequest(viewName, q) + xbmc.log("Is this what I need? %s"%str(req.full_url),xbmc.LOGINFO) res = self._doBinReq(req) if isinstance(res, dict): self._checkStatus(res) return res + def getCoverArtUrl(self, aid, size=None): + """ + since: 1.0.0 + + Returns a cover art image + + aid:str ID string for the cover art image to download + size:int If specified, scale image to this size + + Returns the file-like object for reading or raises an exception + on error + """ + methodName = 'getCoverArt' + viewName = '%s.view' % methodName + + q = self._getQueryDict({'id': aid, 'size': size}) + + req = self._getRequest(viewName, q) + xbmc.log("Is this what I need? %s"%str(req.full_url),xbmc.LOGINFO) + res = self._doBinReq(req) + if isinstance(res, dict): + self._checkStatus(res) + return req.full_url + + def scrobble(self, sid, submission=True, listenTime=None): """ since: 1.5.0 @@ -980,7 +1099,7 @@ class Connection(object): streamRole=True, jukeboxRole=False, downloadRole=False, uploadRole=False, playlistRole=False, coverArtRole=False, commentRole=False, podcastRole=False, shareRole=False, - musicFolderId=None): + videoConversionRole=False, musicFolderId=None): """ since: 1.1.0 @@ -1011,6 +1130,7 @@ class Connection(object): 'uploadRole': uploadRole, 'playlistRole': playlistRole, 'coverArtRole': coverArtRole, 'commentRole': commentRole, 'podcastRole': podcastRole, 'shareRole': shareRole, + 'videoConversionRole': videoConversionRole, 'musicFolderId': musicFolderId }) @@ -1024,7 +1144,7 @@ class Connection(object): streamRole=True, jukeboxRole=False, downloadRole=False, uploadRole=False, playlistRole=False, coverArtRole=False, commentRole=False, podcastRole=False, shareRole=False, - musicFolderId=None, maxBitRate=0): + videoConversionRole=False, musicFolderId=None, maxBitRate=0): """ since 1.10.1 @@ -1056,6 +1176,7 @@ class Connection(object): 'uploadRole': uploadRole, 'playlistRole': playlistRole, 'coverArtRole': coverArtRole, 'commentRole': commentRole, 'podcastRole': podcastRole, 'shareRole': shareRole, + 'videoConversionRole': videoConversionRole, 'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate }) req = self._getRequest(viewName, q) @@ -1960,7 +2081,7 @@ class Connection(object): req = self._getRequest(viewName, q) try: res = self._doBinReq(req) - except urllib2.HTTPError: + except urllib.error.HTTPError: # Avatar is not set/does not exist, return None return None if isinstance(res, dict): @@ -2065,7 +2186,7 @@ class Connection(object): musicFolderId:int Only return results from the music folder with the given ID. See getMusicFolders """ - methodName = 'getGenres' + methodName = 'getSongsByGenre' viewName = '%s.view' % methodName q = self._getQueryDict({'genre': genre, @@ -2112,7 +2233,7 @@ class Connection(object): req = self._getRequest(viewName, q) try: res = self._doBinReq(req) - except urllib2.HTTPError: + except urllib.error.HTTPError: # Avatar is not set/does not exist, return None return None if isinstance(res, dict): @@ -2224,6 +2345,70 @@ class Connection(object): self._checkStatus(res) return res + def createInternetRadioStation(self, streamUrl, name, homepageUrl=None): + """ + since 1.16.0 + + Create an internet radio station + + streamUrl:str The stream URL for the station + name:str The user-defined name for the station + homepageUrl:str The homepage URL for the station + """ + methodName = 'createInternetRadioStation' + viewName = '{}.view'.format(methodName) + + q = self._getQueryDict({ + 'streamUrl': streamUrl, 'name': name, 'homepageUrl': homepageUrl}) + + req = self._getRequest(viewName, q) + res = self._doInfoReq(req) + self._checkStatus(res) + return res + + def updateInternetRadioStation(self, iid, streamUrl, name, + homepageUrl=None): + """ + since 1.16.0 + + Create an internet radio station + + iid:str The ID for the station + streamUrl:str The stream URL for the station + name:str The user-defined name for the station + homepageUrl:str The homepage URL for the station + """ + methodName = 'updateInternetRadioStation' + viewName = '{}.view'.format(methodName) + + q = self._getQueryDict({ + 'id': iid, 'streamUrl': streamUrl, 'name': name, + 'homepageUrl': homepageUrl, + }) + + req = self._getRequest(viewName, q) + res = self._doInfoReq(req) + self._checkStatus(res) + return res + + def deleteInternetRadioStation(self, iid): + """ + since 1.16.0 + + Create an internet radio station + + iid:str The ID for the station + """ + methodName = 'deleteInternetRadioStation' + viewName = '{}.view'.format(methodName) + + q = {'id': iid} + + req = self._getRequest(viewName, q) + res = self._doInfoReq(req) + self._checkStatus(res) + return res + def getBookmarks(self): """ since: 1.9.0 @@ -2376,10 +2561,10 @@ class Connection(object): position:int The position, in milliseconds, within the current playing song - Saves the state of the play queue for this user. This includes - the tracks in the play queue, the currently playing track, and - the position within this track. Typically used to allow a user to - move between different clients/apps while retaining the same play + Saves the state of the play queue for this user. This includes + the tracks in the play queue, the currently playing track, and + the position within this track. Typically used to allow a user to + move between different clients/apps while retaining the same play queue (for instance when listening to an audio book). """ methodName = 'savePlayQueue' @@ -2388,7 +2573,7 @@ class Connection(object): qids = [qids] q = self._getQueryDict({'current': current, 'position': position}) - + req = self._getRequestWithLists(viewName, {'id': qids}, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2398,16 +2583,16 @@ class Connection(object): """ since 1.12.0 - Returns the state of the play queue for this user (as set by - savePlayQueue). This includes the tracks in the play queue, - the currently playing track, and the position within this track. - Typically used to allow a user to move between different - clients/apps while retaining the same play queue (for instance + Returns the state of the play queue for this user (as set by + savePlayQueue). This includes the tracks in the play queue, + the currently playing track, and the position within this track. + Typically used to allow a user to move between different + clients/apps while retaining the same play queue (for instance when listening to an audio book). """ methodName = 'getPlayQueue' viewName = '%s.view' % methodName - + req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) @@ -2424,9 +2609,9 @@ class Connection(object): """ methodName = 'getTopSongs' viewName = '%s.view' % methodName - + q = {'artist': artist, 'count': count} - + req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2442,9 +2627,9 @@ class Connection(object): """ methodName = 'getNewestPodcasts' viewName = '%s.view' % methodName - + q = {'count': count} - + req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2561,7 +2746,7 @@ class Connection(object): url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port, self._separateServerPath(), viewName, methodName) - req = urllib2.Request(url) + req = urllib.request.Request(url) res = self._opener.open(req) res_msg = res.msg.lower() return res_msg == 'ok' @@ -2575,14 +2760,17 @@ class Connection(object): if sys.version_info[:3] >= (2, 7, 9) and self._insecure: https_chain = HTTPSHandlerChain( context=ssl._create_unverified_context()) - opener = urllib2.build_opener(PysHTTPRedirectHandler, https_chain) + opener = urllib.request.build_opener( + PysHTTPRedirectHandler, + https_chain, + ) return opener def _getQueryDict(self, d): """ Given a dictionary, it cleans out all the values set to None """ - for k, v in d.items(): + for k, v in list(d.items()): if v is None: del d[k] return d @@ -2599,7 +2787,7 @@ class Connection(object): qdict['p'] = 'enc:%s' % self._hexEnc(self._rawPass) else: salt = self._getSalt() - token = md5(self._rawPass + salt).hexdigest() + token = md5((self._rawPass + salt).encode('utf-8')).hexdigest() qdict.update({ 's': salt, 't': token, @@ -2612,12 +2800,14 @@ class Connection(object): qdict.update(query) url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, viewName) - req = urllib2.Request(url, urlencode(qdict)) + xbmc.log("Standard URL %s"%url,level=xbmc.LOGINFO) + xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGINFO) + req = urllib.request.Request(url, urlencode(qdict).encode('utf-8')) if self._useGET: url += '?%s' % urlencode(qdict) - req = urllib2.Request(url) - + xbmc.log("UseGET URL %s"%(url),xbmc.LOGINFO) + req = urllib.request.Request(url) return req def _getRequestWithList(self, viewName, listName, alist, query={}): @@ -2633,7 +2823,7 @@ class Connection(object): data.write(urlencode(qdict)) for i in alist: data.write('&%s' % urlencode({listName: i})) - req = urllib2.Request(url, data.getvalue()) + req = urllib.request.Request(url, data.getvalue().encode('utf-8')) if self._useGET: url += '?%s' % data.getvalue() @@ -2657,10 +2847,10 @@ class Connection(object): viewName) data = StringIO() data.write(urlencode(qdict)) - for k, l in listMap.iteritems(): + for k, l in listMap.items(): for i in l: data.write('&%s' % urlencode({k: i})) - req = urllib2.Request(url, data.getvalue()) + req = urllib.request.Request(url, data.getvalue().encode('utf-8')) if self._useGET: url += '?%s' % data.getvalue() @@ -2671,12 +2861,18 @@ class Connection(object): def _doInfoReq(self, req): # Returns a parsed dictionary version of the result res = self._opener.open(req) - dres = json.loads(res.read()) + dres = json.loads(res.read().decode('utf-8')) + xbmc.log("ddres %s"%(str(dres)),xbmc.LOGINFO) return dres['subsonic-response'] def _doBinReq(self, req): res = self._opener.open(req) - contType = res.info().getheader('Content-Type') + info = res.info() + if hasattr(info, 'getheader'): + contType = info.getheader('Content-Type') + else: + contType = info.get('Content-Type') + if contType: if contType.startswith('text/html') or \ contType.startswith('application/json'): @@ -2716,7 +2912,7 @@ class Connection(object): """ separate REST portion of URL from base server path. """ - return urllib2.splithost(self._serverPath)[1].split('/')[0] + return urllib.parse.splithost(self._serverPath)[1].split('/')[0] def _fixLastModified(self, data): """ @@ -2726,9 +2922,9 @@ class Connection(object): of SECONDS since the unix epoch. JAVA SUCKS! """ if isinstance(data, dict): - for k, v in data.items(): + for k, v in list(data.items()): if k == 'lastModified': - data[k] = long(v) / 1000.0 + data[k] = int(v) / 1000.0 return elif isinstance(v, (tuple, list, dict)): return self._fixLastModified(v) diff --git a/lib/libsonic_extra/__init__.py b/lib/libsonic_extra/__init__.py deleted file mode 100644 index b490a39..0000000 --- a/lib/libsonic_extra/__init__.py +++ /dev/null @@ -1,442 +0,0 @@ -import urllib -import urlparse -import libsonic - -def force_list(value): - """ - Coerce the input value to a list. - - If `value` is `None`, return an empty list. If it is a single value, create - a new list with that element on index 0. - - :param value: Input value to coerce. - :return: Value as list. - :rtype: list - """ - - if value is None: - return [] - elif type(value) == list: - return value - else: - return [value] - - -class SubsonicClient(libsonic.Connection): - """ - Extend `libsonic.Connection` with new features and fix a few issues. - - - Parse URL for host and port for constructor. - - Make sure API results are of of uniform type. - - Provide methods to intercept URL of binary requests. - - Add order property to playlist items. - - Add conventient `walk_*' methods to iterate over the API responses. - """ - - def __init__(self, url, username, password, apiversion, insecure, legacyauth): - """ - Construct a new SubsonicClient. - - :param str url: Full URL (including scheme) of the Subsonic server. - :param str username: Username of the server. - :param str password: Password of the server. - """ - - self.intercept_url = False - - # Parse Subsonic URL - parts = urlparse.urlparse(url) - scheme = parts.scheme or "http" - - # Make sure there is hostname - if not parts.hostname: - raise ValueError("Expected hostname for URL: %s" % url) - - # Validate scheme - if scheme not in ("http", "https"): - raise ValueError("Unexpected scheme '%s' for URL: %s" % ( - scheme, url)) - - # Pick a default port - host = "%s://%s" % (scheme, parts.hostname) - port = parts.port or {"http": 80, "https": 443}[scheme] - path = parts.path.rstrip('/') + '/rest' - - # Invoke original constructor - super(SubsonicClient, self).__init__( - host, username, password, port=port, serverPath=path, appName='Kodi', apiVersion=apiversion, insecure=insecure, legacyAuth=legacyauth) - - def getIndexes(self, *args, **kwargs): - """ - Improve the getIndexes method. Ensures IDs are integers. - """ - - def _artists_iterator(artists): - for artist in force_list(artists): - artist["id"] = int(artist["id"]) - yield artist - - def _index_iterator(index): - for index in force_list(index): - index["artist"] = list(_artists_iterator(index.get("artist"))) - yield index - - def _children_iterator(children): - for child in force_list(children): - child["id"] = int(child["id"]) - - if "parent" in child: - child["parent"] = int(child["parent"]) - if "coverArt" in child: - child["coverArt"] = int(child["coverArt"]) - if "artistId" in child: - child["artistId"] = int(child["artistId"]) - if "albumId" in child: - child["albumId"] = int(child["albumId"]) - - yield child - - response = super(SubsonicClient, self).getIndexes(*args, **kwargs) - response["indexes"] = response.get("indexes", {}) - response["indexes"]["index"] = list( - _index_iterator(response["indexes"].get("index"))) - response["indexes"]["child"] = list( - _children_iterator(response["indexes"].get("child"))) - - return response - - def getPlaylists(self, *args, **kwargs): - """ - Improve the getPlaylists method. Ensures IDs are integers. - """ - - def _playlists_iterator(playlists): - for playlist in force_list(playlists): - playlist["id"] = int(playlist["id"]) - yield playlist - - response = super(SubsonicClient, self).getPlaylists(*args, **kwargs) - response["playlists"]["playlist"] = list( - _playlists_iterator(response["playlists"].get("playlist"))) - - return response - - def getPlaylist(self, *args, **kwargs): - """ - Improve the getPlaylist method. Ensures IDs are integers and add an - order property to each entry. - """ - - def _entries_iterator(entries): - for order, entry in enumerate(force_list(entries), start=1): - entry["id"] = int(entry["id"]) - entry["order"] = order - yield entry - - response = super(SubsonicClient, self).getPlaylist(*args, **kwargs) - response["playlist"]["entry"] = list( - _entries_iterator(response["playlist"].get("entry"))) - - return response - - def getArtists(self, *args, **kwargs): - """ - (ID3 tags) - Improve the getArtists method. Ensures IDs are integers. - """ - - def _artists_iterator(artists): - for artist in force_list(artists): - artist["id"] = int(artist["id"]) - yield artist - - def _index_iterator(index): - for index in force_list(index): - index["artist"] = list(_artists_iterator(index.get("artist"))) - yield index - - response = super(SubsonicClient, self).getArtists(*args, **kwargs) - response["artists"] = response.get("artists", {}) - response["artists"]["index"] = list( - _index_iterator(response["artists"].get("index"))) - - return response - - def getArtist(self, *args, **kwargs): - """ - (ID3 tags) - Improve the getArtist method. Ensures IDs are integers. - """ - - def _albums_iterator(albums): - for album in force_list(albums): - album["id"] = int(album["id"]) - - if "artistId" in album: - album["artistId"] = int(album["artistId"]) - - yield album - - response = super(SubsonicClient, self).getArtist(*args, **kwargs) - response["artist"]["album"] = list( - _albums_iterator(response["artist"].get("album"))) - - return response - - def getMusicDirectory(self, *args, **kwargs): - """ - Improve the getMusicDirectory method. Ensures IDs are integers. - """ - - def _children_iterator(children): - for child in force_list(children): - child["id"] = int(child["id"]) - - if "parent" in child: - child["parent"] = int(child["parent"]) - if "coverArt" in child: - child["coverArt"] = int(child["coverArt"]) - if "artistId" in child: - child["artistId"] = int(child["artistId"]) - if "albumId" in child: - child["albumId"] = int(child["albumId"]) - - yield child - - response = super(SubsonicClient, self).getMusicDirectory( - *args, **kwargs) - response["directory"]["child"] = list( - _children_iterator(response["directory"].get("child"))) - - return response - - def getAlbum(self, *args, **kwargs): - """ - (ID3 tags) - Improve the getAlbum method. Ensures the IDs are real integers. - """ - - def _songs_iterator(songs): - for song in force_list(songs): - song["id"] = int(song["id"]) - yield song - - response = super(SubsonicClient, self).getAlbum(*args, **kwargs) - response["album"]["song"] = list( - _songs_iterator(response["album"].get("song"))) - - return response - - def getAlbumList2(self, *args, **kwargs): - """ - Improve the getAlbumList2 method. Ensures the IDs are real integers. - """ - - def _album_iterator(albums): - for album in force_list(albums): - album["id"] = int(album["id"]) - yield album - - response = super(SubsonicClient, self).getAlbumList2(*args, **kwargs) - response["albumList2"]["album"] = list( - _album_iterator(response["albumList2"].get("album"))) - - return response - - def getStarred(self, *args, **kwargs): - """ - Improve the getStarred method. Ensures the IDs are real integers. - """ - - def _song_iterator(songs): - for song in force_list(songs): - song["id"] = int(song["id"]) - yield song - - response = super(SubsonicClient, self).getStarred(*args, **kwargs) - response["starred"]["song"] = list( - _song_iterator(response["starred"].get("song"))) - - return response - - def getCoverArtUrl(self, *args, **kwargs): - """ - Return an URL to the cover art. - """ - - self.intercept_url = True - url = self.getCoverArt(*args, **kwargs) - self.intercept_url = False - - return url - - def streamUrl(self, *args, **kwargs): - """ - Return an URL to the file to stream. - """ - - self.intercept_url = True - url = self.stream(*args, **kwargs) - self.intercept_url = False - - return url - - def _doBinReq(self, *args, **kwargs): - """ - Intercept request URL to provide the URL of the item that is requested. - - If the URL is intercepted, the request is not executed. A username and - password is added to provide direct access to the stream. - """ - - if self.intercept_url: - parts = list(urlparse.urlparse( - args[0].get_full_url() + "?" + args[0].data)) - parts[4] = dict(urlparse.parse_qsl(parts[4])) - if self._legacyAuth: - parts[4].update({"u": self.username, "p": 'enc:%s' % self._hexEnc(self._rawPass)}) - parts[4] = urllib.urlencode(parts[4]) - - return urlparse.urlunparse(parts) - else: - return super(SubsonicClient, self)._doBinReq(*args, **kwargs) - - def walk_index(self, folder_id=None): - """ - Request Subsonic's index and iterate each item. - """ - - response = self.getIndexes(folder_id) - - for index in response["indexes"]["index"]: - for artist in index["artist"]: - yield artist - - - def walk_playlists(self): - """ - Request Subsonic's playlists and iterate over each item. - """ - - response = self.getPlaylists() - - for child in response["playlists"]["playlist"]: - yield child - - def walk_playlist(self, playlist_id): - """ - Request Subsonic's playlist items and iterate over each item. - """ - - response = self.getPlaylist(playlist_id) - - for child in response["playlist"]["entry"]: - yield child - - def walk_folders(self): - response = self.getMusicFolders() - - for child in response["musicFolders"]["musicFolder"]: - yield child - - def walk_directory(self, directory_id): - """ - Request a Subsonic music directory and iterate over each item. - """ - - response = self.getMusicDirectory(directory_id) - - for child in response["directory"]["child"]: - if child.get("isDir"): - for child in self.walk_directory(child["id"]): - yield child - else: - yield child - - def walk_artist(self, artist_id): - """ - Request a Subsonic artist and iterate over each album. - """ - - response = self.getArtist(artist_id) - - for child in response["artist"]["album"]: - yield child - - def walk_artists(self): - """ - (ID3 tags) - Request all artists and iterate over each item. - """ - - response = self.getArtists() - - for index in response["artists"]["index"]: - for artist in index["artist"]: - yield artist - - def walk_genres(self): - """ - (ID3 tags) - Request all genres and iterate over each item. - """ - - response = self.getGenres() - - for genre in response["genres"]["genre"]: - yield genre - - def walk_albums(self, ltype, size=None, fromYear=None,toYear=None, genre=None, offset=None): - """ - (ID3 tags) - Request all albums for a given genre and iterate over each album. - """ - - if ltype == 'byGenre' and genre is None: - return - - if ltype == 'byYear' and (fromYear is None or toYear is None): - return - - response = self.getAlbumList2( - ltype=ltype, size=size, fromYear=fromYear, toYear=toYear,genre=genre, offset=offset) - - if not response["albumList2"]["album"]: - return - - for album in response["albumList2"]["album"]: - yield album - - - def walk_album(self, album_id): - """ - (ID3 tags) - Request an album and iterate over each item. - """ - - response = self.getAlbum(album_id) - - for song in response["album"]["song"]: - yield song - - def walk_tracks_random(self, size=None, genre=None, fromYear=None,toYear=None): - """ - Request random songs by genre and/or year and iterate over each song. - """ - - response = self.getRandomSongs( - size=size, genre=genre, fromYear=fromYear, toYear=toYear) - - for song in response["randomSongs"]["song"]: - yield song - - - def walk_tracks_starred(self): - """ - Request Subsonic's starred songs and iterate over each item. - """ - - response = self.getStarred() - - for song in response["starred"]["song"]: - yield song diff --git a/lib/simpleplugin/__init__.py b/lib/simpleplugin/__init__.py index dbe08eb..24b3c95 100644 --- a/lib/simpleplugin/__init__.py +++ b/lib/simpleplugin/__init__.py @@ -1,4 +1,4 @@ #v2.1.0 #https://github.com/romanvm/script.module.simpleplugin/releases -from simpleplugin import * \ No newline at end of file +from .simpleplugin import * diff --git a/lib/simpleplugin/simpleplugin.py b/lib/simpleplugin/simpleplugin.py index fdb227c..a48e9f8 100644 --- a/lib/simpleplugin/simpleplugin.py +++ b/lib/simpleplugin/simpleplugin.py @@ -8,29 +8,52 @@ SimplePlugin micro-framework for Kodi content plugins **License**: `GPL v.3 `_ """ +from __future__ import unicode_literals +from future.builtins import (zip, super, + bytes, dict, int, list, object, str) +from future.utils import (PY2, PY3, iteritems, itervalues, + python_2_unicode_compatible) +# from future.standard_library import install_aliases +# install_aliases() + +if PY3: + basestring = str + long = int + import os import sys import re -from datetime import datetime, timedelta -import cPickle as pickle -from urlparse import parse_qs -from urllib import urlencode -from functools import wraps +import inspect +import time +import hashlib +import pickle from collections import MutableMapping, namedtuple from copy import deepcopy -from types import GeneratorType -from hashlib import md5 -from shutil import move +from functools import wraps +from shutil import copyfile +from contextlib import contextmanager +from pprint import pformat +from platform import uname +if PY3: + from urllib.parse import urlencode, quote_plus, urlparse, unquote_plus, parse_qs +else: + from future.backports.urllib.parse import urlencode, quote_plus, urlparse, unquote_plus + from urlparse import parse_qs import xbmcaddon import xbmc -import xbmcplugin import xbmcgui +import xbmcvfs -__all__ = ['SimplePluginError', 'Storage', 'Addon', 'Plugin', 'Params'] +__all__ = ['SimplePluginError', 'Storage', 'MemStorage', 'Addon', 'Plugin', + 'RoutedPlugin', 'Params', 'log_exception', 'py2_encode', + 'py2_decode', 'translate_path'] -ListContext = namedtuple('ListContext', ['listing', 'succeeded', 'update_listing', 'cache_to_disk', - 'sort_methods', 'view_mode', 'content']) -PlayContext = namedtuple('PlayContext', ['path', 'play_item', 'succeeded']) +if PY3: + getargspec = inspect.getfullargspec +else: + getargspec = inspect.getargspec + +Route = namedtuple('Route', ['pattern', 'func']) class SimplePluginError(Exception): @@ -38,6 +61,116 @@ class SimplePluginError(Exception): pass +class TimeoutError(SimplePluginError): + pass + + +def _format_vars(variables): + """ + Format variables dictionary + + :param variables: variables dict + :type variables: dict + :return: formatted string with sorted ``var = val`` pairs + :rtype: str + """ + var_list = [(var, val) for var, val in iteritems(variables)] + lines = [] + for var, val in sorted(var_list, key=lambda i: i[0]): + if not (var.startswith('__') or var.endswith('__')): + lines.append('{0} = {1}'.format(var, pformat(val))) + return '\n'.join(lines) + + +def py2_encode(s, encoding='utf-8'): + """ + Encode Python 2 ``unicode`` to ``str`` + + In Python 3 the string is not changed. + """ + if PY2 and isinstance(s, str): + s = s.encode(encoding) + return s + + +def py2_decode(s, encoding='utf-8'): + """ + Decode Python 2 ``str`` to ``unicode`` + + In Python 3 the string is not changed. + """ + if PY2 and isinstance(s, bytes): + s = s.decode(encoding) + return s + +def _kodi_major_version(): + kodi_version = xbmc.getInfoLabel('System.BuildVersion').split(' ')[0] + return kodi_version.split('.')[0] + +def translate_path(*args, **kwargs): + if _kodi_major_version() < '19': + return xbmc.translatePath(*args, **kwargs) + else: + return xbmcvfs.translatePath(*args, **kwargs) + + +@contextmanager +def log_exception(logger=None): + """ + Diagnostic helper context manager + + It controls execution within its context and writes extended + diagnostic info to the Kodi log if an unhandled exception + happens within the context. The info includes the following items: + + - System info + - Kodi version + - Module path. + - Code fragment where the exception has happened. + - Global variables. + - Local variables. + + After logging the diagnostic info the exception is re-raised. + + Example:: + + with log_exception(): + # Some risky code + raise RuntimeError('Fatal error!') + + :param logger: logger function which must accept a single argument + which is a log message. By default it is :func:`xbmc.log` + with ``ERROR`` level. + """ + try: + yield + except: + if logger is None: + logger = lambda msg: xbmc.log(py2_encode(msg), xbmc.LOGERROR) + frame_info = inspect.trace(5)[-1] + logger('Unhandled exception detected!') + logger('*** Start diagnostic info ***') + logger('System info: {0}'.format(uname())) + logger('OS info: {0}'.format(py2_decode(xbmc.getInfoLabel('System.OSVersionInfo')))) + logger('Kodi version: {0}'.format( + xbmc.getInfoLabel('System.BuildVersion')) + ) + logger('File: {0}'.format(frame_info[1])) + context = '' + if frame_info[4] is not None: + for i, line in enumerate(frame_info[4], frame_info[2] - frame_info[5]): + if i == frame_info[2]: + context += '{0}:>{1}'.format(str(i).rjust(5), line) + else: + context += '{0}: {1}'.format(str(i).rjust(5), line) + logger('Code context:\n' + context) + logger('Global variables:\n' + _format_vars(frame_info[0].f_globals)) + logger('Local variables:\n' + _format_vars(frame_info[0].f_locals)) + logger('**** End diagnostic info ****') + raise + + +@python_2_unicode_compatible class Params(dict): """ Params(**kwargs) @@ -47,6 +180,8 @@ class Params(dict): Parameters can be accessed both through :class:`dict` keys and instance properties. + .. note:: For a missing parameter an instance property returns ``None``. + Example: .. code-block:: python @@ -56,20 +191,18 @@ class Params(dict): foo = params['foo'] # Access by key bar = params.bar # Access through property. Both variants are equal """ - def __getattr__(self, item): - if item not in self: - raise AttributeError('Invalid parameter: "{0}"!'.format(item)) - return self[item] + def __getattr__(self, key): + return self.get(key) def __str__(self): - return ''.format(super(Params, self).__repr__()) - - def __repr__(self): - return ''.format(super(Params, self).__repr__()) + return ''.format(super(Params, self).__str__()) +@python_2_unicode_compatible class Storage(MutableMapping): """ + Storage(storage_dir, filename='storage.pcl') + Persistent storage for arbitrary data with a dictionary-like interface It is designed as a context manager and better be used @@ -86,12 +219,16 @@ class Storage(MutableMapping): storage['key1'] = value1 value2 = storage['key2'] - .. note:: After exiting :keyword:`with` block a :class:`Storage` instance is invalidated. - Storage contents are saved to disk only for a new storage or if the contents have been changed. + .. note:: After exiting :keyword:`with` block a :class:`Storage` instance + is invalidated. Storage contents are saved to disk only for + a new storage or if the contents have been changed. """ def __init__(self, storage_dir, filename='storage.pcl'): """ Class constructor + + :type storage_dir: str + :type filename: str """ self._storage = {} self._hash = None @@ -100,14 +237,14 @@ class Storage(MutableMapping): with open(self._filename, 'rb') as fo: contents = fo.read() self._storage = pickle.loads(contents) - self._hash = md5(contents).hexdigest() - except (IOError, pickle.PickleError, EOFError): + self._hash = hashlib.md5(contents).hexdigest() + except (IOError, pickle.PickleError, EOFError, AttributeError): pass def __enter__(self): return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, t, v, tb): self.flush() def __getitem__(self, key): @@ -120,7 +257,7 @@ class Storage(MutableMapping): del self._storage[key] def __iter__(self): - return self._storage.__iter__() + return iter(self._storage) def __len__(self): return len(self._storage) @@ -128,9 +265,6 @@ class Storage(MutableMapping): def __str__(self): return ''.format(self._storage) - def __repr__(self): - return ''.format(self._storage) - def flush(self): """ Save storage contents to disk @@ -139,16 +273,22 @@ class Storage(MutableMapping): and invalidates the Storage instance. Unchanged Storage is not saved but simply invalidated. """ - contents = pickle.dumps(self._storage) - if self._hash is None or md5(contents).hexdigest() != self._hash: + contents = pickle.dumps(self._storage, protocol=2) + if self._hash is None or hashlib.md5(contents).hexdigest() != self._hash: tmp = self._filename + '.tmp' + start = time.time() + while os.path.exists(tmp): + if time.time() - start > 2.0: + raise TimeoutError( + 'Exceeded timeout for saving {0} contents!'.format(self) + ) + xbmc.sleep(100) try: with open(tmp, 'wb') as fo: fo.write(contents) - except: + copyfile(tmp, self._filename) + finally: os.remove(tmp) - raise - move(tmp, self._filename) # Atomic save del self._storage def copy(self): @@ -163,6 +303,117 @@ class Storage(MutableMapping): return deepcopy(self._storage) +@python_2_unicode_compatible +class MemStorage(MutableMapping): + """ + MemStorage(storage_id) + + In-memory storage with dict-like interface + + The data is stored in the Kodi core so contents of a MemStorage instance + with the same ID can be shared between different Python processes. + + .. note:: Keys are case-insensitive + + .. warning:: :class:`MemStorage` does not allow to modify mutable objects + in place! You need to assign them to variables first, modify and + store them back to a MemStorage instance. + + Example: + + .. code-block:: python + + storage = MemStorage('foo') + some_list = storage['bar'] + some_list.append('spam') + storage['bar'] = some_list + + :param storage_id: ID of this storage instance + :type storage_id: str + :param window_id: the ID of a Kodi Window object where storage contents + will be stored. + :type window_id: int + """ + def __init__(self, storage_id, window_id=10000): + """ + :type storage_id: str + :type window_id: int + """ + self._id = storage_id + self._window = xbmcgui.Window(window_id) + try: + self['__keys__'] + except KeyError: + self['__keys__'] = [] + + def _check_key(self, key): + """ + :type key: str + """ + if not isinstance(key, basestring): + raise TypeError('Storage key must be of str type!') + + def _format_contents(self): + """ + :rtype: str + """ + lines = [] + for key, val in iteritems(self): + lines.append('{0}: {1}'.format(repr(key), repr(val))) + return ', '.join(lines) + + def __str__(self): + return ''.format(self._format_contents()) + + def __getitem__(self, key): + self._check_key(key) + full_key = py2_encode('{0}__{1}'.format(self._id, key)) + raw_item = self._window.getProperty(full_key) + if raw_item: + try: + return pickle.loads(bytes(raw_item)) + except TypeError as e: + return pickle.loads(bytes(raw_item, 'utf-8', errors='surrogateescape')) + else: + raise KeyError(key) + + def __setitem__(self, key, value): + self._check_key(key) + full_key = py2_encode('{0}__{1}'.format(self._id, key)) + # protocol=0 is needed for safe string handling in Python 3 + self._window.setProperty(full_key, pickle.dumps(value, protocol=0)) + if key != '__keys__': + keys = self['__keys__'] + keys.append(key) + self['__keys__'] = keys + + def __delitem__(self, key): + self._check_key(key) + full_key = py2_encode('{0}__{1}'.format(self._id, key)) + item = self._window.getProperty(full_key) + if item: + self._window.clearProperty(full_key) + if key != '__keys__': + keys = self['__keys__'] + keys.remove(key) + self['__keys__'] = keys + else: + raise KeyError(key) + + def __contains__(self, key): + self._check_key(key) + full_key = py2_encode('{0}__{1}'.format(self._id, key)) + item = self._window.getProperty(full_key) + return bool(item) + + def __iter__(self): + return iter(self['__keys__']) + + def __len__(self): + return len(self['__keys__']) + + +@python_2_unicode_compatible class Addon(object): """ Base addon class @@ -175,30 +426,20 @@ class Addon(object): def __init__(self, id_=''): """ Class constructor + + :type id_: str """ self._addon = xbmcaddon.Addon(id_) - self._configdir = xbmc.translatePath(self._addon.getAddonInfo('profile')).decode('utf-8') + self._profile_dir = py2_decode( + translate_path(self._addon.getAddonInfo('profile')) + ) self._ui_strings_map = None - if not os.path.exists(self._configdir): - os.mkdir(self._configdir) - - def __getattr__(self, item): - """ - Get addon setting as an Addon instance attribute - - E.g. addon.my_setting is equal to addon.get_setting('my_setting') - - :param item: - :type item: str - """ - return self.get_setting(item) + if not os.path.exists(self._profile_dir): + os.mkdir(self._profile_dir) def __str__(self): return ''.format(self.id) - def __repr__(self): - return ''.format(self.id) - @property def addon(self): """ @@ -225,9 +466,9 @@ class Addon(object): Addon path :return: path to the addon folder - :rtype: str + :rtype: unicode """ - return self._addon.getAddonInfo('path').decode('utf-8') + return py2_decode(self._addon.getAddonInfo('path')) @property def icon(self): @@ -237,7 +478,10 @@ class Addon(object): :return: path to the addon icon image :rtype: str """ - icon = os.path.join(self.path, 'icon.png') + icon = os.path.join(self.path, self._addon.getAddonInfo('icon')) + if not icon: + icon = os.path.join(self.path, 'icon.png') + xbmc.log(icon,xbmc.LOGINFO) if os.path.exists(icon): return icon else: @@ -251,21 +495,23 @@ class Addon(object): :return: path to the addon fanart image :rtype: str """ - fanart = os.path.join(self.path, 'fanart.jpg') + fanart = self._addon.getAddonInfo('fanart') + if not fanart : + fanart = os.path.join(self.path, 'fanart.jpg') if os.path.exists(fanart): return fanart else: return '' @property - def config_dir(self): + def profile_dir(self): """ Addon config dir - :return: path to the addon config dir + :return: path to the addon profile dir :rtype: str """ - return self._configdir + return self._profile_dir @property def version(self): @@ -277,6 +523,86 @@ class Addon(object): """ return self._addon.getAddonInfo('version') + @property + def name(self): + """ + Addon name + + :return: addon name + :rtype: str + """ + return self._addon.getAddonInfo('name') + + @property + def author(self): + """ + Addon author + + :return: addon author + :rtype: str + """ + return self._addon.getAddonInfo('author') + + @property + def changelog(self): + """ + Addon changelog + + :return: addon changelog + :rtype: str + """ + return self._addon.getAddonInfo('changelog') + + @property + def description(self): + """ + Addon description + + :return: addon description + :rtype: str + """ + return self._addon.getAddonInfo('description') + + @property + def disclaimer(self): + """ + Addon disclaimer + + :return: addon disclaimer + :rtype: str + """ + return self._addon.getAddonInfo('disclaimer') + + @property + def stars(self): + """ + Addon stars + + :return: addon stars + :rtype: str + """ + return self._addon.getAddonInfo('stars') + + @property + def summary(self): + """ + Addon summary + + :return: addon summary + :rtype: str + """ + return self._addon.getAddonInfo('summary') + + @property + def type(self): + """ + Addon type + + :return: addon type + :rtype: str + """ + return self._addon.getAddonInfo('type') + def get_localized_string(self, id_): """ Get localized UI string @@ -284,28 +610,31 @@ class Addon(object): :param id_: UI string ID :type id_: int :return: UI string in the current language - :rtype: str + :rtype: unicode """ - return self._addon.getLocalizedString(id_).encode('utf-8') + return self._addon.getLocalizedString(id_) def get_setting(self, id_, convert=True): """ Get addon setting - If ``convert=True``, 'bool' settings are converted to Python :class:`bool` values, - and numeric strings to Python :class:`long` or :class:`float` depending on their format. + If ``convert=True``, 'bool' settings are converted to Python + :class:`bool` values, and numeric strings to Python :class:`long` or + :class:`float` depending on their format. - .. note:: Settings can also be read via :class:`Addon` instance poperties named as the respective settings. - I.e. ``addon.foo`` is equal to ``addon.get_setting('foo')``. + .. note:: Settings can also be read via :class:`Addon` instance + poperties named as the respective settings. I.e. ``addon.foo`` + is equal to ``addon.get_setting('foo')``. :param id_: setting ID :type id_: str :param convert: try to guess and convert the setting to an appropriate type - E.g. ``'1.0'`` will be converted to float ``1.0`` number, ``'true'`` to ``True`` and so on. + E.g. ``'1.0'`` will be converted to float ``1.0`` number, + ``'true'`` to ``True`` and so on. :type convert: bool :return: setting value """ - setting = self._addon.getSetting(id_) + setting = py2_decode(self._addon.getSetting(id_)) if convert: if setting == 'true': return True # Convert boolean strings to bool @@ -324,8 +653,9 @@ class Addon(object): Python :class:`bool` type are converted to ``'true'`` or ``'false'`` Non-string/non-unicode values are converted to strings. - .. warning:: Setting values via :class:`Addon` instance properties is not supported! - Values can only be set using :meth:`Addon.set_setting` method. + .. warning:: Setting values via :class:`Addon` instance properties + is not supported! Values can only be set using + :meth:`Addon.set_setting` method. :param id_: setting ID :type id_: str @@ -335,7 +665,7 @@ class Addon(object): value = 'true' if value else 'false' elif not isinstance(value, basestring): value = str(value) - self._addon.setSetting(id_, value) + self._addon.setSetting(id_, py2_encode(value)) def log(self, message, level=xbmc.LOGDEBUG): """ @@ -343,13 +673,14 @@ class Addon(object): :param message: message to be written into the Kodi log :type message: str - :param level: log level. :mod:`xbmc` module provides the necessary symbolic constants. - Default: ``xbmc.LOGDEBUG`` + :param level: log level. :mod:`xbmc` module provides the necessary + symbolic constants. Default: ``xbmc.LOGDEBUG`` :type level: int """ - if isinstance(message, unicode): - message = message.encode('utf-8') - xbmc.log('{0} [v.{1}]: {2}'.format(self.id, self.version, message), level) + xbmc.log( + py2_encode('{0} [v.{1}]: {2}'.format(self.id, self.version, message)), + level + ) def log_notice(self, message): """ @@ -358,7 +689,10 @@ class Addon(object): :param message: message to write to the Kodi log :type message: str """ - self.log(message, xbmc.LOGINFO) + if _kodi_major_version() < '19': + self.log(message, xbmc.LOGNOTICE) + else: + self.log(message, xbmc.LOGINFO) def log_warning(self, message): """ @@ -389,7 +723,8 @@ class Addon(object): def get_storage(self, filename='storage.pcl'): """ - Get a persistent :class:`Storage` instance for storing arbitrary values between addon calls. + Get a persistent :class:`Storage` instance for storing arbitrary values + between addon calls. A :class:`Storage` instance can be used as a context manager. @@ -399,14 +734,71 @@ class Addon(object): storage['param1'] = value1 value2 = storage['param2'] - .. note:: After exiting :keyword:`with` block a :class:`Storage` instance is invalidated. + .. note:: After exiting :keyword:`with` block a :class:`Storage` + instance is invalidated. :param filename: the name of a storage file (optional) :type filename: str :return: Storage object :rtype: Storage """ - return Storage(self.config_dir, filename) + return Storage(self.profile_dir, filename) + + def get_mem_storage(self, storage_id='', window_id=10000): + """ + Creates an in-memory storage for this addon with :class:`dict`-like + interface + + The storage can store picklable Python objects as long as + Kodi is running and storage contents can be shared between + Python processes. Different addons have separate storages, + so storages with the same names created with this method + do not conflict. + + Example:: + + addon = Addon() + storage = addon.get_mem_storage() + foo = storage['foo'] + storage['bar'] = bar + + :param storage_id: optional storage ID (case-insensitive). + :type storage_id: str + :param window_id: the ID of a Kodi Window object where storage contents + will be stored. + :type window_id: int + :return: in-memory storage for this addon + :rtype: MemStorage + """ + if storage_id: + storage_id = '{0}_{1}'.format(self.id, storage_id) + return MemStorage(storage_id, window_id) + + def _get_cached_data(self, cache, func, duration, *args, **kwargs): + """ + Get data from a cache object + + :param cache: cache object + :param func: function to cache + :param duration: cache duration + :param args: function args + :param kwargs: function kwargs + :return: function return data + """ + if duration <= 0: + raise ValueError('Caching duration cannot be zero or negative!') + current_time = time.time() + key = func.__name__ + str(args) + str(kwargs) + try: + data, timestamp = cache[key] + if current_time - timestamp > duration * 60: + raise KeyError + self.log_debug('Cache hit: {0}'.format(key)) + except KeyError: + self.log_debug('Cache miss: {0}'.format(key)) + data = func(*args, **kwargs) + cache[key] = (data, current_time) + return data def cached(self, duration=10): """ @@ -429,20 +821,32 @@ class Addon(object): @wraps(func) def inner_wrapper(*args, **kwargs): with self.get_storage('__cache__.pcl') as cache: - current_time = datetime.now() - key = func.__name__ + str(args) + str(kwargs) - try: - data, timestamp = cache[key] - if duration > 0 and current_time - timestamp > timedelta(minutes=duration): - raise KeyError - elif duration <= 0: - raise ValueError('Caching duration cannot be zero or negative!') - self.log_debug('Cache hit: {0}'.format(key)) - except KeyError: - self.log_debug('Cache miss: {0}'.format(key)) - data = func(*args, **kwargs) - cache[key] = (data, current_time) - return data + return self._get_cached_data(cache, func, duration, + *args, **kwargs) + return inner_wrapper + return outer_wrapper + + def mem_cached(self, duration=10): + """ + In-memory cache decorator + + Usage:: + + @plugin.mem_cached(30) + def my_func(*args, **kwargs): + # Do some stuff + return value + + :param duration: caching duration in min (positive values only) + :type duration: int + :raises ValueError: if duration is zero or negative + """ + def outer_wrapper(func): + @wraps(func) + def inner_wrapper(*args, **kwargs): + cache = self.get_mem_storage('***cache***') + return self._get_cached_data(cache, func, duration, + *args, **kwargs) return inner_wrapper return outer_wrapper @@ -455,21 +859,27 @@ class Addon(object): can be assigned to a ``_`` (single underscore) variable. For using gettext emulation :meth:`Addon.initialize_gettext` method - needs to be called first. See documentation for that method for more info - about Gettext emulation. + needs to be called first. See documentation for that method for more + info about Gettext emulation. :param ui_string: a UI string from English :file:`strings.po`. :type ui_string: str :return: a UI string from translated :file:`strings.po`. :rtype: unicode - :raises simpleplugin.SimplePluginError: if :meth:`Addon.initialize_gettext` wasn't called first - or if a string is not found in English :file:`strings.po`. + :raises SimplePluginError: if :meth:`Addon.initialize_gettext` + wasn't called first or if a string is not found in + English :file:`strings.po`. """ if self._ui_strings_map is not None: try: - return self.get_localized_string(self._ui_strings_map['strings'][ui_string]) + return self.get_localized_string( + self._ui_strings_map['strings'][ui_string] + ) except KeyError: - raise SimplePluginError('UI string "{0}" is not found in strings.po!'.format(ui_string)) + raise SimplePluginError( + 'UI string "{0}" is not found in strings.po!'.format( + ui_string) + ) else: raise SimplePluginError('Addon localization is not initialized!') @@ -502,28 +912,35 @@ class Addon(object): with localized versions if these strings are translated. :return: :meth:`Addon.gettext` method object - :raises simpleplugin.SimplePluginError: if the addon's English :file:`strings.po` file is missing + :raises SimplePluginError: if the addon's English :file:`strings.po` + file is missing """ - strings_po = os.path.join(self.path, 'resources', 'language', 'English', 'strings.po') + strings_po = os.path.join(self.path, 'resources', 'language', + 'resource.language.en_gb', 'strings.po') + if not os.path.exists(strings_po): + strings_po = os.path.join(self.path, 'resources', 'language', + 'English', 'strings.po') if os.path.exists(strings_po): with open(strings_po, 'rb') as fo: raw_strings = fo.read() - raw_strings_hash = md5(raw_strings).hexdigest() - gettext_pcl = '__gettext__.pcl' - with self.get_storage(gettext_pcl) as ui_strings_map: - if (not os.path.exists(os.path.join(self._configdir, gettext_pcl)) or - raw_strings_hash != ui_strings_map['hash']): - ui_strings = self._parse_po(raw_strings.split('\n')) - self._ui_strings_map = { - 'hash': raw_strings_hash, - 'strings': ui_strings - } - ui_strings_map['hash'] = raw_strings_hash - ui_strings_map['strings'] = ui_strings.copy() - else: - self._ui_strings_map = deepcopy(ui_strings_map) + raw_strings_hash = hashlib.md5(raw_strings).hexdigest() + ui_strings_map = self.get_mem_storage('__gettext__') + if raw_strings_hash != ui_strings_map.get('hash', ''): + ui_strings = self._parse_po( + raw_strings.decode('utf-8').split('\n') + ) + self._ui_strings_map = { + 'hash': raw_strings_hash, + 'strings': ui_strings + } + ui_strings_map['hash'] = raw_strings_hash + ui_strings_map['strings'] = ui_strings.copy() + else: + self._ui_strings_map = {} + self._ui_strings_map.update(ui_strings_map) else: - raise SimplePluginError('Unable to initialize localization because of missing English strings.po!') + raise SimplePluginError('Unable to initialize localization because ' + 'of missing English strings.po!') return self.gettext def _parse_po(self, strings): @@ -534,152 +951,59 @@ class Addon(object): string_id = None for string in strings: if string_id is None and 'msgctxt' in string: - string_id = int(re.search(r'"#(\d+)"', string).group(1)) + string_id = int(re.search(r'"#(\d+)"', string, re.U).group(1)) elif string_id is not None and 'msgid' in string: ui_strings[re.search(r'"(.*?)"', string, re.U).group(1)] = string_id string_id = None return ui_strings +@python_2_unicode_compatible class Plugin(Addon): """ - Plugin class + Plugin class with URL query string routing. + + It provides a simplified plugin call routing mechanism using URL query strings. + A URL query string must contain "action" parameter that defines which function + will be invoked during this plugin call. :param id_: plugin's id, e.g. 'plugin.video.foo' (optional) :type id_: str - - This class provides a simplified API to create virtual directories of playable items - for Kodi content plugins. - :class:`simpleplugin.Plugin` uses a concept of callable plugin actions (functions or methods) - that are defined using :meth:`Plugin.action` decorator. - A Plugin instance must have at least one action that is named ``'root'``. - - Minimal example: - - .. code-block:: python - - from simpleplugin import Plugin - - plugin = Plugin() - - @plugin.action() - def root(params): # Mandatory item! - return [{'label': 'Foo', - 'url': plugin.get_url(action='some_action', param='Foo')}, - {'label': 'Bar', - 'url': plugin.get_url(action='some_action', param='Bar')}] - - @plugin.action() - def some_action(params): - return [{'label': params['param']}] - - plugin.run() - - An action callable receives 1 parameter -- params. - params is a dict-like object containing plugin call parameters (including action string) - The action callable can return - either a list/generator of dictionaries representing Kodi virtual directory items - or a resolved playable path (:class:`str` or :obj:`unicode`) for Kodi to play. - - Example 1:: - - @plugin.action() - def list_action(params): - listing = get_listing(params) # Some external function to create listing - return listing - - The ``listing`` variable is a Python list/generator of dict items. - Example 2:: - - @plugin.action() - def play_action(params): - path = get_path(params) # Some external function to get a playable path - return path - - Each dict item can contain the following properties: - - - label -- item's label (default: ``''``). - - label2 -- item's label2 (default: ``''``). - - thumb -- item's thumbnail (default: ``''``). - - icon -- item's icon (default: ``''``). - - path -- item's path (default: ``''``). - - fanart -- item's fanart (optional). - - art -- a dict containing all item's graphic (see :meth:`xbmcgui.ListItem.setArt` for more info) -- optional. - - stream_info -- a dictionary of ``{stream_type: {param: value}}`` items - (see :meth:`xbmcgui.ListItem.addStreamInfo`) -- optional. - - info -- a dictionary of ``{media: {param: value}}`` items - (see :meth:`xbmcgui.ListItem.setInfo`) -- optional - - context_menu - a list that contains 2-item tuples ``('Menu label', 'Action')``. - The items from the tuples are added to the item's context menu. - - url -- a callback URL for this list item. - - is_playable -- if ``True``, then this item is playable and must return a playable path or - be resolved via :meth:`Plugin.resolve_url` (default: ``False``). - - is_folder -- if ``True`` then the item will open a lower-level sub-listing. if ``False``, - the item either is a playable media or a general-purpose script - which neither creates a virtual folder nor points to a playable media (default: C{True}). - if ``'is_playable'`` is set to ``True``, then ``'is_folder'`` value automatically assumed to be ``False``. - - subtitles -- the list of paths to subtitle files (optional). - - mime -- item's mime type (optional). - - list_item -- an 'class:`xbmcgui.ListItem` instance (optional). - It is used when you want to set all list item properties by yourself. - If ``'list_item'`` property is present, all other properties, - except for ``'url'`` and ``'is_folder'``, are ignored. - - properties -- a dictionary of list item properties - (see :meth:`xbmcgui.ListItem.setProperty`) -- optional. - - Example 3:: - - listing = [{ 'label': 'Label', - 'label2': 'Label 2', - 'thumb': 'thumb.png', - 'icon': 'icon.png', - 'fanart': 'fanart.jpg', - 'art': {'clearart': 'clearart.png'}, - 'stream_info': {'video': {'codec': 'h264', 'duration': 1200}, - 'audio': {'codec': 'ac3', 'language': 'en'}}, - 'info': {'video': {'genre': 'Comedy', 'year': 2005}}, - 'context_menu': [('Menu Item', 'Action')], - 'url': 'plugin:/plugin.video.test/?action=play', - 'is_playable': True, - 'is_folder': False, - 'subtitles': ['/path/to/subtitles.en.srt', '/path/to/subtitles.uk.srt'], - 'mime': 'video/mp4' - }] - - Alternatively, an action callable can use :meth:`Plugin.create_listing` and :meth:`Plugin.resolve_url` - static methods to pass additional parameters to Kodi. - - Example 4:: - - @plugin.action() - def list_action(params): - listing = get_listing(params) # Some external function to create listing - return Plugin.create_listing(listing, sort_methods=(2, 10, 17), view_mode=500) - - Example 5:: - - @plugin.action() - def play_action(params): - path = get_path(params) # Some external function to get a playable path - return Plugin.resolve_url(path, succeeded=True) - - If an action callable performs any actions other than creating a listing or - resolving a playable URL, it must return ``None``. """ def __init__(self, id_=''): """ Class constructor + + :type id_: str """ super(Plugin, self).__init__(id_) self._url = 'plugin://{0}/'.format(self.id) self._handle = None self.actions = {} + self._params = None def __str__(self): return ''.format(sys.argv) - def __repr__(self): - return ''.format(sys.argv) + @property + def params(self): + """ + Get plugin call parameters + + :return: plugin call parameters + :rtype: Params + """ + return self._params + + @property + def handle(self): + """ + Get plugin handle + + :return: plugin handle + :rtype: int + """ + return self._handle @staticmethod def get_params(paramstring): @@ -693,8 +1017,9 @@ class Plugin(Addon): """ raw_params = parse_qs(paramstring) params = Params() - for key, value in raw_params.iteritems(): - params[key] = value[0] if len(value) == 1 else value + for key, value in iteritems(raw_params): + param_value = value[0] if len(value) == 1 else value + params[key] = py2_decode(param_value) return params def get_url(self, plugin_url='', **kwargs): @@ -705,7 +1030,8 @@ class Plugin(Addon): kwargs are converted to a URL-encoded string of plugin call parameters To call a plugin action, 'action' parameter must be used, if 'action' parameter is missing, then the plugin root action is called - If the action is not added to :class:`Plugin` actions, :class:`PluginError` will be raised. + If the action is not added to :class:`Plugin` actions, + :class:`PluginError` will be raised. :param plugin_url: plugin URL with trailing / (optional) :type plugin_url: str @@ -722,18 +1048,20 @@ class Plugin(Addon): """ Action decorator - Defines plugin callback action. If action's name is not defined explicitly, - then the action is named after the decorated function. + Defines plugin callback action. If action's name is not defined + explicitly, then the action is named after the decorated function. .. warning:: Action's name must be unique. - A plugin must have at least one action named ``'root'`` implicitly or explicitly. + A plugin must have at least one action named ``'root'`` + implicitly or explicitly. Example: .. code-block:: python - @plugin.action() # The action is implicitly named 'root' after the decorated function + # The action is implicitly named 'root' after the decorated function + @plugin.action() def root(params): pass @@ -743,181 +1071,325 @@ class Plugin(Addon): :param name: action's name (optional). :type name: str - :raises simpleplugin.SimplePluginError: if the action with such name is already defined. + :raises SimplePluginError: if the action with such name is already defined. """ def wrap(func, name=name): if name is None: name = func.__name__ if name in self.actions: - raise SimplePluginError('Action "{0}" already defined!'.format(name)) + raise SimplePluginError( + 'Action "{0}" already defined!'.format(name) + ) self.actions[name] = func return func return wrap - def run(self, category=''): + def run(self): """ Run plugin - :param category: str - plugin sub-category, e.g. 'Comedy'. - See :func:`xbmcplugin.setPluginCategory` for more info. - :type category: str - :raises simpleplugin.SimplePluginError: if unknown action string is provided. + :raises SimplePluginError: if unknown action string is provided. """ self._handle = int(sys.argv[1]) - if category: - xbmcplugin.setPluginCategory(self._handle, category) - params = self.get_params(sys.argv[2][1:]) - action = params.get('action', 'root') + self._params = self.get_params(sys.argv[2][1:]) self.log_debug(str(self)) - self.log_debug('Actions: {0}'.format(str(self.actions.keys()))) - self.log_debug('Called action "{0}" with params "{1}"'.format(action, str(params))) + result = self._resolve_function() + if result is not None: + raise SimplePluginError( + 'A decorated function must not return any value! ' + 'It returned {0} instead.'.format(result) + ) + + def _resolve_function(self): + """ + Resolve action from plugin call params and call the respective callable + function + + :return: action callable's return value + """ + self.log_debug('Actions: {0}'.format(str(list(self.actions.keys())))) + action = self._params.get('action', 'root') + self.log_debug('Called action {0} with params {1}'.format( + action, str(self._params)) + ) try: action_callable = self.actions[action] except KeyError: raise SimplePluginError('Invalid action: "{0}"!'.format(action)) else: - result = action_callable(params) - self.log_debug('Action return value: {0}'.format(str(result))) - if isinstance(result, (list, GeneratorType)): - self._add_directory_items(self.create_listing(result)) - elif isinstance(result, basestring): - self._set_resolved_url(self.resolve_url(result)) - elif isinstance(result, tuple) and hasattr(result, 'listing'): - self._add_directory_items(result) - elif isinstance(result, tuple) and hasattr(result, 'path'): - self._set_resolved_url(result) - else: - self.log_debug('The action "{0}" has not returned any valid data to process.'.format(action)) + with log_exception(self.log_error): + # inspect.isfunction is needed for tests + if (inspect.isfunction(action_callable) and + not getargspec(action_callable).args): + return action_callable() + else: + return action_callable(self._params) - @staticmethod - def create_listing(listing, succeeded=True, update_listing=False, cache_to_disk=False, sort_methods=None, - view_mode=None, content=None): - """ - Create and return a context dict for a virtual folder listing - :param listing: the list of the plugin virtual folder items - :type listing: :class:`list` or :class:`types.GeneratorType` - :param succeeded: if ``False`` Kodi won't open a new listing and stays on the current level. - :type succeeded: bool - :param update_listing: if ``True``, Kodi won't open a sub-listing but refresh the current one. - :type update_listing: bool - :param cache_to_disk: cache this view to disk. - :type cache_to_disk: bool - :param sort_methods: the list of integer constants representing virtual folder sort methods. - :type sort_methods: tuple - :param view_mode: a numeric code for a skin view mode. - View mode codes are different in different skins except for ``50`` (basic listing). - :type view_mode: int - :param content: string - current plugin content, e.g. 'movies' or 'episodes'. - See :func:`xbmcplugin.setContent` for more info. - :type content: str - :return: context object containing necessary parameters - to create virtual folder listing in Kodi UI. - :rtype: ListContext - """ - return ListContext(listing, succeeded, update_listing, cache_to_disk, sort_methods, view_mode, content) +@python_2_unicode_compatible +class RoutedPlugin(Plugin): + """ + Plugin class that implements "pretty URL" routing similar to Flask and Bottle + web-frameworks - @staticmethod - def resolve_url(path='', play_item=None, succeeded=True): + :param id_: plugin's id, e.g. 'plugin.video.foo' (optional) + :type id_: str + """ + def __init__(self, id_=''): """ - Create and return a context dict to resolve a playable URL + :param id_: plugin's id, e.g. 'plugin.video.foo' (optional) + :type id_: str + """ + super(RoutedPlugin, self).__init__(id_) + self._routes = {} - :param path: the path to a playable media. - :type path: str or unicode - :param play_item: a dict of item properties as described in the class docstring. - It allows to set additional properties for the item being played, like graphics, metadata etc. - if ``play_item`` parameter is present, then ``path`` value is ignored, and the path must be set via - ``'path'`` property of a ``play_item`` dict. - :type play_item: dict - :param succeeded: if ``False``, Kodi won't play anything - :type succeeded: bool - :return: context object containing necessary parameters - for Kodi to play the selected media. - :rtype: PlayContext - """ - return PlayContext(path, play_item, succeeded) + def __str__(self): + return ''.format(sys.argv) - @staticmethod - def create_list_item(item): + def url_for(self, func_, *args, **kwargs): """ - Create an :class:`xbmcgui.ListItem` instance from an item dict + Build a URL for a plugin route - :param item: a dict of ListItem properties - :type item: dict - :return: ListItem instance - :rtype: xbmcgui.ListItem + This method performs reverse resolving a plugin callback URL for + the named route. If route's name is not set explicitly, then the name + of a decorated function is used as the name of the corresponding route. + The method can optionally take positional args and kwargs. + If any positional args are provided their values replace + variable placeholders by position. + + .. warning:: The number of positional args must not exceed + the number of variable placeholders! + + If any kwargs are provided their values replace variable placeholders + by name. If the number of kwargs provided exceeds the number of variable + placeholders, then the rest of the kwargs are added to the URL + as a query string. + + .. note:: All :class:`unicode` arguments are encoded with UTF-8 encoding. + + Let's assume that the ID of our plugin is ``plugin.acme``. + The following examples will show how to use this method to resolve + callback URLs for this plugin. + + Example 1:: + + @plugin.route('/foo') + def foo(): + pass + url = plugin.url_for('foo') + # url = 'plugin://plugin.acme/foo' + + Example 2:: + + @plugin.route('/foo/') + def foo(param): + pass + url = plugin.url_for('foo', param='bar') + # url = 'plugin://plugin.acme/foo/bar' + + Example 3:: + + plugin.route('/foo/') + def foo(param): + pass + url = plugin.url_for('foo', param='bar', ham='spam') + # url = 'plugin://plugin.acme/foo/bar?ham=spam + + :param func_: route's name or a decorated function object. + :type func_: str or types.FunctionType + :param args: positional arguments. + :param kwargs: keyword arguments. + :return: full plugin call URL for the route. + :rtype: str + :raises simpleplugin.SimplePluginError: if a route with such name + does not exist or on arguments mismatch. """ - list_item = xbmcgui.ListItem(label=item.get('label', ''), - label2=item.get('label2', ''), - path=item.get('path', '')) - if int(xbmc.getInfoLabel('System.BuildVersion')[:2]) >= 16: - art = item.get('art', {}) - art['thumb'] = item.get('thumb', '') - art['icon'] = item.get('icon', '') - art['fanart'] = item.get('fanart', '') - item['art'] = art + if isinstance(func_, basestring): + name = func_ + elif inspect.isfunction(func_) or inspect.ismethod(func_): + name = func_.__name__ else: - list_item.setThumbnailImage(item.get('thumb', '')) - list_item.setIconImage(item.get('icon', '')) - list_item.setProperty('fanart_image', item.get('fanart', '')) - if item.get('art'): - list_item.setArt(item['art']) - if item.get('stream_info'): - for stream, stream_info in item['stream_info'].iteritems(): - list_item.addStreamInfo(stream, stream_info) - if item.get('info'): - for media, info in item['info'].iteritems(): - list_item.setInfo(media, info) - if item.get('context_menu') is not None: - list_item.addContextMenuItems(item['context_menu']) - if item.get('subtitles'): - list_item.setSubtitles(item['subtitles']) - if item.get('mime'): - list_item.setMimeType(item['mime']) - if item.get('properties'): - for key, value in item['properties'].iteritems(): - list_item.setProperty(key, value) - return list_item + raise TypeError('The first argument to url_for must be ' + 'a route\'s name or a route function object!') + try: + pattern = self._routes[name].pattern + except KeyError: + raise SimplePluginError('Route "{0}" does not exist!'.format(name)) + matches = re.findall(r'/(<\w+?>)', pattern) + if len(args) + len(kwargs) < len(matches) or len(args) > len(matches): + raise SimplePluginError( + 'Arguments for the route "{0}" ' + 'do not match placeholders!'.format(name) + ) + if matches: + for arg, match in zip(args, matches): + pattern = pattern.replace( + match, + quote_plus(py2_encode(str(arg))) + ) + # list allows to manipulate the dict during iteration + for key, value in list(iteritems(kwargs)): + for match in matches[len(args):]: - def _add_directory_items(self, context): - """ - Create a virtual folder listing + match_string = match[1:-1] + match_parts = match_string.split('__') + if len(match_parts) > 1: + match_string = match_parts[1] - :param context: context object - :type context: ListContext - """ - self.log_debug('Creating listing from {0}'.format(str(context))) - if context.content is not None: - xbmcplugin.setContent(self._handle, context.content) # This must be at the beginning - for item in context.listing: - is_folder = item.get('is_folder', True) - if item.get('list_item') is not None: - list_item = item['list_item'] - else: - list_item = self.create_list_item(item) - if item.get('is_playable'): - list_item.setProperty('IsPlayable', 'true') - is_folder = False - xbmcplugin.addDirectoryItem(self._handle, item['url'], list_item, is_folder) - if context.sort_methods is not None: - [xbmcplugin.addSortMethod(self._handle, method) for method in context.sort_methods] - xbmcplugin.endOfDirectory(self._handle, - context.succeeded, - context.update_listing, - context.cache_to_disk) - if context.view_mode is not None: - xbmc.executebuiltin('Container.SetViewMode({0})'.format(context.view_mode)) + if key == match_string: + pattern = pattern.replace( + match, quote_plus(py2_encode(str(value))) + ) + del kwargs[key] + url = 'plugin://{0}{1}'.format(self.id, pattern) + if kwargs: + url += '?' + urlencode(kwargs, doseq=True) + return url - def _set_resolved_url(self, context): - """ - Resolve a playable URL + get_url = url_for - :param context: context object - :type context: PlayContext + def route(self, pattern, name=None): """ - self.log_debug('Resolving URL from {0}'.format(str(context))) - if context.play_item is None: - list_item = xbmcgui.ListItem(path=context.path) - else: - list_item = self.create_list_item(context.play_item) - xbmcplugin.setResolvedUrl(self._handle, context.succeeded, list_item) \ No newline at end of file + Route decorator for plugin callback routes + + The route decorator is used to define plugin callback routes + similar to a URL routing mechanism in Flask and Bottle Python + web-frameworks. The plugin routing mechanism calls decorated functions + by matching a path in a plugin callback URL (passed as ``sys.argv[0]``) + to a route pattern. A route pattern *must* start with a forward slash + ``/``. An end slash is optional. A plugin must have at least the root + route with ``'/'`` pattern. Bu default a route is named by the decorated + function, but route's name can be set explicitly by providing + the 2nd optional ``name`` argument. + + .. warning:: Route names must be unique! + + Example 1:: + + @plugin.route('/foo') + def foo_function(): + pass + + In the preceding example ``foo_function`` will be called when the plugin + is invoked with ``plugin://plugin.acme/foo/`` callback URL. + A route pattern can contain variable placeholders + (marked with angular brackets ``<>``) that are used to pass arguments + to a route function. + + Example 2:: + + @plugin.route('/foo/') + def foo_function(param): + pass + + In the preceding example the part of a callback path marked with + ```` placeholder will be passed to the function as an argument. + The name of a placeholder must be the same as the name of + the corresponding parameter. By default arguments are passed as strings. + The ``int`` and ``float`` prefixes can be used to pass arguments + as :class:`int` and :class:`float` numbers, for example ```` + or ````. + + Example 3:: + + @plugin.route('/add//') + def addition(param1, param2): + sum = param1 + param2 + + A function can have multiple route decorators. In this case additional + routes must have explicitly defined names. If a route has less variable + placeholders than function parameters, "missing" function parameters + must have default values. + + Example 4:: + + @plugin.route('/foo/', name='foo_route') + @plugin.route('/bar') + def some_function(param='spam'): + # Do something + + In the preceding example ``some_function`` can be called through + 2 possible routes. If the function is called through the 1st route + (``'foo_route'``) ```` value will be passed as an argument. + The 2nd route will call the function with the default argument + ``'spam'`` because this route has no variable placeholders to pass + arguments to the function. The order of the ``route`` decorators + does not matter but each route must have a unique name. + + .. note:: A route pattern must start with a forward slash ``/`` + and must not have a slash at the end. + + :param pattern: route matching pattern + :type pattern: str + :param name: route's name (optional). If no name is provided, + the route is named after the decorated function. + The name must be unique. + :type name: str + """ + def wrapper(func, pattern=pattern, name=name): + if name is None: + name = func.__name__ + if name in self._routes: + raise SimplePluginError( + 'The route "{0}" already exists!'.format(name) + ) + pattern = pattern.replace('int:', 'int__' + ).replace('float:', 'float__') + self._routes[name] = Route(pattern, func) + return func + return wrapper + + def _resolve_function(self): + """ + Resolve route from plugin callback path and call the respective + route function + + :return: route function's return value + """ + path = urlparse(sys.argv[0]).path + self.log_debug('Routes: {0}'.format(self._routes)) + for route in itervalues(self._routes): + if route.pattern == path: + kwargs = {} + self.log_debug( + 'Calling {0} with kwargs {1}'.format(route, kwargs)) + with log_exception(self.log_error): + return route.func(**kwargs) + + for route in itervalues(self._routes): + pattern = route.pattern + if not pattern.count('/') == path.count('/'): + continue + while True: + pattern, count = re.subn(r'/(<\w+?>)', r'/(?P\1.+?)', pattern) + if not count: + break + match = re.search(r'^' + pattern + r'$', path) + if match is not None: + kwargs = match.groupdict() + # list allows to manipulate the dict during iteration + for key, value in list(iteritems(kwargs)): + if key.startswith('int__') or key.startswith('float__'): + del kwargs[key] + if key.startswith('int__'): + key = key[5:] + value = int(value) + else: + key = key[7:] + value = float(value) + kwargs[key] = value + else: + kwargs[key] = py2_decode(unquote_plus(value)) + self.log_debug( + 'Calling {0} with kwargs {1}'.format(route, kwargs)) + with log_exception(self.log_error): + return route.func(**kwargs) + raise SimplePluginError( + 'No route matches the path "{0}"!'.format(path) + ) + + def action(self, name=None): + raise NotImplementedError( + 'RoutedPlugin does not support action decorator. ' + 'Use route decorator instead.' + ) diff --git a/main.py b/main.py index e30871e..ef89883 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ # Created on: 14 January 2017 # License: GPL v.3 https://www.gnu.org/copyleft/gpl.html +import xbmcvfs import os import xbmcaddon import xbmcplugin @@ -14,12 +15,13 @@ import json import shutil import dateutil.parser from datetime import datetime +from collections import MutableMapping, namedtuple # Add the /lib folder to sys -sys.path.append(xbmc.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib"))) +sys.path.append(xbmcvfs.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib"))) -import libsonic_extra #TO FIX - we should get rid of this and use only libsonic +import libsonic#Removed libsonic_extra from simpleplugin import Plugin from simpleplugin import Addon @@ -33,6 +35,8 @@ plugin = Plugin() connection = None cachetime = int(Addon().get_setting('cachetime')) +ListContext = namedtuple('ListContext', ['listing', 'succeeded','update_listing', 'cache_to_disk','sort_methods', 'view_mode','content', 'category']) +PlayContext = namedtuple('PlayContext', ['path', 'play_item', 'succeeded']) def popup(text, time=5000, image=None): title = plugin.addon.getAddonInfo('name') icon = plugin.addon.getAddonInfo('icon') @@ -42,26 +46,25 @@ def popup(text, time=5000, image=None): def get_connection(): global connection - if connection is None: - - connected = False - + if connection==None: + connected = False # Create connection - try: - connection = libsonic_extra.SubsonicClient( - Addon().get_setting('subsonic_url'), - Addon().get_setting('username', convert=False), - Addon().get_setting('password', convert=False), - Addon().get_setting('apiversion'), - Addon().get_setting('insecure') == 'true', - Addon().get_setting('legacyauth') == 'true', - ) + connection = libsonic.Connection( + baseUrl=Addon().get_setting('subsonic_url'), + username=Addon().get_setting('username', convert=False), + password=Addon().get_setting('password', convert=False), + port=4040,#TO FIX + apiVersion=Addon().get_setting('apiversion'), + insecure=Addon().get_setting('insecure') == 'true', + legacyAuth=Addon().get_setting('legacyauth') == 'true', + useGET=True,#Addon().get_setting('useget') == 'True', #TO FIX + ) connected = connection.ping() except: pass - if connected is False: + if connected==False: popup('Connection error') return False @@ -73,7 +76,7 @@ def root(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -129,7 +132,7 @@ def root(params): ) }) # Item label - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -137,7 +140,7 @@ def root(params): sort_methods = None, #he list of integer constants representing virtual folder sort methods. #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). #content = None #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) @plugin.action() def menu_albums(params): @@ -145,7 +148,7 @@ def menu_albums(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -195,7 +198,7 @@ def menu_albums(params): ) }) # Item label - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -203,7 +206,7 @@ def menu_albums(params): #sort_methods = None, #he list of integer constants representing virtual folder sort methods. #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). #content = None #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) @plugin.action() def menu_tracks(params): @@ -211,7 +214,7 @@ def menu_tracks(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -247,7 +250,7 @@ def menu_tracks(params): ) }) # Item label - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -255,7 +258,7 @@ def menu_tracks(params): #sort_methods = None, #he list of integer constants representing virtual folder sort methods. #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). #content = None #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) @plugin.action() #@plugin.cached(cachetime) # cache (in minutes) @@ -263,13 +266,13 @@ def browse_folders(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] # Get items - items = connection.walk_folders() + items = walk_folders() # Iterate through items for item in items: @@ -288,7 +291,7 @@ def browse_folders(params): plugin.log('One single Media Folder found; do return listing from browse_indexes()...') return browse_indexes(params) else: - return plugin.create_listing(listing) + add_directory_items(create_listing(listing)) @plugin.action() #@plugin.cached(cachetime) # cache (in minutes) @@ -296,7 +299,7 @@ def browse_indexes(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -304,7 +307,7 @@ def browse_indexes(params): # Get items # optional folder ID folder_id = params.get('folder_id') - items = connection.walk_index(folder_id) + items = walk_index(folder_id) # Iterate through items for item in items: @@ -318,9 +321,9 @@ def browse_indexes(params): } listing.append(entry) - return plugin.create_listing( + add_directory_items(create_listing( listing - ) + )) @plugin.action() #@plugin.cached(cachetime) # cache (in minutes) @@ -328,14 +331,14 @@ def list_directory(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] # Get items id = params.get('id') - items = connection.walk_directory(id) + items = walk_directory(id) # Iterate through items for item in items: @@ -356,9 +359,9 @@ def list_directory(params): listing.append(entry) - return plugin.create_listing( + add_directory_items(create_listing( listing - ) + )) @plugin.action() #@plugin.cached(cachetime) # cache (in minutes) @@ -370,13 +373,13 @@ def browse_library(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] # Get items - items = connection.walk_artists() + items = walk_artists() # Iterate through items @@ -394,7 +397,7 @@ def browse_library(params): listing.append(entry) - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -402,7 +405,7 @@ def browse_library(params): sort_methods = get_sort_methods('artists',params), #he list of integer constants representing virtual folder sort methods. #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). content = 'artists' #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) @plugin.action() #@plugin.cached(cachetime) #cache (in minutes) @@ -417,7 +420,7 @@ def list_albums(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return #query @@ -443,14 +446,14 @@ def list_albums(params): #Get items if 'artist_id' in params: - generator = connection.walk_artist(params.get('artist_id')) + generator = walk_artist(params.get('artist_id')) else: - generator = connection.walk_albums(**query_args) + generator = walk_albums(**query_args) #make a list out of the generator so we can iterate it several times items = list(generator) - #check if there is only one artist for this album (and then hide it) + #check if there==only one artist for this album (and then hide it) artists = [item.get('artist',None) for item in items] if len(artists) <= 1: params['hide_artist'] = True @@ -471,7 +474,7 @@ def list_albums(params): link_next = navigate_next(params) listing.append(link_next) - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -479,7 +482,7 @@ def list_albums(params): sort_methods = get_sort_methods('albums',params), #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). content = 'albums' #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) @plugin.action() #@plugin.cached(cachetime) #cache (in minutes) @@ -513,16 +516,16 @@ def list_tracks(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return # Album if 'album_id' in params: - generator = connection.walk_album(params['album_id']) + generator = walk_album(params['album_id']) # Playlist elif 'playlist_id' in params: - generator = connection.walk_playlist(params['playlist_id']) + generator = walk_playlist(params['playlist_id']) #TO FIX #tracknumber = 0 @@ -532,12 +535,12 @@ def list_tracks(params): # Starred elif menu_id == 'tracks_starred': - generator = connection.walk_tracks_starred() + generator = walk_tracks_starred() # Random elif menu_id == 'tracks_random': - generator = connection.walk_tracks_random(**query_args) + generator = walk_tracks_random(**query_args) # Filters #else: #TO WORK @@ -545,7 +548,7 @@ def list_tracks(params): #make a list out of the generator so we can iterate it several times items = list(generator) - #check if there is only one artist for this album (and then hide it) + #check if there==only one artist for this album (and then hide it) artists = [item.get('artist',None) for item in items] if len(artists) <= 1: params['hide_artist'] = True @@ -573,7 +576,7 @@ def list_tracks(params): - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -581,9 +584,9 @@ def list_tracks(params): sort_methods= get_sort_methods('tracks',params), #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). content = 'songs' #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) -#stars (persistent) cache is used to know what context action (star/unstar) we should display. +#stars (persistent) cache==used to know what context action (star/unstar) we should display. #run this function every time we get starred items. #ids can be a single ID or a list #using a set makes sure that IDs will be unique. @@ -594,13 +597,13 @@ def list_playlists(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] # Get items - items = connection.walk_playlists() + items = walk_playlists() # Iterate through items @@ -608,7 +611,7 @@ def list_playlists(params): entry = get_entry_playlist(item,params) listing.append(entry) - return plugin.create_listing( + add_directory_items(create_listing( listing, #succeeded = True, #if False Kodi won’t open a new listing and stays on the current level. #update_listing = False, #if True, Kodi won’t open a sub-listing but refresh the current one. @@ -616,7 +619,7 @@ def list_playlists(params): sort_methods = get_sort_methods('playlists',params), #he list of integer constants representing virtual folder sort methods. #view_mode = None, #a numeric code for a skin view mode. View mode codes are different in different skins except for 50 (basic listing). #content = None #string - current plugin content, e.g. ‘movies’ or ‘episodes’. - ) + )) @plugin.action() #@plugin.cached(cachetime) #cache (in minutes) def search(params): @@ -630,7 +633,7 @@ def search(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -646,7 +649,7 @@ def search(params): plugin.log('One single Media Folder found; do return listing from browse_indexes()...') return browse_indexes(params) else: - return plugin.create_listing(listing) + add_directory_items(create_listing(listing)) @@ -659,7 +662,7 @@ def play_track(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return url = connection.streamUrl(sid=id, @@ -667,7 +670,8 @@ def play_track(params): tformat=Addon().get_setting('transcode_format_streaming') ) - return url + #return url + _set_resolved_url(resolve_url(url)) @plugin.action() def star_item(params): @@ -697,7 +701,7 @@ def star_item(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return ### @@ -774,7 +778,8 @@ def download_item(params): plugin.log_error('Unable to downloaded %s #%s' % (type,id)) return did_action - + +@plugin.cached(cachetime) #cache (in minutes) def get_entry_playlist(item,params): image = connection.getCoverArtUrl(item.get('coverArt')) return { @@ -796,6 +801,7 @@ def get_entry_playlist(item,params): } #star (or unstar) an item +@plugin.cached(cachetime) #cache (in minutes) def get_entry_artist(item,params): image = connection.getCoverArtUrl(item.get('coverArt')) return { @@ -815,6 +821,7 @@ def get_entry_artist(item,params): } } +@plugin.cached(cachetime) #cache (in minutes) def get_entry_album(item, params): image = connection.getCoverArtUrl(item.get('coverArt')) @@ -857,6 +864,7 @@ def get_entry_album(item, params): return entry +@plugin.cached(cachetime) #cache (in minutes) def get_entry_track(item,params): menu_id = params.get('menu_id') @@ -904,11 +912,13 @@ def get_entry_track(item,params): return entry +@plugin.cached(cachetime) #cache (in minutes) def get_starred_label(id,label): if is_starred(id): label = '[COLOR=FF00FF00]%s[/COLOR]' % label return label +@plugin.cached(cachetime) #cache (in minutes) def get_entry_track_label(item,hide_artist = False): if hide_artist: label = item.get('title', '') @@ -920,6 +930,7 @@ def get_entry_track_label(item,hide_artist = False): return get_starred_label(item.get('id'),label) +@plugin.cached(cachetime) #cache (in minutes) def get_entry_album_label(item,hide_artist = False): if hide_artist: label = item.get('name', '') @@ -928,7 +939,7 @@ def get_entry_album_label(item,hide_artist = False): item.get('name', '')) return get_starred_label(item.get('id'),label) - +#@plugin.cached(cachetime) #cache (in minutes) def get_sort_methods(type,params): #sort method for list types #https://github.com/xbmc/xbmc/blob/master/xbmc/SortFileItem.h @@ -947,7 +958,7 @@ def get_sort_methods(type,params): xbmcplugin.SORT_METHOD_UNSORTED ] - if type is 'artists': + if type=='artists': artists = [ xbmcplugin.SORT_METHOD_ARTIST @@ -955,7 +966,7 @@ def get_sort_methods(type,params): sortable = sortable + artists - elif type is 'albums': + elif type=='albums': albums = [ xbmcplugin.SORT_METHOD_ALBUM, @@ -969,7 +980,7 @@ def get_sort_methods(type,params): sortable = sortable + albums - elif type is 'tracks': + elif type=='tracks': tracks = [ xbmcplugin.SORT_METHOD_TITLE, @@ -992,7 +1003,7 @@ def get_sort_methods(type,params): sortable = sortable + tracks - elif type is 'playlists': + elif type=='playlists': playlists = [ xbmcplugin.SORT_METHOD_TITLE, @@ -1010,7 +1021,7 @@ def stars_cache_update(ids,remove=False): #get existing cache set starred = stars_cache_get() - #make sure this is a list + #make sure this==a list if not isinstance(ids, list): ids = [ids] @@ -1098,7 +1109,7 @@ def context_action_star(type,id): 'XBMC.RunPlugin(%s)' % plugin.get_url(action='star_item',type=type,ids=id,unstar=starred) ) -#Subsonic API says this is supported for artist,tracks and albums, +#Subsonic API says this==supported for artist,tracks and albums, #But I can see it available only for tracks on Subsonic 5.3, so disable it. def can_star(type,ids = None): @@ -1128,7 +1139,7 @@ def context_action_download(type,id): ) def can_download(type,id = None): - if id is None: + if id==None: return False if type == 'track': @@ -1138,7 +1149,7 @@ def can_download(type,id = None): def download_tracks(ids): - #popup is fired before, in download_item + #popup==fired before, in download_item download_folder = Addon().get_setting('download_folder') if not download_folder: return @@ -1163,7 +1174,7 @@ def download_tracks(ids): # get connection connection = get_connection() - if connection is False: + if connection==False: return #progress... @@ -1236,7 +1247,7 @@ def download_album(id): # get connection connection = get_connection() - if connection is False: + if connection==False: return # get album infos @@ -1255,6 +1266,250 @@ def download_album(id): download_tracks(ids) +@plugin.cached(cachetime) #cache (in minutes) +def create_listing(listing, succeeded=True, update_listing=False, cache_to_disk=False, sort_methods=None,view_mode=None, content=None, category=None): + return ListContext(listing, succeeded, update_listing, cache_to_disk,sort_methods, view_mode, content, category) + + +def resolve_url(path='', play_item=None, succeeded=True): + """ + Create and return a context dict to resolve a playable URL + :param path: the path to a playable media. + :type path: str or unicode + :param play_item: a dict of item properties as described in the class docstring. + It allows to set additional properties for the item being played, like graphics, metadata etc. + if ``play_item`` parameter==present, then ``path`` value==ignored, and the path must be set via + ``'path'`` property of a ``play_item`` dict. + :type play_item: dict + :param succeeded: if ``False``, Kodi won't play anything + :type succeeded: bool + :return: context object containing necessary parameters + for Kodi to play the selected media. + :rtype: PlayContext + """ + return PlayContext(path, play_item, succeeded) + +#@plugin.cached(cachetime) #cache (in minutes) +def create_list_item(item): + """ + Create an :class:`xbmcgui.ListItem` instance from an item dict + + :param item: a dict of ListItem properties + :type item: dict + :return: ListItem instance + :rtype: xbmcgui.ListItem + """ + major_version = xbmc.getInfoLabel('System.BuildVersion')[:2] + if major_version >= '18': + list_item = xbmcgui.ListItem(label=item.get('label', ''), + label2=item.get('label2', ''), + path=item.get('path', ''), + offscreen=item.get('offscreen', False)) + else: + list_item = xbmcgui.ListItem(label=item.get('label', ''), + label2=item.get('label2', ''), + path=item.get('path', '')) + if major_version >= '16': + art = item.get('art', {}) + art['thumb'] = item.get('thumb', '') + art['icon'] = item.get('icon', '') + art['fanart'] = item.get('fanart', '') + item['art'] = art + cont_look = item.get('content_lookup') + if cont_look is not None: + list_item.setContentLookup(cont_look) + else: + list_item.setThumbnailImage(item.get('thumb', '')) + list_item.setIconImage(item.get('icon', '')) + list_item.setProperty('fanart_image', item.get('fanart', '')) + if item.get('art'): + list_item.setArt(item['art']) + if item.get('stream_info'): + for stream, stream_info in item['stream_info'].iteritems(): + list_item.addStreamInfo(stream, stream_info) + if item.get('info'): + for media, info in item['info'].iteritems(): + list_item.setInfo(media, info) + if item.get('context_menu') is not None: + list_item.addContextMenuItems(item['context_menu']) + if item.get('subtitles'): + list_item.setSubtitles(item['subtitles']) + if item.get('mime'): + list_item.setMimeType(item['mime']) + if item.get('properties'): + for key, value in item['properties'].iteritems(): + list_item.setProperty(key, value) + if major_version >= '17': + cast = item.get('cast') + if cast is not None: + list_item.setCast(cast) + db_ids = item.get('online_db_ids') + if db_ids is not None: + list_item.setUniqueIDs(db_ids) + ratings = item.get('ratings') + if ratings is not None: + for rating in ratings: + list_item.setRating(**rating) + return list_item + +def _set_resolved_url(context): + + plugin.log_debug('Resolving URL from {0}'.format(str(context))) + if context.play_item==None: + list_item = xbmcgui.ListItem(path=context.path) + else: + list_item = self.create_list_item(context.play_item) + xbmcplugin.setResolvedUrl(plugin.handle, context.succeeded, list_item) + + +#@plugin.cached(cachetime) #cache (in minutes) +def add_directory_items(context): + plugin.log_debug('Creating listing from {0}'.format(str(context))) + if context.category is not None: + xbmcplugin.setPluginCategory(plugin.handle, context.category) + if context.content is not None: + xbmcplugin.setContent(plugin.handle, context.content) # This must be at the beginning + for item in context.listing: + is_folder = item.get('is_folder', True) + if item.get('list_item') is not None: + list_item = item['list_item'] + else: + list_item = create_list_item(item) + if item.get('is_playable'): + list_item.setProperty('IsPlayable', 'true') + is_folder = False + xbmcplugin.addDirectoryItem(plugin.handle, item['url'], list_item, is_folder) + if context.sort_methods is not None: + if isinstance(context.sort_methods, (int, dict)): + sort_methods = [context.sort_methods] + elif isinstance(context.sort_methods, (tuple, list)): + sort_methods = context.sort_methods + else: + raise TypeError( + 'sort_methods parameter must be of int, dict, tuple or list type!') + for method in sort_methods: + if isinstance(method, int): + xbmcplugin.addSortMethod(plugin.handle, method) + elif isinstance(method, dict): + xbmcplugin.addSortMethod(plugin.handle, **method) + else: + raise TypeError( + 'method parameter must be of int or dict type!') + + xbmcplugin.endOfDirectory(plugin.handle, + context.succeeded, + context.update_listing, + context.cache_to_disk) + if context.view_mode is not None: + xbmc.executebuiltin('Container.SetViewMode({0})'.format(context.view_mode)) + +def walk_playlists(): + """ + Request Subsonic's playlists and iterate over each item. + """ + + response = connection.getPlaylists() + + for child in response["playlists"]["playlist"]: + yield child + + +def walk_playlist(playlist_id): + """ + Request Subsonic's playlist items and iterate over each item. + """ + response = connection.getPlaylist(playlist_id) + + for child in response["playlist"]["entry"]: + yield child + +def walk_artist(artist_id): + """ + Request a Subsonic artist and iterate over each album. + """ + + response = connection.getArtist(artist_id) + + for child in response["artist"]["album"]: + yield child + +def walk_artists(): + """ + (ID3 tags) + Request all artists and iterate over each item. + """ + + response = connection.getArtists() + + for index in response["artists"]["index"]: + for artist in index["artist"]: + yield artist + +def walk_genres(): + """ + (ID3 tags) + Request all genres and iterate over each item. + """ + + response = connection.getGenres() + + for genre in response["genres"]["genre"]: + yield genre + +def walk_albums(ltype, size=None, fromYear=None,toYear=None, genre=None, offset=None): + """ + (ID3 tags) + Request all albums for a given genre and iterate over each album. + """ + + if ltype == 'byGenre' and genre is None: + return + + if ltype == 'byYear' and (fromYear is None or toYear is None): + return + + response = connection.getAlbumList2( + ltype=ltype, size=size, fromYear=fromYear, toYear=toYear,genre=genre, offset=offset) + + if not response["albumList2"]["album"]: + return + + for album in response["albumList2"]["album"]: + yield album + + +def walk_album(album_id): + """ + (ID3 tags) + Request an album and iterate over each item. + """ + + response = connection.getAlbum(album_id) + + for song in response["album"]["song"]: + yield song + +def walk_tracks_random(size=None, genre=None, fromYear=None,toYear=None): + """ + Request random songs by genre and/or year and iterate over each song. + """ + + response = connection.getRandomSongs( + size=size, genre=genre, fromYear=fromYear, toYear=toYear) + + for song in response["randomSongs"]["song"]: + yield song + + +def walk_tracks_starred(): + """ + Request Subsonic's starred songs and iterate over each item. + """ + + response = connection.getStarred() + + for song in response["starred"]["song"]: + yield song # Start plugin from within Kodi. diff --git a/resources/language/English/strings.po b/resources/language/English/strings.po index c2a98a5..b79b7ff 100644 --- a/resources/language/English/strings.po +++ b/resources/language/English/strings.po @@ -157,4 +157,14 @@ msgctxt "#30039" msgid "Search" msgstr "" +msgctxt "#30040" +msgid "useGET" +msgstr "" +msgctxt "#30041" +msgid "legacyauth" +msgstr "" + +msgctxt "#30042" +msgid "port" +msgstr "" \ No newline at end of file diff --git a/resources/language/French/strings.po b/resources/language/French/strings.po index aa01542..edd8460 100644 --- a/resources/language/French/strings.po +++ b/resources/language/French/strings.po @@ -158,3 +158,14 @@ msgid "Search" msgstr "Rechercher" +msgctxt "#30040" +msgid "useGET" +msgstr "" + +msgctxt "#30041" +msgid "legacyauth" +msgstr "" + +msgctxt "#30042" +msgid "port" +msgstr "" diff --git a/resources/language/German/strings.po b/resources/language/German/strings.po index 73087f8..df521ac 100644 --- a/resources/language/German/strings.po +++ b/resources/language/German/strings.po @@ -155,3 +155,16 @@ msgstr "Durchsuchen" msgctxt "#30039" msgid "Search" msgstr "Suche" + + +msgctxt "#30040" +msgid "useGET" +msgstr "" + +msgctxt "#30041" +msgid "legacyauth" +msgstr "" + +msgctxt "#30042" +msgid "port" +msgstr "" \ No newline at end of file diff --git a/resources/settings.xml b/resources/settings.xml index 5f3affc..9b32800 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -4,6 +4,7 @@ + @@ -13,14 +14,16 @@ - + - + + +