From 18f736d60d55f250e1591b638cc2cad2b0e9e44f Mon Sep 17 00:00:00 2001 From: Warwick Harris Date: Sun, 18 Sep 2022 17:35:49 +1000 Subject: [PATCH] Upgrade - Match to Matrix version --- CHANGELOG.md | 21 + README.md | 29 +- addon.xml | 34 +- fanart.jpg | Bin 0 -> 127087 bytes lib/libsonic/__init__.py | 12 +- lib/libsonic/connection.py | 381 ++++++-- lib/libsonic_extra/__init__.py | 442 --------- lib/simpleplugin/__init__.py | 2 +- lib/simpleplugin/simpleplugin.py | 1203 +++++++++++++++++-------- main.py | 664 +++++++++----- resources/language/English/strings.po | 338 +++---- resources/language/French/strings.po | 338 +++---- resources/language/German/strings.po | 334 +++---- resources/settings.xml | 11 +- service.py | 105 +++ 15 files changed, 2285 insertions(+), 1629 deletions(-) create mode 100644 fanart.jpg delete mode 100644 lib/libsonic_extra/__init__.py create mode 100644 service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a16046..1bf578b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## v2.1.0 +Backport v3.0.2 to Kodi Leia for testing + +## v3.0.2 +Released 29th September 2021 (by warwickh) +* Removed dependency on future and dateutil +* Simpleplugin modified - no longer py2 compatible + +## v3.0.1 +Released 2nd September 2021 (by warwickh) +* Added Navidrome compatibility (remove dependency on integer ids) + +## v3.0.0 +Released 29th June 2021 (by warwickh) +* Basic update to provide Matrix compatility. Not tested on Kodi below v19 +* Updates simpleplugin to latest version (3.0.0) https://github.com/vlmaksime/script.module.simpleplugin +* Moves some legacy simpleplugin static routines into main.py +* Removes dependancy on libsonic_extra by moving some walk functions into main.py +* Updates libsonic to latest version and adds functions for returning raw url for populating menus +* Move to version 3+ for diffferentiation from Leia compatible version + ## v2.0.8 Released 29th November 2017 (by Heruwar) * Fixes a security issue where the password is sent as plaintext in the URL query parameters when methods from libsonic_extas are used. diff --git a/README.md b/README.md index de0d941..5dc9297 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,40 @@ # Subsonic -Kodi plugin to stream, star and download music from Subsonic. +Kodi plugin to stream, star and download music from Subsonic/Airsonic/Navidrome (requires Subsonic API compatibility) + For feature requests / issues: -https://github.com/gordielachance/plugin.audio.subsonic/issues +https://github.com/warwickh/plugin.audio.subsonic/issues + Contributions are welcome: -https://github.com/gordielachance/plugin.audio.subsonic +https://github.com/warwickh/plugin.audio.subsonic + +Master branch updated to support Kodi 19 Matrix + +Leia compatible version available in alternate branch ## Features * Browse by artist, albums (newest/most played/recently played/random), tracks (starred/random), and playlists * Download songs * Star songs +* Navidrome compatibility added (please report any issues) +* Scrobble to Last.FM ## Installation +From repository +[repository.warwickh](https://github.com/warwickh/repository.warwickh/raw/master/matrix/zips/repository.warwickh) (Please report any issues) + +From GitHub * Click the code button and download * Enable unknown sources and install from zip in Kodi - + or * Navigate to your `.kodi/addons/` folder * Clone this repository: `git clone https://github.com/warwickh/plugin.audio.subsonic.git` * (Re)start Kodi. -Note: You may need to install dependencies manually if installing this way. I recommend installing from zip first, then updating using git clone - -Repository installation now available -[repository.warwickh-0.9.0.zip](https://github.com/warwickh/repository.warwickh/raw/master/leia/zips/repository.warwickh/repository.warwickh-0.9.0.zip) (Please report any issues) +Note: You will need to enter your server settings into the plugin configuration before use ## TODO -* Scrobble to Last.FM (http://forum.kodi.tv/showthread.php?tid=195597&pid=2429362#pid2429362) * Improve the caching system * Search filter GUI for tracks and albums @@ -34,7 +42,8 @@ Repository installation now available See the `LICENSE` file. Additional copyright notices: +* [Previous version of this plugin](https://github.com/gordielachance/plugin.audio.subsonic) by gordielachance * [Previous version of this plugin](https://github.com/basilfx/plugin.audio.subsonic) by basilfx -* [SimplePlugin](https://github.com/romanvm/script.module.simpleplugin/stargazers) by romanvm +* [SimplePlugin](https://github.com/romanvm/script.module.simpleplugin/stargazers) by romanvm now at [SimplePlugin3](https://github.com/vlmaksime/script.module.simpleplugin) * The original [SubKodi](https://github.com/DarkAllMan/SubKodi) plugin * [`py-sonic`](https://github.com/crustymonkey/py-sonic) Python module diff --git a/addon.xml b/addon.xml index c630c93..40afe10 100644 --- a/addon.xml +++ b/addon.xml @@ -1,12 +1,12 @@ - - - - - - - audio - + + + + + + audio + + Subsonic music addon for Kodi. Extension Subsonic pour Kodi. @@ -14,31 +14,35 @@ Stream, star and download your tunes, directly to Kodi ! For feature requests / issues: - https://github.com/gordielachance/plugin.audio.subsonic/issues + https://github.com/warwickh/plugin.audio.subsonic/issues Contributions are welcome: - https://github.com/gordielachance/plugin.audio.subsonic + https://github.com/warwickh/plugin.audio.subsonic Jouez, marquez vos favoris et téléchargez votre musique, directement dans Kodi ! Pour les demandes et problèmes : - https://github.com/gordielachance/plugin.audio.subsonic/issues + https://github.com/warwickh/plugin.audio.subsonic/issues Les contributions sont les bienvenues : - https://github.com/gordielachance/plugin.audio.subsonic + https://github.com/warwickh/plugin.audio.subsonic Streame, bewerte und downloade deine Medien direkt in Kodi ! Für neue Eigentschaften oder Fehler: - https://github.com/gordielachance/plugin.audio.subsonic/issues + https://github.com/warwickh/plugin.audio.subsonic/issues Beihilfe ist Willkommen: - https://github.com/gordielachance/plugin.audio.subsonic + https://github.com/warwickh/plugin.audio.subsonic + + icon.png + fanart.jpg + multi all MIT http://www.subsonic.org - https://github.com/gordielachance/plugin.audio.subsonic + https://github.com/warwickh/plugin.audio.subsonic diff --git a/fanart.jpg b/fanart.jpg new file mode 100644 index 0000000000000000000000000000000000000000..33de3ff5b9e437bd397b6e6192b0d23ab53794ad GIT binary patch literal 127087 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⁢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 - 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..24f1496 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 urllib2 +import httplib +import urlparse +from urllib 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 - def _create_sock(self): sock = socket.create_connection((self.host, self.port), self.timeout) if self._tunnel_host: @@ -40,33 +45,16 @@ 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 - - # 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 - + sock = self._create_sock() + try: + self.sock = self._context.wrap_socket(sock, + server_hostname=self.host) + except: + sock.close() class HTTPSHandlerChain(urllib2.HTTPSHandler): def https_open(self, req): - return self.do_open(HTTPSConnectionChain, req) + return self.do_open(HTTPSConnectionChain, req, context=self._context) # install opener urllib2.install_opener(urllib2.build_opener(HTTPSHandlerChain())) @@ -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() + if req.data: + data = req.data return urllib2.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 @@ -160,8 +154,18 @@ class Connection(object): request. This is not recommended as request URLs can get very long with some API calls """ - self._baseUrl = baseUrl - self._hostname = baseUrl.split('://')[1].strip() + + self._baseUrl = baseUrl.rstrip('/') + self._hostname = self._baseUrl.split('://')[1] + if len(self._hostname.split('/'))>1: + print(len(self._hostname.split('/'))) + xbmc.log("Got a folder %s"%(self._hostname.split('/')[1]),xbmc.LOGDEBUG) + parts = urlparse.urlparse(self._baseUrl) + self._baseUrl = "%s://%s" % (parts.scheme, parts.hostname) + self._hostname = parts.hostname + self._serverPath = parts.path.strip('/') + '/rest' + else: + self._serverPath = serverPath.strip('/') self._username = username self._rawPass = password self._legacyAuth = legacyAuth @@ -178,7 +182,6 @@ class Connection(object): self._port = int(port) self._apiVersion = apiVersion self._appName = appName - self._serverPath = serverPath.strip('/') self._insecure = insecure self._opener = self._getOpener(self._username, self._rawPass) @@ -236,9 +239,12 @@ class Connection(object): viewName = '%s.view' % methodName req = self._getRequest(viewName) + xbmc.log("Pinging %s"%str(req.get_full_url()),xbmc.LOGDEBUG) + #res = self._doInfoReq(req) try: res = self._doInfoReq(req) - except: + except Exception as e: + print("Ping failed %s"%e) return False if res['status'] == 'ok': return True @@ -271,6 +277,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 +396,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 +861,59 @@ 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) + #xbmc.log("Requesting %s"%str(req.get_full_url()),xbmc.LOGDEBUG) + return_url = req.get_full_url() + if self._insecure: + return_url += '&verifypeer=false' + xbmc.log("Request is insecure %s"%return_url,level=xbmc.LOGDEBUG) + return return_url + + def getCoverArt(self, aid, size=None): """ since: 1.0.0 @@ -832,6 +937,32 @@ class Connection(object): 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("Requesting %s"%str(req.get_full_url()),xbmc.LOGDEBUG) + return_url = req.get_full_url() + if self._insecure: + return_url += '&verifypeer=false' + xbmc.log("Request is insecure %s"%return_url,level=xbmc.LOGDEBUG) + return return_url + + def scrobble(self, sid, submission=True, listenTime=None): """ since: 1.5.0 @@ -980,7 +1111,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 +1142,7 @@ class Connection(object): 'uploadRole': uploadRole, 'playlistRole': playlistRole, 'coverArtRole': coverArtRole, 'commentRole': commentRole, 'podcastRole': podcastRole, 'shareRole': shareRole, + 'videoConversionRole': videoConversionRole, 'musicFolderId': musicFolderId }) @@ -1024,7 +1156,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 +1188,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) @@ -1874,6 +2007,7 @@ class Connection(object): q['musicFolderId'] = musicFolderId req = self._getRequest(viewName, q) + xbmc.log("Requesting %s"%str(req.get_full_url()),xbmc.LOGDEBUG) res = self._doInfoReq(req) self._checkStatus(res) return res @@ -1960,7 +2094,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 +2199,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 +2246,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 +2358,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 @@ -2301,6 +2499,8 @@ class Connection(object): req = self._getRequest(viewName, q) res = self._doInfoReq(req) + print(req.get_full_url()) + print(res) self._checkStatus(res) return res @@ -2376,10 +2576,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 +2588,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 +2598,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 +2624,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 +2642,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) @@ -2490,7 +2690,8 @@ class Connection(object): methodName = 'getVideoInfo' viewName = '%s.view' % methodName - q = {'id': int(vid)} + #q = {'id': int(vid)} + q = {'id': vid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2507,7 +2708,8 @@ class Connection(object): methodName = 'getAlbumInfo' viewName = '%s.view' % methodName - q = {'id': int(aid)} + #q = {'id': int(aid)} + q = {'id': aid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2524,7 +2726,8 @@ class Connection(object): methodName = 'getAlbumInfo2' viewName = '%s.view' % methodName - q = {'id': int(aid)} + #q = {'id': int(aid)} + q = {'id': aid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2543,7 +2746,8 @@ class Connection(object): methodName = 'getCaptions' viewName = '%s.view' % methodName - q = self._getQueryDict({'id': int(vid), 'format': fmt}) + #q = self._getQueryDict({'id': int(vid), 'format': fmt}) + q = self._getQueryDict({'id': vid, 'format': fmt}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) @@ -2575,14 +2779,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 = urllib2.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 +2806,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 +2819,13 @@ class Connection(object): qdict.update(query) url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, viewName) - req = urllib2.Request(url, urlencode(qdict)) - - if self._useGET: + #xbmc.log("Standard URL %s"%url,level=xbmc.LOGDEBUG) + #xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGDEBUG) + req = urllib2.Request(url, urlencode(qdict).encode('utf-8')) + if(self._useGET or ('getCoverArt' in viewName) or ('stream' in viewName)): url += '?%s' % urlencode(qdict) + #xbmc.log("UseGET URL %s"%(url),xbmc.LOGDEBUG) req = urllib2.Request(url) - return req def _getRequestWithList(self, viewName, listName, alist, query={}): @@ -2633,7 +2841,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 = urllib2.Request(url, data.getvalue().encode('utf-8')) if self._useGET: url += '?%s' % data.getvalue() @@ -2657,10 +2865,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 = urllib2.Request(url, data.getvalue().encode('utf-8')) if self._useGET: url += '?%s' % data.getvalue() @@ -2671,12 +2879,17 @@ 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')) 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 +2929,7 @@ class Connection(object): """ separate REST portion of URL from base server path. """ - return urllib2.splithost(self._serverPath)[1].split('/')[0] + return urlparse.splithost(self._serverPath)[1].split('/')[0] def _fixLastModified(self, data): """ @@ -2726,9 +2939,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..c51e4d1 100644 --- a/lib/simpleplugin/simpleplugin.py +++ b/lib/simpleplugin/simpleplugin.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Created on: 03.06.2015 +# Python2 support removed 28.09.2021 """ SimplePlugin micro-framework for Kodi content plugins @@ -8,29 +9,38 @@ SimplePlugin micro-framework for Kodi content plugins **License**: `GPL v.3 `_ """ +basestring = str +long = int + import os import sys import re -from datetime import datetime, timedelta -import cPickle as pickle +import inspect +import time +import hashlib +import pickle +from collections import MutableMapping +from collections import namedtuple +from copy import deepcopy +from functools import wraps +from shutil import copyfile +from contextlib import contextmanager +from pprint import pformat +from platform import uname +#from urllib.parse import urlencode, quote_plus, urlparse, unquote_plus, parse_qs from urlparse import parse_qs from urllib import urlencode -from functools import wraps -from collections import MutableMapping, namedtuple -from copy import deepcopy -from types import GeneratorType -from hashlib import md5 -from shutil import move 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', 'translate_path'] -ListContext = namedtuple('ListContext', ['listing', 'succeeded', 'update_listing', 'cache_to_disk', - 'sort_methods', 'view_mode', 'content']) -PlayContext = namedtuple('PlayContext', ['path', 'play_item', 'succeeded']) +getargspec = inspect.getargspec + +Route = namedtuple('Route', ['pattern', 'func']) class SimplePluginError(Exception): @@ -38,6 +48,94 @@ 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 iter(variables.items())] + 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 _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(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(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 + + + class Params(dict): """ Params(**kwargs) @@ -47,6 +145,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 +156,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__()) + return ''.format(super(Params, self).__str__()) - def __repr__(self): - return ''.format(super(Params, self).__repr__()) 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 +184,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 +202,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 +222,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 +230,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 +238,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 +268,117 @@ class Storage(MutableMapping): return deepcopy(self._storage) + +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 = '{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 = '{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 = '{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 = '{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__']) + + + class Addon(object): """ Base addon class @@ -175,30 +391,18 @@ 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 = 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 +429,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 self._addon.getAddonInfo('path') @property def icon(self): @@ -237,7 +441,9 @@ 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') if os.path.exists(icon): return icon else: @@ -251,21 +457,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 +485,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,24 +572,27 @@ 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 """ @@ -324,8 +615,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 @@ -343,13 +635,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( + '{0} [v.{1}]: {2}'.format(self.id, self.version, message), + level + ) def log_notice(self, message): """ @@ -358,7 +651,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 +685,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 +696,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 +783,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 +821,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 +874,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 +913,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 + 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 +979,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 iter(raw_params.items()): + param_value = value[0] if len(value) == 1 else value + params[key] = param_value return params def get_url(self, plugin_url='', **kwargs): @@ -705,7 +992,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 +1010,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 +1033,324 @@ 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) +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(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(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] = 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..6f9fe5f 100644 --- a/main.py +++ b/main.py @@ -1,25 +1,24 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Module: main -# Author: G.Breant -# Created on: 14 January 2017 -# License: GPL v.3 https://www.gnu.org/copyleft/gpl.html - +import xbmcvfs import os import xbmcaddon import xbmcplugin import xbmcgui import json import shutil -import dateutil.parser +import time +import hashlib +import random from datetime import datetime +from collections import MutableMapping +from collections import namedtuple # Add the /lib folder to sys sys.path.append(xbmc.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 from simpleplugin import Plugin from simpleplugin import Addon @@ -27,12 +26,12 @@ from simpleplugin import Addon # Create plugin instance plugin = Plugin() -# initialize_gettext -#_ = plugin.initialize_gettext() - connection = None cachetime = int(Addon().get_setting('cachetime')) +local_starred = set({}) +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,27 +41,27 @@ def popup(text, time=5000, image=None): def get_connection(): global connection - if connection is None: - - connected = False - - # Create connection - + 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=Addon().get_setting('port'), + apiVersion=Addon().get_setting('apiversion'), + insecure=Addon().get_setting('insecure'), + legacyAuth=Addon().get_setting('legacyauth'), + useGET=Addon().get_setting('useget'), + ) connected = connection.ping() except: pass - if connected is False: + if connected==False: popup('Connection error') + plugin.log('Connection error') return False return connection @@ -73,7 +72,7 @@ def root(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -129,15 +128,10 @@ 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. - #cache_to_disk = True, #cache this view to disk. - 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’. - ) + sort_methods = None, + )) @plugin.action() def menu_albums(params): @@ -145,7 +139,7 @@ def menu_albums(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -173,7 +167,7 @@ def menu_albums(params): } } - # Iterate through categories + # Iterate through albums for menu_id in menus: @@ -195,15 +189,9 @@ 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. - #cache_to_disk = True, #cache this view to disk. - #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 +199,7 @@ def menu_tracks(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -247,29 +235,22 @@ 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. - #cache_to_disk = True, #cache this view to disk. - #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) 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,15 +269,14 @@ 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) def browse_indexes(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -304,7 +284,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,25 +298,25 @@ 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) def list_directory(params): # get connection connection = get_connection() + merge_artist = Addon().get_setting('merge') - if connection is False: + if connection==False: return listing = [] # Get items id = params.get('id') - items = connection.walk_directory(id) - + items = walk_directory(id, merge_artist) + # Iterate through items for item in items: @@ -356,12 +336,11 @@ 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) def browse_library(params): """ List artists from the library (ID3 tags) @@ -370,13 +349,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,18 +373,14 @@ 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. cache_to_disk = True, #cache this view to disk. 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) def list_albums(params): """ @@ -417,7 +392,7 @@ def list_albums(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return #query @@ -443,14 +418,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,18 +446,14 @@ 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. cache_to_disk = True, #cache this view to disk. 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) def list_tracks(params): menu_id = params.get('menu_id') @@ -513,16 +484,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 +503,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 +516,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 @@ -553,7 +524,8 @@ def list_tracks(params): #update stars if menu_id == 'tracks_starred': ids_list = [item.get('id') for item in items] - stars_cache_update(ids_list) + #stars_local_update(ids_list) + cache_refresh(True) # Iterate through items key = 0; @@ -571,36 +543,25 @@ def list_tracks(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. - #cache_to_disk = True, #cache this view to disk. 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. -#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. @plugin.action() -#@plugin.cached(cachetime) #cache (in minutes) 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,17 +569,12 @@ 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. - #cache_to_disk = True, #cache this view to disk. 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): dialog = xbmcgui.Dialog() @@ -630,7 +586,7 @@ def search(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return listing = [] @@ -646,8 +602,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)) @plugin.action() @@ -659,7 +614,7 @@ def play_track(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return url = connection.streamUrl(sid=id, @@ -667,11 +622,11 @@ 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): - ids= params.get('ids'); #can be single or lists of IDs unstar= params.get('unstar',False); unstar = (unstar) and (unstar != 'None') and (unstar != 'False') #TO FIX better statement ? @@ -697,7 +652,7 @@ def star_item(params): # get connection connection = get_connection() - if connection is False: + if connection==False: return ### @@ -709,15 +664,12 @@ def star_item(params): request = connection.unstar(sids, albumIds, artistIds) else: request = connection.star(sids, albumIds, artistIds) - if request['status'] == 'ok': did_action = True except: pass - ### - if did_action: if unstar: @@ -727,7 +679,8 @@ def star_item(params): message = Addon().get_localized_string(30032) plugin.log('Starred %s #%s' % (type,json.dumps(ids))) - stars_cache_update(ids,unstar) + #stars_local_update(ids,unstar) + cache_refresh(True) popup(message) @@ -740,10 +693,9 @@ def star_item(params): else: plugin.log_error('Unable to star %s #%s' % (type,json.dumps(ids))) - return did_action - + #return did_action + return - @plugin.action() def download_item(params): @@ -774,7 +726,7 @@ def download_item(params): plugin.log_error('Unable to downloaded %s #%s' % (type,id)) return did_action - + def get_entry_playlist(item,params): image = connection.getCoverArtUrl(item.get('coverArt')) return { @@ -795,13 +747,42 @@ def get_entry_playlist(item,params): }} } -#star (or unstar) an item +def get_artist_info(artist_id, forced=False): + print("Updating artist info for id: %s"%(artist_id)) + popup("Updating artist info\nplease wait") + last_update = 0 + artist_info = {} + cache_file = 'ar-%s'%hashlib.md5(artist_id.encode('utf-8')).hexdigest() + with plugin.get_storage(cache_file) as storage: + try: + last_update = storage['updated'] + except KeyError as e: + plugin.log("Artist keyerror, is this a new cache file? %s"%cache_file) + if(time.time()-last_update>(random.randint(1,111)*360) or forced): + plugin.log("Artist cache expired, updating %s elapsed vs random %s forced %s"%(int(time.time()-last_update),(random.randint(1,111)*3600), forced)) + try: + artist_info = connection.getArtistInfo2(artist_id).get('artistInfo2') + storage['artist_info'] = artist_info + storage['updated']=time.time() + except AttributeError as e: + plugin.log("Attribute error, probably couldn't find any info") + else: + print("Cache ok for %s retrieving"%artist_id) + artist_info = storage['artist_info'] + return artist_info + def get_entry_artist(item,params): image = connection.getCoverArtUrl(item.get('coverArt')) + #artist_info = get_artist_info(item.get('id')) + #artist_bio = artist_info.get('biography') + #fanart = artist_info.get('largeImageUrl') + fanart = image return { 'label': get_starred_label(item.get('id'),item.get('name')), + 'label2': "test label", + 'offscreen': True, 'thumb': image, - 'fanart': image, + 'fanart': fanart, 'url': plugin.get_url( action= 'list_albums', artist_id= item.get('id'), @@ -810,7 +791,11 @@ def get_entry_artist(item,params): 'info': { 'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo 'count': item.get('albumCount'), - 'artist': item.get('name') + 'artist': item.get('name'), + #'title': "testtitle", + #'album': "testalbum", + #'comment': "testcomment" + #'title': artist_bio } } } @@ -858,7 +843,6 @@ def get_entry_album(item, params): return entry def get_entry_track(item,params): - menu_id = params.get('menu_id') image = connection.getCoverArtUrl(item.get('coverArt')) @@ -909,6 +893,14 @@ def get_starred_label(id,label): label = '[COLOR=FF00FF00]%s[/COLOR]' % label return label +def is_starred(id): + starred = stars_cache_get() + #id = int(id) + if id in starred: + return True + else: + return False + def get_entry_track_label(item,hide_artist = False): if hide_artist: label = item.get('title', '') @@ -928,7 +920,6 @@ def get_entry_album_label(item,hide_artist = False): item.get('name', '')) return get_starred_label(item.get('id'),label) - def get_sort_methods(type,params): #sort method for list types #https://github.com/xbmc/xbmc/blob/master/xbmc/SortFileItem.h @@ -947,7 +938,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 +946,7 @@ def get_sort_methods(type,params): sortable = sortable + artists - elif type is 'albums': + elif type=='albums': albums = [ xbmcplugin.SORT_METHOD_ALBUM, @@ -969,7 +960,7 @@ def get_sort_methods(type,params): sortable = sortable + albums - elif type is 'tracks': + elif type=='tracks': tracks = [ xbmcplugin.SORT_METHOD_TITLE, @@ -992,7 +983,7 @@ def get_sort_methods(type,params): sortable = sortable + tracks - elif type is 'playlists': + elif type=='playlists': playlists = [ xbmcplugin.SORT_METHOD_TITLE, @@ -1004,51 +995,38 @@ def get_sort_methods(type,params): return sortable - -def stars_cache_update(ids,remove=False): - - #get existing cache set - starred = stars_cache_get() - - #make sure this is a list - if not isinstance(ids, list): - ids = [ids] - - #abord if empty - if len(ids) == 0: - return - - #parse items - for item_id in ids: - item_id = int(item_id) - if not remove: - starred.add(item_id) - else: - starred.remove(item_id) - - #store them +def cache_refresh(forced=False): + global local_starred + #cachetime = 5 + last_update = 0 with plugin.get_storage() as storage: - storage['starred_ids'] = starred - - plugin.log('stars_cache_update:') - plugin.log(starred) - + #storage['starred_ids'] = starred + try: + last_update = storage['updated'] + except KeyError as e: + plugin.log("keyerror, is this a new cache file?") + if(time.time()-(cachetime*60)>last_update) or forced: + plugin.log("Cache expired, updating %s %s %s forced %s"%(time.time(),cachetime*60,last_update, forced)) + generator = walk_tracks_starred() + items = list(generator) + ids_list = [item.get('id') for item in items] + #plugin.log("Retreived from server: %s"%ids_list) + storage['starred_ids'] = ids_list + storage['updated']=time.time() + plugin.log("cache_refresh checking length of load to local %s items"%len(ids_list)) + local_starred = ids_list + else: + #plugin.log("Cache fresh %s %s %s forced %s remaining %s"%(time.time(),cachetime*60,last_update, forced, time.time()-(cachetime*60)-last_update)) + pass + if(len(local_starred)==0): + local_starred = storage['starred_ids'] + #plugin.log("cache_refresh returning %s items"%len(local_starred)) + return def stars_cache_get(): - with plugin.get_storage() as storage: - starred = storage.get('starred_ids',set()) - - plugin.log('stars_cache_get:') - plugin.log(starred) - return starred - -def is_starred(id): - starred = stars_cache_get() - id = int(id) - if id in starred: - return True - else: - return False + global local_starred + cache_refresh() + return local_starred def navigate_next(params): @@ -1074,7 +1052,11 @@ def navigate_root(): #converts a date string from eg. '2012-04-17T19:53:44' to eg. '17.04.2012' def convert_date_from_iso8601(iso8601): - date_obj = dateutil.parser.parse(iso8601) + format = "%Y-%m-%dT%H:%M:%S" + try: + date_obj = datetime.strptime(iso8601.split(".")[0], format) + except TypeError: + date_obj = datetime(*(time.strptime(iso8601.split(".")[0], format)[0:6])) return date_obj.strftime('%d.%m.%Y') def context_action_star(type,id): @@ -1093,12 +1075,13 @@ def context_action_star(type,id): label = Addon().get_localized_string(30034) + xbmc.log('Context action star returning RunPlugin(%s)' % plugin.get_url(action='star_item',type=type,ids=id,unstar=starred),xbmc.LOGDEBUG) return ( label, - 'XBMC.RunPlugin(%s)' % plugin.get_url(action='star_item',type=type,ids=id,unstar=starred) + '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): @@ -1124,11 +1107,11 @@ def context_action_download(type,id): return ( label, - 'XBMC.RunPlugin(%s)' % plugin.get_url(action='download_item',type=type,id=id) + 'RunPlugin(%s)' % plugin.get_url(action='download_item',type=type,id=id) ) def can_download(type,id = None): - if id is None: + if id==None: return False if type == 'track': @@ -1138,7 +1121,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 +1146,7 @@ def download_tracks(ids): # get connection connection = get_connection() - if connection is False: + if connection==False: return #progress... @@ -1236,7 +1219,7 @@ def download_album(id): # get connection connection = get_connection() - if connection is False: + if connection==False: return # get album infos @@ -1255,20 +1238,297 @@ def download_album(id): download_tracks(ids) +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) + +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)) + + + 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) + if item.get('art'): + list_item.setArt(item['art']) + if item.get('stream_info'): + for stream, stream_info in item['stream_info'].items(): + list_item.addStreamInfo(stream, stream_info) + if item.get('info'): + for media, info in item['info'].items(): + 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'].items(): + 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) + + +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_index(folder_id=None): + """ + Request Subsonic's index and iterate each item. + """ + response = connection.getIndexes(folder_id) + plugin.log("Walk index resp: %s"%response) + try: + for index in response["indexes"]["index"]: + for artist in index["artist"]: + plugin.log("artist: %s"%artist) + yield artist + except KeyError: + for emp in (): + yield emp + +def walk_playlists(): + """ + Request Subsonic's playlists and iterate over each item. + """ + response = connection.getPlaylists() + try: + for child in response["playlists"]["playlist"]: + yield child + except KeyError: + for emp in (): + yield emp + +def walk_playlist(playlist_id): + """ + Request Subsonic's playlist items and iterate over each item. + """ + response = connection.getPlaylist(playlist_id) + try: + for child in response["playlist"]["entry"]: + yield child + except KeyError: + for emp in (): + yield emp + +def walk_folders(): + response = connection.getMusicFolders() + try: + for child in response["musicFolders"]["musicFolder"]: + yield child + except KeyError: + for emp in (): + yield emp + +def walk_directory(directory_id, merge_artist = True): + """ + Request a Subsonic music directory and iterate over each item. + """ + response = connection.getMusicDirectory(directory_id) + + try: + for child in response["directory"]["child"]: + if merge_artist and child.get("isDir"): + for child in walk_directory(child["id"], merge_artist): + yield child + else: + yield child + except KeyError: + for emp in (): + yield emp + +def walk_artist(artist_id): + """ + Request a Subsonic artist and iterate over each album. + """ + + response = connection.getArtist(artist_id) + try: + for child in response["artist"]["album"]: + yield child + except KeyError: + for emp in (): + yield emp + +def walk_artists(): + """ + (ID3 tags) + Request all artists and iterate over each item. + """ + response = connection.getArtists() + try: + for index in response["artists"]["index"]: + for artist in index["artist"]: + yield artist + except KeyError: + for emp in (): + yield emp + +def walk_genres(): + """ + (ID3 tags) + Request all genres and iterate over each item. + """ + response = connection.getGenres() + try: + for genre in response["genres"]["genre"]: + yield genre + except KeyError: + for emp in (): + yield emp + +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) + try: + for song in response["album"]["song"]: + yield song + except KeyError: + for emp in (): + yield emp + +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) + try: + for song in response["randomSongs"]["song"]: + yield song + except KeyError: + for emp in (): + yield emp + +def walk_tracks_starred(): + """ + Request Subsonic's starred songs and iterate over each item. + """ + response = connection.getStarred() + try: + for song in response["starred"]["song"]: + yield song + except KeyError: + for emp in (): + yield emp # Start plugin from within Kodi. if __name__ == "__main__": # Map actions # Note that we map callable objects without brackets () plugin.run() - - - - - - - - - - diff --git a/resources/language/English/strings.po b/resources/language/English/strings.po index c2a98a5..cf0e1e6 100644 --- a/resources/language/English/strings.po +++ b/resources/language/English/strings.po @@ -1,160 +1,178 @@ -# XBMC Media Center language file -# Addon Name: Subsonic -# Addon id: plugin.audio.subsonic -# Addon Provider: -# Addon Translate: Moshkopp - -msgid "" -msgstr "" - - -msgctxt "#30000" -msgid "General" -msgstr "" - -msgctxt "#30001" -msgid "Server" -msgstr "" - -msgctxt "#30002" -msgid "Server URL" -msgstr "" - -msgctxt "#30003" -msgid "Username" -msgstr "" - -msgctxt "#30004" -msgid "Password" -msgstr "" - -msgctxt "#30005" -msgid "Display" -msgstr "" - -msgctxt "#30006" -msgid "Albums per page" -msgstr "" - -msgctxt "#30007" -msgid "Tracks per page (ignored in albums & playlists)" -msgstr "" - -msgctxt "#30008" -msgid "Download" -msgstr "" - -msgctxt "#30009" -msgid "Download folder" -msgstr "" - -msgctxt "#30010" -msgid "Streaming" -msgstr "" - -msgctxt "#30011" -msgid "Transcode format" -msgstr "" - -msgctxt "#30012" -msgid "Bitrate" -msgstr "" - -msgctxt "#30013" -msgid "Advanced Settings" -msgstr "" - -msgctxt "#30014" -msgid "API version" -msgstr "" - -msgctxt "#30016" -msgid "Allow self signed certificates" -msgstr "" - -msgctxt "#30017" -msgid "Cache (in minutes)" -msgstr "" - -msgctxt "#30018" -msgid "Cache datas time" -msgstr "" - -msgctxt "#30019" -msgid "Library" -msgstr "" - -msgctxt "#30020" -msgid "Albums" -msgstr "" - -msgctxt "#30021" -msgid "Tracks" -msgstr "" - -msgctxt "#30022" -msgid "Playlists" -msgstr "" - -msgctxt "#30023" -msgid "Newest albums" -msgstr "" - -msgctxt "#30024" -msgid "Most played albums" -msgstr "" - -msgctxt "#30025" -msgid "Recently played albums" -msgstr "" - -msgctxt "#30026" -msgid "Random albums" -msgstr "" - -msgctxt "#30029" -msgid "Next page" -msgstr "" - - -msgctxt "#30030" -msgid "Back to Menu" -msgstr "" - -msgctxt "#30031" -msgid "Item has been unstarred." -msgstr "" - -msgctxt "#30032" -msgid "Item has been starred!" -msgstr "" - -msgctxt "#30033" -msgid "Star on Subsonic" -msgstr "" - -msgctxt "#30034" -msgid "Unstar on Subsonic" -msgstr "" - -msgctxt "#30035" -msgid "Download" -msgstr "" - -msgctxt "#30036" -msgid "Starred tracks" -msgstr "" - -msgctxt "#30037" -msgid "Random tracks" -msgstr "" - -msgctxt "#30038" -msgid "Browse" -msgstr "" - -msgctxt "#30039" -msgid "Search" -msgstr "" - - +# XBMC Media Center language file +# Addon Name: Subsonic +# Addon id: plugin.audio.subsonic +# Addon Provider: +# Addon Translate: Moshkopp + +msgid "" +msgstr "" + + +msgctxt "#30000" +msgid "General" +msgstr "" + +msgctxt "#30001" +msgid "Server" +msgstr "" + +msgctxt "#30002" +msgid "Server URL" +msgstr "" + +msgctxt "#30003" +msgid "Username" +msgstr "" + +msgctxt "#30004" +msgid "Password" +msgstr "" + +msgctxt "#30005" +msgid "Display" +msgstr "" + +msgctxt "#30006" +msgid "Albums per page" +msgstr "" + +msgctxt "#30007" +msgid "Tracks per page (ignored in albums & playlists)" +msgstr "" + +msgctxt "#30008" +msgid "Download" +msgstr "" + +msgctxt "#30009" +msgid "Download folder" +msgstr "" + +msgctxt "#30010" +msgid "Streaming" +msgstr "" + +msgctxt "#30011" +msgid "Transcode format" +msgstr "" + +msgctxt "#30012" +msgid "Bitrate" +msgstr "" + +msgctxt "#30013" +msgid "Advanced Settings" +msgstr "" + +msgctxt "#30014" +msgid "API version" +msgstr "" + +msgctxt "#30016" +msgid "Allow self signed certificates" +msgstr "" + +msgctxt "#30017" +msgid "Cache (in minutes)" +msgstr "" + +msgctxt "#30018" +msgid "Cache data time" +msgstr "" + +msgctxt "#30019" +msgid "Library" +msgstr "" + +msgctxt "#30020" +msgid "Albums" +msgstr "" + +msgctxt "#30021" +msgid "Tracks" +msgstr "" + +msgctxt "#30022" +msgid "Playlists" +msgstr "" + +msgctxt "#30023" +msgid "Newest albums" +msgstr "" + +msgctxt "#30024" +msgid "Most played albums" +msgstr "" + +msgctxt "#30025" +msgid "Recently played albums" +msgstr "" + +msgctxt "#30026" +msgid "Random albums" +msgstr "" + +msgctxt "#30029" +msgid "Next page" +msgstr "" + + +msgctxt "#30030" +msgid "Back to Menu" +msgstr "" + +msgctxt "#30031" +msgid "Item has been unstarred." +msgstr "" + +msgctxt "#30032" +msgid "Item has been starred!" +msgstr "" + +msgctxt "#30033" +msgid "Star on Subsonic" +msgstr "" + +msgctxt "#30034" +msgid "Unstar on Subsonic" +msgstr "" + +msgctxt "#30035" +msgid "Download" +msgstr "" + +msgctxt "#30036" +msgid "Starred tracks" +msgstr "" + +msgctxt "#30037" +msgid "Random tracks" +msgstr "" + +msgctxt "#30038" +msgid "Browse" +msgstr "" + +msgctxt "#30039" +msgid "Search" +msgstr "" + +msgctxt "#30040" +msgid "useGET" +msgstr "" + +msgctxt "#30041" +msgid "legacyauth" +msgstr "" + +msgctxt "#30042" +msgid "port" +msgstr "" + +msgctxt "#30043" +msgid "Merge album folders" +msgstr "" + +msgctxt "#30044" +msgid "Scrobble to Last.FM" +msgstr "" diff --git a/resources/language/French/strings.po b/resources/language/French/strings.po index aa01542..88f7515 100644 --- a/resources/language/French/strings.po +++ b/resources/language/French/strings.po @@ -1,160 +1,178 @@ -# XBMC Media Center language file -# Addon Name: Subsonic -# Addon id: plugin.audio.subsonic -# Addon Provider: -# Addon Translate: Gordie - -msgid "" -msgstr "" - - -msgctxt "#30000" -msgid "General" -msgstr "Général" - -msgctxt "#30001" -msgid "Server" -msgstr "Serveur" - -msgctxt "#30002" -msgid "Server URL" -msgstr "URL du serveur" - -msgctxt "#30003" -msgid "Username" -msgstr "Nom d'utilisateur" - -msgctxt "#30004" -msgid "Password" -msgstr "Mot de passe" - -msgctxt "#30005" -msgid "Display" -msgstr "Affichage" - -msgctxt "#30006" -msgid "Albums per page" -msgstr "Albums par page" - -msgctxt "#30007" -msgid "Tracks per page (ignored in albums & playlists)" -msgstr "Pistes par page (ignoré dans les albums & listes de lecture)" - -msgctxt "#30008" -msgid "Download" -msgstr "Télécharger" - -msgctxt "#30009" -msgid "Download folder" -msgstr "Répertoire de téléchargement" - -msgctxt "#30010" -msgid "Streaming" -msgstr "Diffusion" - -msgctxt "#30011" -msgid "Transcode format" -msgstr "Format de transcodage" - -msgctxt "#30012" -msgid "Bitrate" -msgstr "Bitrate" - -msgctxt "#30013" -msgid "Advanced Settings" -msgstr "Paramètres avancés" - -msgctxt "#30014" -msgid "API version" -msgstr "Version de l'API" - -msgctxt "#30016" -msgid "Allow self signed certificates" -msgstr "Autoriser les certificats auto-signés" - -msgctxt "#30017" -msgid "Cache (in minutes)" -msgstr "Cache (en minutes)" - -msgctxt "#30018" -msgid "Cache datas time" -msgstr "Durée du cache pour les données" - -msgctxt "#30019" -msgid "Library" -msgstr "Bibliothèque" - -msgctxt "#30020" -msgid "Albums" -msgstr "Albums" - -msgctxt "#30021" -msgid "Tracks" -msgstr "Pistes" - -msgctxt "#30022" -msgid "Playlists" -msgstr "Playlists" - -msgctxt "#30023" -msgid "Newest albums" -msgstr "Nouveaux albums" - -msgctxt "#30024" -msgid "Most played albums" -msgstr "Albums les plus joués" - -msgctxt "#30025" -msgid "Recently played albums" -msgstr "Albums joués récemment" - -msgctxt "#30026" -msgid "Random albums" -msgstr "Albums au hasard" - -msgctxt "#30029" -msgid "Next page" -msgstr "Page suivante" - -msgctxt "#30030" -msgid "Back to Menu" -msgstr "Retour au menu" - -msgctxt "#30031" -msgid "Item has been unstarred." -msgstr "Cet élément a été retiré des favoris" - -msgctxt "#30032" -msgid "Item has been starred!" -msgstr "Cet élément a été ajouté aux favoris !" - -msgctxt "#30033" -msgid "Star on Subsonic" -msgstr "Ajouter aux favoris Subsonic" - -msgctxt "#30034" -msgid "Unstar on Subsonic" -msgstr "Retirer des favoris Subsonic" - -msgctxt "#30035" -msgid "Download" -msgstr "Télécharger" - -msgctxt "#30036" -msgid "Starred tracks" -msgstr "Pistes favorites" - -msgctxt "#30037" -msgid "Random tracks" -msgstr "Pistes au hasard" - -msgctxt "#30038" -msgid "Browse" -msgstr "Parcourir" - - -msgctxt "#30039" -msgid "Search" -msgstr "Rechercher" - - +# XBMC Media Center language file +# Addon Name: Subsonic +# Addon id: plugin.audio.subsonic +# Addon Provider: +# Addon Translate: Gordie + +msgid "" +msgstr "" + + +msgctxt "#30000" +msgid "General" +msgstr "Général" + +msgctxt "#30001" +msgid "Server" +msgstr "Serveur" + +msgctxt "#30002" +msgid "Server URL" +msgstr "URL du serveur" + +msgctxt "#30003" +msgid "Username" +msgstr "Nom d'utilisateur" + +msgctxt "#30004" +msgid "Password" +msgstr "Mot de passe" + +msgctxt "#30005" +msgid "Display" +msgstr "Affichage" + +msgctxt "#30006" +msgid "Albums per page" +msgstr "Albums par page" + +msgctxt "#30007" +msgid "Tracks per page (ignored in albums & playlists)" +msgstr "Pistes par page (ignoré dans les albums & listes de lecture)" + +msgctxt "#30008" +msgid "Download" +msgstr "Télécharger" + +msgctxt "#30009" +msgid "Download folder" +msgstr "Répertoire de téléchargement" + +msgctxt "#30010" +msgid "Streaming" +msgstr "Diffusion" + +msgctxt "#30011" +msgid "Transcode format" +msgstr "Format de transcodage" + +msgctxt "#30012" +msgid "Bitrate" +msgstr "Bitrate" + +msgctxt "#30013" +msgid "Advanced Settings" +msgstr "Paramètres avancés" + +msgctxt "#30014" +msgid "API version" +msgstr "Version de l'API" + +msgctxt "#30016" +msgid "Allow self signed certificates" +msgstr "Autoriser les certificats auto-signés" + +msgctxt "#30017" +msgid "Cache (in minutes)" +msgstr "Cache (en minutes)" + +msgctxt "#30018" +msgid "Cache datas time" +msgstr "Durée du cache pour les données" + +msgctxt "#30019" +msgid "Library" +msgstr "Bibliothèque" + +msgctxt "#30020" +msgid "Albums" +msgstr "Albums" + +msgctxt "#30021" +msgid "Tracks" +msgstr "Pistes" + +msgctxt "#30022" +msgid "Playlists" +msgstr "Playlists" + +msgctxt "#30023" +msgid "Newest albums" +msgstr "Nouveaux albums" + +msgctxt "#30024" +msgid "Most played albums" +msgstr "Albums les plus joués" + +msgctxt "#30025" +msgid "Recently played albums" +msgstr "Albums joués récemment" + +msgctxt "#30026" +msgid "Random albums" +msgstr "Albums au hasard" + +msgctxt "#30029" +msgid "Next page" +msgstr "Page suivante" + +msgctxt "#30030" +msgid "Back to Menu" +msgstr "Retour au menu" + +msgctxt "#30031" +msgid "Item has been unstarred." +msgstr "Cet élément a été retiré des favoris" + +msgctxt "#30032" +msgid "Item has been starred!" +msgstr "Cet élément a été ajouté aux favoris !" + +msgctxt "#30033" +msgid "Star on Subsonic" +msgstr "Ajouter aux favoris Subsonic" + +msgctxt "#30034" +msgid "Unstar on Subsonic" +msgstr "Retirer des favoris Subsonic" + +msgctxt "#30035" +msgid "Download" +msgstr "Télécharger" + +msgctxt "#30036" +msgid "Starred tracks" +msgstr "Pistes favorites" + +msgctxt "#30037" +msgid "Random tracks" +msgstr "Pistes au hasard" + +msgctxt "#30038" +msgid "Browse" +msgstr "Parcourir" + +msgctxt "#30039" +msgid "Search" +msgstr "Rechercher" + + +msgctxt "#30040" +msgid "useGET" +msgstr "" + +msgctxt "#30041" +msgid "legacyauth" +msgstr "" + +msgctxt "#30042" +msgid "port" +msgstr "" + +msgctxt "#30043" +msgid "Merge album folders" +msgstr "" + +msgctxt "#30044" +msgid "Scrobble to Last.FM" +msgstr "" diff --git a/resources/language/German/strings.po b/resources/language/German/strings.po index 73087f8..46ba647 100644 --- a/resources/language/German/strings.po +++ b/resources/language/German/strings.po @@ -1,157 +1,177 @@ -# XBMC Media Center language file -# Addon Name: Subsonic -# Addon id: plugin.audio.subsonic -# Addon Provider: -# Addon Translate: Moshkopp - -msgid "" -msgstr "" - - -msgctxt "#30000" -msgid "General" -msgstr "Allgemein" - -msgctxt "#30001" -msgid "Server" -msgstr "Server" - -msgctxt "#30002" -msgid "Server URL" -msgstr "Serveradresse" - -msgctxt "#30003" -msgid "Username" -msgstr "Benutzername" - -msgctxt "#30004" -msgid "Password" -msgstr "Passwort" - -msgctxt "#30005" -msgid "Display" -msgstr "Anzeige" - -msgctxt "#30006" -msgid "Albums per page" -msgstr "Alben pro Seite" - -msgctxt "#30007" -msgid "Tracks per page (ignored in albums & playlists)" -msgstr "Lieder pro Seite (wird in Alben und Playlisten ignoriert)" - -msgctxt "#30008" -msgid "Download" -msgstr "Download" - -msgctxt "#30009" -msgid "Download folder" -msgstr "Download Verzeichnis" - -msgctxt "#30010" -msgid "Streaming" -msgstr "Übertragung" - -msgctxt "#30011" -msgid "Transcode format" -msgstr "Umwandlungs Format" - -msgctxt "#30012" -msgid "Bitrate" -msgstr "Bitrate" - -msgctxt "#30013" -msgid "Advanced Settings" -msgstr "Erweitert" - -msgctxt "#30014" -msgid "API version" -msgstr "API Version" - -msgctxt "#30016" -msgid "Allow self signed certificates" -msgstr "Erlaube eigensignierte Zertifikate" - -msgctxt "#30017" -msgid "Cache (in minutes)" -msgstr "Speicher (in Minuten)" - -msgctxt "#30018" -msgid "Cache datas time" -msgstr "Speicher Daten Zeit" - -msgctxt "#30019" -msgid "Library" -msgstr "Bibliothek" - -msgctxt "#30020" -msgid "Albums" -msgstr "Alben" - -msgctxt "#30021" -msgid "Tracks" -msgstr "Lieder" - -msgctxt "#30022" -msgid "Playlists" -msgstr "Playlisten" - -msgctxt "#30023" -msgid "Newest albums" -msgstr "Neueste Alben" - -msgctxt "#30024" -msgid "Most played albums" -msgstr "Häufig gehörte Alben" - -msgctxt "#30025" -msgid "Recently played albums" -msgstr "Zuletzt gehörte Alben" - -msgctxt "#30026" -msgid "Random albums" -msgstr "Zufällige Alben" - -msgctxt "#30029" -msgid "Next page" -msgstr "Nächste Seite" - -msgctxt "#30030" -msgid "Back to Menu" -msgstr "Hauptmenü" - -msgctxt "#30031" -msgid "Item has been unstarred." -msgstr "Bewertung entfernt" - -msgctxt "#30032" -msgid "Item has been starred!" -msgstr "Bewertung hinzugefügt" - -msgctxt "#30033" -msgid "Star on Subsonic" -msgstr "Bewerten auf Subsonic" - -msgctxt "#30034" -msgid "Unstar on Subsonic" -msgstr "Löschen auf Subsonic" - -msgctxt "#30035" -msgid "Download" -msgstr "Herunterladen" - -msgctxt "#30036" -msgid "Starred tracks" -msgstr "Lieblings lieder" - -msgctxt "#30037" -msgid "Random tracks" -msgstr "Zufällig lieder" - -msgctxt "#30038" -msgid "Browse" -msgstr "Durchsuchen" - -msgctxt "#30039" -msgid "Search" -msgstr "Suche" +# XBMC Media Center language file +# Addon Name: Subsonic +# Addon id: plugin.audio.subsonic +# Addon Provider: +# Addon Translate: Moshkopp + +msgid "" +msgstr "" + + +msgctxt "#30000" +msgid "General" +msgstr "Allgemein" + +msgctxt "#30001" +msgid "Server" +msgstr "Server" + +msgctxt "#30002" +msgid "Server URL" +msgstr "Serveradresse" + +msgctxt "#30003" +msgid "Username" +msgstr "Benutzername" + +msgctxt "#30004" +msgid "Password" +msgstr "Passwort" + +msgctxt "#30005" +msgid "Display" +msgstr "Anzeige" + +msgctxt "#30006" +msgid "Albums per page" +msgstr "Alben pro Seite" + +msgctxt "#30007" +msgid "Tracks per page (ignored in albums & playlists)" +msgstr "Lieder pro Seite (wird in Alben und Playlisten ignoriert)" + +msgctxt "#30008" +msgid "Download" +msgstr "Download" + +msgctxt "#30009" +msgid "Download folder" +msgstr "Download Verzeichnis" + +msgctxt "#30010" +msgid "Streaming" +msgstr "Übertragung" + +msgctxt "#30011" +msgid "Transcode format" +msgstr "Umwandlungs Format" + +msgctxt "#30012" +msgid "Bitrate" +msgstr "Bitrate" + +msgctxt "#30013" +msgid "Advanced Settings" +msgstr "Erweitert" + +msgctxt "#30014" +msgid "API version" +msgstr "API Version" + +msgctxt "#30016" +msgid "Allow self signed certificates" +msgstr "Erlaube eigensignierte Zertifikate" + +msgctxt "#30017" +msgid "Cache (in minutes)" +msgstr "Speicher (in Minuten)" + +msgctxt "#30018" +msgid "Cache datas time" +msgstr "Speicher Daten Zeit" + +msgctxt "#30019" +msgid "Library" +msgstr "Bibliothek" + +msgctxt "#30020" +msgid "Albums" +msgstr "Alben" + +msgctxt "#30021" +msgid "Tracks" +msgstr "Lieder" + +msgctxt "#30022" +msgid "Playlists" +msgstr "Playlisten" + +msgctxt "#30023" +msgid "Newest albums" +msgstr "Neueste Alben" + +msgctxt "#30024" +msgid "Most played albums" +msgstr "Häufig gehörte Alben" + +msgctxt "#30025" +msgid "Recently played albums" +msgstr "Zuletzt gehörte Alben" + +msgctxt "#30026" +msgid "Random albums" +msgstr "Zufällige Alben" + +msgctxt "#30029" +msgid "Next page" +msgstr "Nächste Seite" + +msgctxt "#30030" +msgid "Back to Menu" +msgstr "Hauptmenü" + +msgctxt "#30031" +msgid "Item has been unstarred." +msgstr "Bewertung entfernt" + +msgctxt "#30032" +msgid "Item has been starred!" +msgstr "Bewertung hinzugefügt" + +msgctxt "#30033" +msgid "Star on Subsonic" +msgstr "Bewerten auf Subsonic" + +msgctxt "#30034" +msgid "Unstar on Subsonic" +msgstr "Löschen auf Subsonic" + +msgctxt "#30035" +msgid "Download" +msgstr "Herunterladen" + +msgctxt "#30036" +msgid "Starred tracks" +msgstr "Lieblings lieder" + +msgctxt "#30037" +msgid "Random tracks" +msgstr "Zufällig lieder" + +msgctxt "#30038" +msgid "Browse" +msgstr "Durchsuchen" + +msgctxt "#30039" +msgid "Search" +msgstr "Suche" + +msgctxt "#30040" +msgid "useGET" +msgstr "" + +msgctxt "#30041" +msgid "legacyauth" +msgstr "" + +msgctxt "#30042" +msgid "port" +msgstr "" + +msgctxt "#30043" +msgid "Merge album folders" +msgstr "" + +msgctxt "#30044" +msgid "Scrobble to Last.FM" +msgstr "" diff --git a/resources/settings.xml b/resources/settings.xml index 5f3affc..0f09285 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -4,6 +4,7 @@ + @@ -13,16 +14,20 @@ - + - + + + - + + + diff --git a/service.py b/service.py new file mode 100644 index 0000000..b3e4550 --- /dev/null +++ b/service.py @@ -0,0 +1,105 @@ +import re +import xbmc +import xbmcvfs +import os +import xbmcaddon +# Add the /lib folder to sys +sys.path.append(xbmc.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib"))) + +import libsonic + +from simpleplugin import Plugin +from simpleplugin import Addon + +# Create plugin instance +plugin = Plugin() +connection = None + +try: + scrobbleEnabled = Addon().get_setting('scrobble') +except: + scrobbleEnabled = False + +scrobbled = False + +def popup(text, time=5000, image=None): + title = plugin.addon.getAddonInfo('name') + icon = plugin.addon.getAddonInfo('icon') + xbmc.executebuiltin('Notification(%s, %s, %d, %s)' % (title, text, + time, icon)) +def get_connection(): + global connection + + if connection==None: + connected = False + # Create connection + try: + connection = libsonic.Connection( + baseUrl=Addon().get_setting('subsonic_url'), + username=Addon().get_setting('username', convert=False), + password=Addon().get_setting('password', convert=False), + port=Addon().get_setting('port'), + apiVersion=Addon().get_setting('apiversion'), + insecure=Addon().get_setting('insecure'), + legacyAuth=Addon().get_setting('legacyauth'), + useGET=Addon().get_setting('useget'), + ) + connected = connection.ping() + except: + pass + + if connected==False: + popup('Connection error') + return False + + return connection + +def scrobble_track(track_id): + connection = get_connection() + + if connection==False: + return False + res = connection.scrobble(track_id) + #xbmc.log("response %s"%(res), xbmc.LOGINFO) + if res['status'] == 'ok': + popup("Scrobbled track") + return True + else: + popup("Scrobble failed") + return False + +if __name__ == '__main__': + if(scrobbleEnabled): + monitor = xbmc.Monitor() + xbmc.log("Subsonic service started", xbmc.LOGINFO) + popup("Subsonic service started") + while not monitor.abortRequested(): + if monitor.waitForAbort(10): + break + if (xbmc.getCondVisibility("Player.HasMedia")): + try: + + currentFileName = xbmc.getInfoLabel("Player.Filenameandpath") + currentFileProgress = xbmc.getInfoLabel("Player.Progress") + pattern = re.compile(r'plugin:\/\/plugin\.audio\.subsonic\/\?action=play_track&id=(.*?)&') + currentTrackId = re.findall(pattern, currentFileName)[0] + #xbmc.log("Name %s Id %s Progress %s"%(currentFileName,currentTrackId,currentFileProgress), xbmc.LOGDEBUG) + if (int(currentFileProgress)<50): + scrobbled = False + elif (int(currentFileProgress)>=50 and scrobbled == False): + xbmc.log("Scrobbling Track Id %s"%(currentTrackId), xbmc.LOGDEBUG) + success = scrobble_track(currentTrackId) + if success: + scrobbled = True + else: + pass + except IndexError: + print ("Not a Subsonic track") + scrobbled = True + except Exception as e: + xbmc.log("Subsonic service failed %e"%e, xbmc.LOGINFO) + else: + pass + #xbmc.log("Playing stopped", xbmc.LOGINFO) + else: + xbmc.log("Subsonic service not started due to settings", xbmc.LOGINFO)