6 Commits

Author SHA1 Message Date
f6792e02aa Include feature from GioF71 [Feature] Search Album 2023-02-03 09:30:07 +11:00
2ae13c655e Include feature from GioF71 [Feature] Search Album 2023-02-03 09:29:24 +11:00
fc49f29fae Include feature from GioF71 [Feature] Search Album 2023-02-03 09:28:35 +11:00
670bf0107b Update main.py
Include feature from GioF71 [Feature] Search Album
2023-02-03 09:26:33 +11:00
18f736d60d Upgrade - Match to Matrix version 2022-09-18 17:35:49 +10:00
f62f2b2236 Update installation details in readme 2021-09-04 10:24:12 +10:00
11 changed files with 384 additions and 183 deletions

View File

@ -1,5 +1,17 @@
# Changelog # 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 ## v3.0.0
Released 29th June 2021 (by warwickh) Released 29th June 2021 (by warwickh)
* Basic update to provide Matrix compatility. Not tested on Kodi below v19 * Basic update to provide Matrix compatility. Not tested on Kodi below v19

View File

@ -16,8 +16,13 @@ Leia compatible version available in alternate branch
* Download songs * Download songs
* Star songs * Star songs
* Navidrome compatibility added (please report any issues) * Navidrome compatibility added (please report any issues)
* Scrobble to Last.FM
## Installation ## 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 * Click the code button and download
* Enable unknown sources and install from zip in Kodi * Enable unknown sources and install from zip in Kodi
@ -27,10 +32,9 @@ or
* Clone this repository: `git clone https://github.com/warwickh/plugin.audio.subsonic.git` * Clone this repository: `git clone https://github.com/warwickh/plugin.audio.subsonic.git`
* (Re)start Kodi. * (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 Note: You will need to enter your server settings into the plugin configuration before use
## TODO ## TODO
* Scrobble to Last.FM (http://forum.kodi.tv/showthread.php?tid=195597&pid=2429362#pid2429362)
* Improve the caching system * Improve the caching system
* Search filter GUI for tracks and albums * Search filter GUI for tracks and albums

View File

@ -1,13 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.audio.subsonic" name="Subsonic" version="3.0.0" provider-name="BasilFX,grosbouff,silascutler,Heruwar,warwickh"> <addon id="plugin.audio.subsonic" name="Subsonic" version="2.1.0" provider-name="BasilFX,warwickh">
<requires> <requires>
<import addon="xbmc.python" version="3.0.0"/> <import addon="xbmc.python" version="2.7.0"/>
<import addon="script.module.dateutil" version="2.4.2"/> </requires>
<import addon="script.module.future" version="0.18.2"/> <extension point="xbmc.python.pluginsource" library="main.py">
</requires> <provides>audio</provides>
<extension point="xbmc.python.pluginsource" library="main.py"> </extension>
<provides>audio</provides> <extension point="xbmc.service" library="service.py" />
</extension>
<extension point="xbmc.addon.metadata"> <extension point="xbmc.addon.metadata">
<summary lang="en">Subsonic music addon for Kodi.</summary> <summary lang="en">Subsonic music addon for Kodi.</summary>
<summary lang="fr">Extension Subsonic pour Kodi.</summary> <summary lang="fr">Extension Subsonic pour Kodi.</summary>

View File

@ -18,10 +18,10 @@ along with py-sonic. If not, see <http://www.gnu.org/licenses/>
from libsonic.errors import * from libsonic.errors import *
from netrc import netrc from netrc import netrc
from hashlib import md5 from hashlib import md5
import urllib.request import urllib2
import urllib.error import httplib
from http import client as http_client import urlparse
from urllib.parse import urlencode from urllib import urlencode
from io import StringIO from io import StringIO
import json import json
@ -36,7 +36,7 @@ API_VERSION = '1.16.1'
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class HTTPSConnectionChain(http_client.HTTPSConnection): class HTTPSConnectionChain(httplib.HTTPSConnection):
def _create_sock(self): def _create_sock(self):
sock = socket.create_connection((self.host, self.port), self.timeout) sock = socket.create_connection((self.host, self.port), self.timeout)
if self._tunnel_host: if self._tunnel_host:
@ -47,19 +47,19 @@ class HTTPSConnectionChain(http_client.HTTPSConnection):
def connect(self): def connect(self):
sock = self._create_sock() sock = self._create_sock()
try: try:
self.sock = ssl.create_default_context().wrap_socket(sock, self.sock = self._context.wrap_socket(sock,
server_hostname=self.host) server_hostname=self.host)
except: except:
sock.close() sock.close()
class HTTPSHandlerChain(urllib.request.HTTPSHandler): class HTTPSHandlerChain(urllib2.HTTPSHandler):
def https_open(self, req): def https_open(self, req):
return self.do_open(HTTPSConnectionChain, req) return self.do_open(HTTPSConnectionChain, req, context=self._context)
# install opener # install opener
urllib.request.install_opener(urllib.request.build_opener(HTTPSHandlerChain())) urllib2.install_opener(urllib2.build_opener(HTTPSHandlerChain()))
class PysHTTPRedirectHandler(urllib.request.HTTPRedirectHandler): class PysHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
""" """
This class is used to override the default behavior of the This class is used to override the default behavior of the
HTTPRedirectHandler, which does *not* redirect POST data HTTPRedirectHandler, which does *not* redirect POST data
@ -75,7 +75,7 @@ class PysHTTPRedirectHandler(urllib.request.HTTPRedirectHandler):
data = None data = None
if req.data: if req.data:
data = req.data data = req.data
return urllib.request.Request(newurl, return urllib2.Request(newurl,
data=data, data=data,
headers=newheaders, headers=newheaders,
origin_req_host=req.origin_req_host, origin_req_host=req.origin_req_host,
@ -154,8 +154,18 @@ class Connection(object):
request. This is not recommended as request request. This is not recommended as request
URLs can get very long with some API calls 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._username = username
self._rawPass = password self._rawPass = password
self._legacyAuth = legacyAuth self._legacyAuth = legacyAuth
@ -172,7 +182,6 @@ class Connection(object):
self._port = int(port) self._port = int(port)
self._apiVersion = apiVersion self._apiVersion = apiVersion
self._appName = appName self._appName = appName
self._serverPath = serverPath.strip('/')
self._insecure = insecure self._insecure = insecure
self._opener = self._getOpener(self._username, self._rawPass) self._opener = self._getOpener(self._username, self._rawPass)
@ -230,9 +239,12 @@ class Connection(object):
viewName = '%s.view' % methodName viewName = '%s.view' % methodName
req = self._getRequest(viewName) req = self._getRequest(viewName)
xbmc.log("Pinging %s"%str(req.get_full_url()),xbmc.LOGDEBUG)
#res = self._doInfoReq(req)
try: try:
res = self._doInfoReq(req) res = self._doInfoReq(req)
except: except Exception as e:
print("Ping failed %s"%e)
return False return False
if res['status'] == 'ok': if res['status'] == 'ok':
return True return True
@ -894,11 +906,12 @@ class Connection(object):
'converted': converted}) 'converted': converted})
req = self._getRequest(viewName, q) req = self._getRequest(viewName, q)
xbmc.log("Requesting %s"%str(req.full_url),xbmc.LOGDEBUG) #xbmc.log("Requesting %s"%str(req.get_full_url()),xbmc.LOGDEBUG)
#res = self._doBinReq(req) return_url = req.get_full_url()
#if isinstance(res, dict): if self._insecure:
# self._checkStatus(res) return_url += '&verifypeer=false'
return req.full_url xbmc.log("Request is insecure %s"%return_url,level=xbmc.LOGDEBUG)
return return_url
def getCoverArt(self, aid, size=None): def getCoverArt(self, aid, size=None):
@ -942,11 +955,12 @@ class Connection(object):
q = self._getQueryDict({'id': aid, 'size': size}) q = self._getQueryDict({'id': aid, 'size': size})
req = self._getRequest(viewName, q) req = self._getRequest(viewName, q)
xbmc.log("Requesting %s"%str(req.full_url),xbmc.LOGDEBUG) #xbmc.log("Requesting %s"%str(req.get_full_url()),xbmc.LOGDEBUG)
#res = self._doBinReq(req) return_url = req.get_full_url()
#if isinstance(res, dict): if self._insecure:
# self._checkStatus(res) return_url += '&verifypeer=false'
return req.full_url xbmc.log("Request is insecure %s"%return_url,level=xbmc.LOGDEBUG)
return return_url
def scrobble(self, sid, submission=True, listenTime=None): def scrobble(self, sid, submission=True, listenTime=None):
@ -1993,7 +2007,7 @@ class Connection(object):
q['musicFolderId'] = musicFolderId q['musicFolderId'] = musicFolderId
req = self._getRequest(viewName, q) req = self._getRequest(viewName, q)
xbmc.log("Requesting %s"%str(req.full_url),xbmc.LOGDEBUG) xbmc.log("Requesting %s"%str(req.get_full_url()),xbmc.LOGDEBUG)
res = self._doInfoReq(req) res = self._doInfoReq(req)
self._checkStatus(res) self._checkStatus(res)
return res return res
@ -2485,6 +2499,8 @@ class Connection(object):
req = self._getRequest(viewName, q) req = self._getRequest(viewName, q)
res = self._doInfoReq(req) res = self._doInfoReq(req)
print(req.get_full_url())
print(res)
self._checkStatus(res) self._checkStatus(res)
return res return res
@ -2749,7 +2765,7 @@ class Connection(object):
url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port, url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port,
self._separateServerPath(), viewName, methodName) self._separateServerPath(), viewName, methodName)
req = urllib.request.Request(url) req = urllib2.Request(url)
res = self._opener.open(req) res = self._opener.open(req)
res_msg = res.msg.lower() res_msg = res.msg.lower()
return res_msg == 'ok' return res_msg == 'ok'
@ -2763,7 +2779,7 @@ class Connection(object):
if sys.version_info[:3] >= (2, 7, 9) and self._insecure: if sys.version_info[:3] >= (2, 7, 9) and self._insecure:
https_chain = HTTPSHandlerChain( https_chain = HTTPSHandlerChain(
context=ssl._create_unverified_context()) context=ssl._create_unverified_context())
opener = urllib.request.build_opener( opener = urllib2.build_opener(
PysHTTPRedirectHandler, PysHTTPRedirectHandler,
https_chain, https_chain,
) )
@ -2805,11 +2821,11 @@ class Connection(object):
viewName) viewName)
#xbmc.log("Standard URL %s"%url,level=xbmc.LOGDEBUG) #xbmc.log("Standard URL %s"%url,level=xbmc.LOGDEBUG)
#xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGDEBUG) #xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGDEBUG)
req = urllib.request.Request(url, urlencode(qdict).encode('utf-8')) req = urllib2.Request(url, urlencode(qdict).encode('utf-8'))
if(self._useGET or ('getCoverArt' in viewName) or ('stream' in viewName)): if(self._useGET or ('getCoverArt' in viewName) or ('stream' in viewName)):
url += '?%s' % urlencode(qdict) url += '?%s' % urlencode(qdict)
#xbmc.log("UseGET URL %s"%(url),xbmc.LOGDEBUG) #xbmc.log("UseGET URL %s"%(url),xbmc.LOGDEBUG)
req = urllib.request.Request(url) req = urllib2.Request(url)
return req return req
def _getRequestWithList(self, viewName, listName, alist, query={}): def _getRequestWithList(self, viewName, listName, alist, query={}):
@ -2825,7 +2841,7 @@ class Connection(object):
data.write(urlencode(qdict)) data.write(urlencode(qdict))
for i in alist: for i in alist:
data.write('&%s' % urlencode({listName: i})) data.write('&%s' % urlencode({listName: i}))
req = urllib.request.Request(url, data.getvalue().encode('utf-8')) req = urllib2.Request(url, data.getvalue().encode('utf-8'))
if self._useGET: if self._useGET:
url += '?%s' % data.getvalue() url += '?%s' % data.getvalue()
@ -2852,7 +2868,7 @@ class Connection(object):
for k, l in listMap.items(): for k, l in listMap.items():
for i in l: for i in l:
data.write('&%s' % urlencode({k: i})) data.write('&%s' % urlencode({k: i}))
req = urllib.request.Request(url, data.getvalue().encode('utf-8')) req = urllib2.Request(url, data.getvalue().encode('utf-8'))
if self._useGET: if self._useGET:
url += '?%s' % data.getvalue() url += '?%s' % data.getvalue()
@ -2913,7 +2929,7 @@ class Connection(object):
""" """
separate REST portion of URL from base server path. separate REST portion of URL from base server path.
""" """
return urllib.parse.splithost(self._serverPath)[1].split('/')[0] return urlparse.splithost(self._serverPath)[1].split('/')[0]
def _fixLastModified(self, data): def _fixLastModified(self, data):
""" """

View File

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Created on: 03.06.2015 # Created on: 03.06.2015
# Python2 support removed 28.09.2021
""" """
SimplePlugin micro-framework for Kodi content plugins SimplePlugin micro-framework for Kodi content plugins
@ -8,17 +9,8 @@ SimplePlugin micro-framework for Kodi content plugins
**License**: `GPL v.3 <https://www.gnu.org/copyleft/gpl.html>`_ **License**: `GPL v.3 <https://www.gnu.org/copyleft/gpl.html>`_
""" """
from __future__ import unicode_literals basestring = str
from future.builtins import (zip, super, long = int
bytes, dict, int, list, object, str)
from future.utils import (PY2, PY3, iteritems, itervalues,
python_2_unicode_compatible)
# from future.standard_library import install_aliases
# install_aliases()
if PY3:
basestring = str
long = int
import os import os
import sys import sys
@ -27,31 +19,26 @@ import inspect
import time import time
import hashlib import hashlib
import pickle import pickle
from collections import MutableMapping, namedtuple from collections import MutableMapping
from collections import namedtuple
from copy import deepcopy from copy import deepcopy
from functools import wraps from functools import wraps
from shutil import copyfile from shutil import copyfile
from contextlib import contextmanager from contextlib import contextmanager
from pprint import pformat from pprint import pformat
from platform import uname from platform import uname
if PY3: #from urllib.parse import urlencode, quote_plus, urlparse, unquote_plus, parse_qs
from urllib.parse import urlencode, quote_plus, urlparse, unquote_plus, parse_qs from urlparse import parse_qs
else: from urllib import urlencode
from future.backports.urllib.parse import urlencode, quote_plus, urlparse, unquote_plus
from urlparse import parse_qs
import xbmcaddon import xbmcaddon
import xbmc import xbmc
import xbmcgui import xbmcgui
import xbmcvfs import xbmcvfs
__all__ = ['SimplePluginError', 'Storage', 'MemStorage', 'Addon', 'Plugin', __all__ = ['SimplePluginError', 'Storage', 'MemStorage', 'Addon', 'Plugin',
'RoutedPlugin', 'Params', 'log_exception', 'py2_encode', 'RoutedPlugin', 'Params', 'log_exception', 'translate_path']
'py2_decode', 'translate_path']
if PY3: getargspec = inspect.getargspec
getargspec = inspect.getfullargspec
else:
getargspec = inspect.getargspec
Route = namedtuple('Route', ['pattern', 'func']) Route = namedtuple('Route', ['pattern', 'func'])
@ -74,35 +61,13 @@ def _format_vars(variables):
:return: formatted string with sorted ``var = val`` pairs :return: formatted string with sorted ``var = val`` pairs
:rtype: str :rtype: str
""" """
var_list = [(var, val) for var, val in iteritems(variables)] var_list = [(var, val) for var, val in iter(variables.items())]
lines = [] lines = []
for var, val in sorted(var_list, key=lambda i: i[0]): for var, val in sorted(var_list, key=lambda i: i[0]):
if not (var.startswith('__') or var.endswith('__')): if not (var.startswith('__') or var.endswith('__')):
lines.append('{0} = {1}'.format(var, pformat(val))) lines.append('{0} = {1}'.format(var, pformat(val)))
return '\n'.join(lines) return '\n'.join(lines)
def py2_encode(s, encoding='utf-8'):
"""
Encode Python 2 ``unicode`` to ``str``
In Python 3 the string is not changed.
"""
if PY2 and isinstance(s, str):
s = s.encode(encoding)
return s
def py2_decode(s, encoding='utf-8'):
"""
Decode Python 2 ``str`` to ``unicode``
In Python 3 the string is not changed.
"""
if PY2 and isinstance(s, bytes):
s = s.decode(encoding)
return s
def _kodi_major_version(): def _kodi_major_version():
kodi_version = xbmc.getInfoLabel('System.BuildVersion').split(' ')[0] kodi_version = xbmc.getInfoLabel('System.BuildVersion').split(' ')[0]
return kodi_version.split('.')[0] return kodi_version.split('.')[0]
@ -146,12 +111,12 @@ def log_exception(logger=None):
yield yield
except: except:
if logger is None: if logger is None:
logger = lambda msg: xbmc.log(py2_encode(msg), xbmc.LOGERROR) logger = lambda msg: xbmc.log(msg, xbmc.LOGERROR)
frame_info = inspect.trace(5)[-1] frame_info = inspect.trace(5)[-1]
logger('Unhandled exception detected!') logger('Unhandled exception detected!')
logger('*** Start diagnostic info ***') logger('*** Start diagnostic info ***')
logger('System info: {0}'.format(uname())) logger('System info: {0}'.format(uname()))
logger('OS info: {0}'.format(py2_decode(xbmc.getInfoLabel('System.OSVersionInfo')))) logger('OS info: {0}'.format(xbmc.getInfoLabel('System.OSVersionInfo')))
logger('Kodi version: {0}'.format( logger('Kodi version: {0}'.format(
xbmc.getInfoLabel('System.BuildVersion')) xbmc.getInfoLabel('System.BuildVersion'))
) )
@ -170,7 +135,7 @@ def log_exception(logger=None):
raise raise
@python_2_unicode_compatible
class Params(dict): class Params(dict):
""" """
Params(**kwargs) Params(**kwargs)
@ -198,7 +163,7 @@ class Params(dict):
return '<Params {0}>'.format(super(Params, self).__str__()) return '<Params {0}>'.format(super(Params, self).__str__())
@python_2_unicode_compatible
class Storage(MutableMapping): class Storage(MutableMapping):
""" """
Storage(storage_dir, filename='storage.pcl') Storage(storage_dir, filename='storage.pcl')
@ -303,7 +268,7 @@ class Storage(MutableMapping):
return deepcopy(self._storage) return deepcopy(self._storage)
@python_2_unicode_compatible
class MemStorage(MutableMapping): class MemStorage(MutableMapping):
""" """
MemStorage(storage_id) MemStorage(storage_id)
@ -367,7 +332,7 @@ class MemStorage(MutableMapping):
def __getitem__(self, key): def __getitem__(self, key):
self._check_key(key) self._check_key(key)
full_key = py2_encode('{0}__{1}'.format(self._id, key)) full_key = '{0}__{1}'.format(self._id, key)
raw_item = self._window.getProperty(full_key) raw_item = self._window.getProperty(full_key)
if raw_item: if raw_item:
try: try:
@ -379,7 +344,7 @@ class MemStorage(MutableMapping):
def __setitem__(self, key, value): def __setitem__(self, key, value):
self._check_key(key) self._check_key(key)
full_key = py2_encode('{0}__{1}'.format(self._id, key)) full_key = '{0}__{1}'.format(self._id, key)
# protocol=0 is needed for safe string handling in Python 3 # protocol=0 is needed for safe string handling in Python 3
self._window.setProperty(full_key, pickle.dumps(value, protocol=0)) self._window.setProperty(full_key, pickle.dumps(value, protocol=0))
if key != '__keys__': if key != '__keys__':
@ -389,7 +354,7 @@ class MemStorage(MutableMapping):
def __delitem__(self, key): def __delitem__(self, key):
self._check_key(key) self._check_key(key)
full_key = py2_encode('{0}__{1}'.format(self._id, key)) full_key = '{0}__{1}'.format(self._id, key)
item = self._window.getProperty(full_key) item = self._window.getProperty(full_key)
if item: if item:
self._window.clearProperty(full_key) self._window.clearProperty(full_key)
@ -402,7 +367,7 @@ class MemStorage(MutableMapping):
def __contains__(self, key): def __contains__(self, key):
self._check_key(key) self._check_key(key)
full_key = py2_encode('{0}__{1}'.format(self._id, key)) full_key = '{0}__{1}'.format(self._id, key)
item = self._window.getProperty(full_key) item = self._window.getProperty(full_key)
return bool(item) return bool(item)
@ -413,7 +378,7 @@ class MemStorage(MutableMapping):
return len(self['__keys__']) return len(self['__keys__'])
@python_2_unicode_compatible
class Addon(object): class Addon(object):
""" """
Base addon class Base addon class
@ -430,9 +395,7 @@ class Addon(object):
:type id_: str :type id_: str
""" """
self._addon = xbmcaddon.Addon(id_) self._addon = xbmcaddon.Addon(id_)
self._profile_dir = py2_decode( self._profile_dir = translate_path(self._addon.getAddonInfo('profile'))
translate_path(self._addon.getAddonInfo('profile'))
)
self._ui_strings_map = None self._ui_strings_map = None
if not os.path.exists(self._profile_dir): if not os.path.exists(self._profile_dir):
os.mkdir(self._profile_dir) os.mkdir(self._profile_dir)
@ -468,7 +431,7 @@ class Addon(object):
:return: path to the addon folder :return: path to the addon folder
:rtype: unicode :rtype: unicode
""" """
return py2_decode(self._addon.getAddonInfo('path')) return self._addon.getAddonInfo('path')
@property @property
def icon(self): def icon(self):
@ -633,7 +596,7 @@ class Addon(object):
:type convert: bool :type convert: bool
:return: setting value :return: setting value
""" """
setting = py2_decode(self._addon.getSetting(id_)) setting = self._addon.getSetting(id_)
if convert: if convert:
if setting == 'true': if setting == 'true':
return True # Convert boolean strings to bool return True # Convert boolean strings to bool
@ -664,7 +627,7 @@ class Addon(object):
value = 'true' if value else 'false' value = 'true' if value else 'false'
elif not isinstance(value, basestring): elif not isinstance(value, basestring):
value = str(value) value = str(value)
self._addon.setSetting(id_, py2_encode(value)) self._addon.setSetting(id_, value)
def log(self, message, level=xbmc.LOGDEBUG): def log(self, message, level=xbmc.LOGDEBUG):
""" """
@ -677,7 +640,7 @@ class Addon(object):
:type level: int :type level: int
""" """
xbmc.log( xbmc.log(
py2_encode('{0} [v.{1}]: {2}'.format(self.id, self.version, message)), '{0} [v.{1}]: {2}'.format(self.id, self.version, message),
level level
) )
@ -957,7 +920,7 @@ class Addon(object):
return ui_strings return ui_strings
@python_2_unicode_compatible
class Plugin(Addon): class Plugin(Addon):
""" """
Plugin class with URL query string routing. Plugin class with URL query string routing.
@ -1016,9 +979,9 @@ class Plugin(Addon):
""" """
raw_params = parse_qs(paramstring) raw_params = parse_qs(paramstring)
params = Params() params = Params()
for key, value in iteritems(raw_params): for key, value in iter(raw_params.items()):
param_value = value[0] if len(value) == 1 else value param_value = value[0] if len(value) == 1 else value
params[key] = py2_decode(param_value) params[key] = param_value
return params return params
def get_url(self, plugin_url='', **kwargs): def get_url(self, plugin_url='', **kwargs):
@ -1125,7 +1088,6 @@ class Plugin(Addon):
return action_callable(self._params) return action_callable(self._params)
@python_2_unicode_compatible
class RoutedPlugin(Plugin): class RoutedPlugin(Plugin):
""" """
Plugin class that implements "pretty URL" routing similar to Flask and Bottle Plugin class that implements "pretty URL" routing similar to Flask and Bottle
@ -1224,7 +1186,7 @@ class RoutedPlugin(Plugin):
for arg, match in zip(args, matches): for arg, match in zip(args, matches):
pattern = pattern.replace( pattern = pattern.replace(
match, match,
quote_plus(py2_encode(str(arg))) quote_plus(str(arg))
) )
# list allows to manipulate the dict during iteration # list allows to manipulate the dict during iteration
for key, value in list(iteritems(kwargs)): for key, value in list(iteritems(kwargs)):
@ -1237,7 +1199,7 @@ class RoutedPlugin(Plugin):
if key == match_string: if key == match_string:
pattern = pattern.replace( pattern = pattern.replace(
match, quote_plus(py2_encode(str(value))) match, quote_plus(str(value))
) )
del kwargs[key] del kwargs[key]
url = 'plugin://{0}{1}'.format(self.id, pattern) url = 'plugin://{0}{1}'.format(self.id, pattern)
@ -1378,7 +1340,7 @@ class RoutedPlugin(Plugin):
value = float(value) value = float(value)
kwargs[key] = value kwargs[key] = value
else: else:
kwargs[key] = py2_decode(unquote_plus(value)) kwargs[key] = unquote_plus(value)
self.log_debug( self.log_debug(
'Calling {0} with kwargs {1}'.format(route, kwargs)) 'Calling {0} with kwargs {1}'.format(route, kwargs))
with log_exception(self.log_error): with log_exception(self.log_error):

190
main.py
View File

@ -9,13 +9,14 @@ import xbmcgui
import json import json
import shutil import shutil
import time import time
import dateutil.parser import hashlib
import random
from datetime import datetime from datetime import datetime
from collections.abc import MutableMapping from collections import MutableMapping
from collections import namedtuple from collections import namedtuple
# Add the /lib folder to sys # Add the /lib folder to sys
sys.path.append(xbmcvfs.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib"))) sys.path.append(xbmc.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib")))
import libsonic import libsonic
@ -60,6 +61,7 @@ def get_connection():
if connected==False: if connected==False:
popup('Connection error') popup('Connection error')
plugin.log('Connection error')
return False return False
return connection return connection
@ -106,6 +108,11 @@ def root(params):
'callback': 'search', 'callback': 'search',
'thumb': None 'thumb': None
}, },
'searchalbum': {
'name': Addon().get_localized_string(30045),
'callback': 'search_album',
'thumb': None
},
} }
# Iterate through categories # Iterate through categories
@ -238,7 +245,6 @@ def menu_tracks(params):
)) ))
@plugin.action() @plugin.action()
#@plugin.cached(cachetime) # cache (in minutes)
def browse_folders(params): def browse_folders(params):
# get connection # get connection
connection = get_connection() connection = get_connection()
@ -305,6 +311,7 @@ def browse_indexes(params):
def list_directory(params): def list_directory(params):
# get connection # get connection
connection = get_connection() connection = get_connection()
merge_artist = Addon().get_setting('merge')
if connection==False: if connection==False:
return return
@ -313,7 +320,7 @@ def list_directory(params):
# Get items # Get items
id = params.get('id') id = params.get('id')
items = walk_directory(id) items = walk_directory(id, merge_artist)
# Iterate through items # Iterate through items
for item in items: for item in items:
@ -379,7 +386,6 @@ def browse_library(params):
)) ))
@plugin.action() @plugin.action()
#@plugin.cached(cachetime) #cache (in minutes)
def list_albums(params): def list_albums(params):
""" """
@ -453,7 +459,6 @@ def list_albums(params):
)) ))
@plugin.action() @plugin.action()
#@plugin.cached(cachetime) #cache (in minutes)
def list_tracks(params): def list_tracks(params):
menu_id = params.get('menu_id') menu_id = params.get('menu_id')
@ -549,11 +554,6 @@ def list_tracks(params):
content = 'songs' #string - current plugin content, e.g. movies or episodes. content = 'songs' #string - current plugin content, e.g. movies or episodes.
)) ))
#stars (persistent) cache==used to know what context action (star/unstar) we should display.
#run this function every time we get starred items.
#ids can be a single ID or a list
#using a set makes sure that IDs will be unique.
@plugin.action() @plugin.action()
def list_playlists(params): def list_playlists(params):
@ -578,38 +578,71 @@ def list_playlists(params):
listing, listing,
sort_methods = get_sort_methods('playlists',params), #he list of integer constants representing virtual folder sort methods. sort_methods = get_sort_methods('playlists',params), #he list of integer constants representing virtual folder sort methods.
)) ))
@plugin.action() @plugin.action()
#@plugin.cached(cachetime) #cache (in minutes)
def search(params): def search(params):
dialog = xbmcgui.Dialog() dialog = xbmcgui.Dialog()
d = dialog.input(Addon().get_localized_string(30039), type=xbmcgui.INPUT_ALPHANUM) d = dialog.input(Addon().get_localized_string(30039), type=xbmcgui.INPUT_ALPHANUM)
if not d:
d = " "
# get connection
connection = get_connection()
if connection==False:
return
listing = [] listing = []
# Get items if d:
items = connection.search2(query=d) # get connection
# Iterate through items connection = get_connection()
for item in items.get('searchResult2').get('song'):
entry = get_entry_track( item, params)
listing.append(entry)
if len(listing) == 1: if connection == False:
plugin.log('One single Media Folder found; do return listing from browse_indexes()...') return
# Get items
items = connection.search2(query=d)
# Iterate through items
songs = items.get('searchResult2').get('song')
if songs:
for item in songs:
entry = get_entry_track( item, params)
listing.append(entry)
if len(listing) == 0:
plugin.log('No songs found; do return listing from browse_indexes()...')
return browse_indexes(params) return browse_indexes(params)
else: else:
add_directory_items(create_listing(listing)) add_directory_items(create_listing(listing))
@plugin.action()
def search_album(params):
dialog = xbmcgui.Dialog()
d = dialog.input(Addon().get_localized_string(30045), type=xbmcgui.INPUT_ALPHANUM)
listing = []
if d:
# get connection
connection = get_connection()
if connection==False:
return
# Get items, we are only looking for albums here
# so artistCount and songCount is set to 0
items = connection.search2(query=d, artistCount=0, songCount=0)
# Iterate through items
album_list = items.get('searchResult2').get('album')
if album_list:
for item in items.get('searchResult2').get('album'):
entry = get_entry_album( item, params)
listing.append(entry)
# I believe it is ok to return an empty listing if
# the search gave no result
# maybe inform the user?
if len(listing) == 0:
plugin.log('No albums found; do return listing from browse_indexes()...')
return browse_indexes(params)
else:
add_directory_items(create_listing(listing))
@plugin.action() @plugin.action()
def play_track(params): def play_track(params):
@ -702,7 +735,6 @@ def star_item(params):
#return did_action #return did_action
return return
@plugin.action() @plugin.action()
def download_item(params): def download_item(params):
@ -734,7 +766,6 @@ def download_item(params):
return did_action return did_action
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_playlist(item,params): def get_entry_playlist(item,params):
image = connection.getCoverArtUrl(item.get('coverArt')) image = connection.getCoverArtUrl(item.get('coverArt'))
return { return {
@ -755,19 +786,42 @@ def get_entry_playlist(item,params):
}} }}
} }
#star (or unstar) an item def get_artist_info(artist_id, forced=False):
#@plugin.cached(cachetime) #cache (in minutes) 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): def get_entry_artist(item,params):
image = connection.getCoverArtUrl(item.get('coverArt')) image = connection.getCoverArtUrl(item.get('coverArt'))
#artist_info = connection.getArtistInfo(item.get('id')).get('artistInfo') #artist_info = get_artist_info(item.get('id'))
#artist_bio = artist_info.get('biography') #artist_bio = artist_info.get('biography')
#xbmc.log("Artist info: %s"%artist_info.get('biography'),xbmc.LOGINFO) #fanart = artist_info.get('largeImageUrl')
fanart = image
return { return {
'label': get_starred_label(item.get('id'),item.get('name')), 'label': get_starred_label(item.get('id'),item.get('name')),
'label2': "test label", 'label2': "test label",
'offscreen': True, 'offscreen': True,
'thumb': image, 'thumb': image,
'fanart': image, 'fanart': fanart,
'url': plugin.get_url( 'url': plugin.get_url(
action= 'list_albums', action= 'list_albums',
artist_id= item.get('id'), artist_id= item.get('id'),
@ -776,16 +830,15 @@ def get_entry_artist(item,params):
'info': { 'info': {
'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo 'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'count': item.get('albumCount'), 'count': item.get('albumCount'),
'artist': item.get('name')#, 'artist': item.get('name'),
#'title': "testtitle", #'title': "testtitle",
#'album': "testalbum", #'album': "testalbum",
#'comment': "testcomment" #'comment': "testcomment"
# 'title': artist_info.get('biography') #'title': artist_bio
} }
} }
} }
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_album(item, params): def get_entry_album(item, params):
image = connection.getCoverArtUrl(item.get('coverArt')) image = connection.getCoverArtUrl(item.get('coverArt'))
@ -829,7 +882,6 @@ def get_entry_album(item, params):
return entry return entry
def get_entry_track(item,params): def get_entry_track(item,params):
menu_id = params.get('menu_id') menu_id = params.get('menu_id')
image = connection.getCoverArtUrl(item.get('coverArt')) image = connection.getCoverArtUrl(item.get('coverArt'))
@ -1039,7 +1091,11 @@ def navigate_root():
#converts a date string from eg. '2012-04-17T19:53:44' to eg. '17.04.2012' #converts a date string from eg. '2012-04-17T19:53:44' to eg. '17.04.2012'
def convert_date_from_iso8601(iso8601): 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') return date_obj.strftime('%d.%m.%Y')
def context_action_star(type,id): def context_action_star(type,id):
@ -1297,7 +1353,6 @@ def create_list_item(item):
return list_item return list_item
def _set_resolved_url(context): def _set_resolved_url(context):
plugin.log_debug('Resolving URL from {0}'.format(str(context))) plugin.log_debug('Resolving URL from {0}'.format(str(context)))
if context.play_item==None: if context.play_item==None:
list_item = xbmcgui.ListItem(path=context.path) list_item = xbmcgui.ListItem(path=context.path)
@ -1351,12 +1406,15 @@ def walk_index(folder_id=None):
Request Subsonic's index and iterate each item. Request Subsonic's index and iterate each item.
""" """
response = connection.getIndexes(folder_id) response = connection.getIndexes(folder_id)
plugin.log("Walk index resp: %s"%response)
try: try:
for index in response["indexes"]["index"]: for index in response["indexes"]["index"]:
for artist in index["artist"]: for artist in index["artist"]:
plugin.log("artist: %s"%artist)
yield artist yield artist
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_playlists(): def walk_playlists():
""" """
@ -1367,7 +1425,8 @@ def walk_playlists():
for child in response["playlists"]["playlist"]: for child in response["playlists"]["playlist"]:
yield child yield child
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_playlist(playlist_id): def walk_playlist(playlist_id):
""" """
@ -1378,7 +1437,8 @@ def walk_playlist(playlist_id):
for child in response["playlist"]["entry"]: for child in response["playlist"]["entry"]:
yield child yield child
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_folders(): def walk_folders():
response = connection.getMusicFolders() response = connection.getMusicFolders()
@ -1386,9 +1446,10 @@ def walk_folders():
for child in response["musicFolders"]["musicFolder"]: for child in response["musicFolders"]["musicFolder"]:
yield child yield child
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_directory(directory_id): def walk_directory(directory_id, merge_artist = True):
""" """
Request a Subsonic music directory and iterate over each item. Request a Subsonic music directory and iterate over each item.
""" """
@ -1396,13 +1457,14 @@ def walk_directory(directory_id):
try: try:
for child in response["directory"]["child"]: for child in response["directory"]["child"]:
if child.get("isDir"): if merge_artist and child.get("isDir"):
for child in walk_directory(child["id"]): for child in walk_directory(child["id"], merge_artist):
yield child yield child
else: else:
yield child yield child
except: except KeyError:
yield from () for emp in ():
yield emp
def walk_artist(artist_id): def walk_artist(artist_id):
""" """
@ -1414,7 +1476,8 @@ def walk_artist(artist_id):
for child in response["artist"]["album"]: for child in response["artist"]["album"]:
yield child yield child
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_artists(): def walk_artists():
""" """
@ -1427,7 +1490,8 @@ def walk_artists():
for artist in index["artist"]: for artist in index["artist"]:
yield artist yield artist
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_genres(): def walk_genres():
""" """
@ -1439,7 +1503,8 @@ def walk_genres():
for genre in response["genres"]["genre"]: for genre in response["genres"]["genre"]:
yield genre yield genre
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_albums(ltype, size=None, fromYear=None,toYear=None, genre=None, offset=None): def walk_albums(ltype, size=None, fromYear=None,toYear=None, genre=None, offset=None):
""" """
@ -1473,7 +1538,8 @@ def walk_album(album_id):
for song in response["album"]["song"]: for song in response["album"]["song"]:
yield song yield song
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_tracks_random(size=None, genre=None, fromYear=None,toYear=None): def walk_tracks_random(size=None, genre=None, fromYear=None,toYear=None):
""" """
@ -1485,7 +1551,8 @@ def walk_tracks_random(size=None, genre=None, fromYear=None,toYear=None):
for song in response["randomSongs"]["song"]: for song in response["randomSongs"]["song"]:
yield song yield song
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
def walk_tracks_starred(): def walk_tracks_starred():
""" """
@ -1496,7 +1563,8 @@ def walk_tracks_starred():
for song in response["starred"]["song"]: for song in response["starred"]["song"]:
yield song yield song
except KeyError: except KeyError:
yield from () for emp in ():
yield emp
# Start plugin from within Kodi. # Start plugin from within Kodi.
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -77,7 +77,7 @@ msgid "Cache (in minutes)"
msgstr "" msgstr ""
msgctxt "#30018" msgctxt "#30018"
msgid "Cache datas time" msgid "Cache data time"
msgstr "" msgstr ""
msgctxt "#30019" msgctxt "#30019"
@ -154,7 +154,7 @@ msgid "Browse"
msgstr "" msgstr ""
msgctxt "#30039" msgctxt "#30039"
msgid "Search" msgid "Search Songs"
msgstr "" msgstr ""
msgctxt "#30040" msgctxt "#30040"
@ -169,3 +169,14 @@ msgctxt "#30042"
msgid "port" msgid "port"
msgstr "" msgstr ""
msgctxt "#30043"
msgid "Merge album folders"
msgstr ""
msgctxt "#30044"
msgid "Scrobble to Last.FM"
msgstr ""
msgctxt "#30045"
msgid "Search Albums"
msgstr ""

View File

@ -153,8 +153,8 @@ msgid "Browse"
msgstr "Parcourir" msgstr "Parcourir"
msgctxt "#30039" msgctxt "#30039"
msgid "Search" msgid "Search Songs"
msgstr "Rechercher" msgstr "Rechercher Chansons"
msgctxt "#30040" msgctxt "#30040"
@ -169,3 +169,14 @@ msgctxt "#30042"
msgid "port" msgid "port"
msgstr "" msgstr ""
msgctxt "#30043"
msgid "Merge album folders"
msgstr ""
msgctxt "#30044"
msgid "Scrobble to Last.FM"
msgstr ""
msgctxt "#30045"
msgid "Search Albums"
msgstr "Rechercher Albums"

View File

@ -153,8 +153,8 @@ msgid "Browse"
msgstr "Durchsuchen" msgstr "Durchsuchen"
msgctxt "#30039" msgctxt "#30039"
msgid "Search" msgid "Search Songs"
msgstr "Suche" msgstr "Suche Lieder"
msgctxt "#30040" msgctxt "#30040"
msgid "useGET" msgid "useGET"
@ -168,3 +168,14 @@ msgctxt "#30042"
msgid "port" msgid "port"
msgstr "" msgstr ""
msgctxt "#30043"
msgid "Merge album folders"
msgstr ""
msgctxt "#30044"
msgid "Scrobble to Last.FM"
msgstr ""
msgctxt "#30045"
msgid "Search Albums"
msgstr "Suche Albums"

View File

@ -25,7 +25,9 @@
<setting label="30040" id="useget" type="bool" default="true" /> <setting label="30040" id="useget" type="bool" default="true" />
<setting label="30041" id="legacyauth" type="bool" default="false" /> <setting label="30041" id="legacyauth" type="bool" default="false" />
<setting label="30017" type="lsep" /> <setting label="30017" type="lsep" />
<setting label="30018" id="cachetime" type="labelenum" default="15" values="1|5|15|30|60|120|180|720|1440"/> <setting label="30018" id="cachetime" type="labelenum" default="3600" values="1|5|15|30|60|120|180|720|1440|3600"/>
<setting label="30043" id="merge" type="bool" default="false" />
<setting label="30044" id="scrobble" type="bool" default="false" />
</category> </category>
</settings> </settings>

105
service.py Normal file
View File

@ -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)