59 Commits

Author SHA1 Message Date
552cd606f5 Some cleanup 2021-10-11 10:33:35 +11:00
4980752fd0 Some cleanup 2021-10-04 13:00:31 +11:00
f587d3d4d6 Refresh info by alpha index to avoid blocking 2021-10-04 12:46:10 +11:00
607bb52158 Clean up try/catch 2021-09-29 14:15:14 +10:00
117175d2cc Service will now check for old address format 2021-09-29 13:59:54 +10:00
0db8d3b06c Fix log error 2021-09-28 17:26:13 +10:00
3fb94e3a68 Update addon.xml for v3.0.3 2021-09-28 16:07:02 +10:00
94ceecb245 Update readme for v3.0.3 2021-09-28 14:56:31 +10:00
3b694559e2 Update Changelog for v3.0.3 2021-09-28 14:55:31 +10:00
3a71d3c107 Improved settings handling - fulltime service 2021-09-28 14:52:43 +10:00
cefe9d617f Merge branch 'master' into artist-info 2021-09-28 10:22:41 +10:00
5fa93a9830 Some cleanup 2021-09-28 10:18:59 +10:00
f7e3d18b5b Merge branch 'master' into artist-info 2021-09-28 09:21:58 +10:00
1295d078e5 Update readme for v3.0.2 2021-09-28 09:16:33 +10:00
4b1e0ad104 Update changelog for v3.0.2 2021-09-28 09:13:44 +10:00
7baa12f06f remove future and dateutil dependency 2021-09-28 09:05:01 +10:00
9d8eaef45b cleanup 2021-09-28 08:19:57 +10:00
42629ac27f remove future dependency 2021-09-27 09:51:56 +10:00
569d028d7c remove dateutil dependency 2021-09-27 09:47:32 +10:00
98d7965307 remove dateutil dependency 2021-09-27 09:46:37 +10:00
d9398ea082 testing error popups 2021-09-27 08:42:58 +10:00
1b708bdbb1 Allow disable in main.py 2021-09-18 11:07:43 +10:00
ade19135a3 Added image grab from wikipedia - artist level only 2021-09-18 10:05:27 +10:00
40d4ac737a Added info refresh to service 2021-09-17 18:09:10 +10:00
9be5f646da Updated modules and DB functions - service needs DB update function added 2021-09-16 17:07:23 +10:00
d141895328 Merge branch 'artist-info' of github.com:warwickh/plugin.audio.subsonic into artist-info 2021-09-16 16:24:25 +10:00
d85328ffa0 DB Working but slow 2021-09-11 12:15:22 +10:00
b7e734f6d3 DB Working but slow 2021-09-11 12:02:54 +10:00
54a071a9c7 DB functions working - slow to load 2021-09-11 10:55:43 +10:00
f97b9e1de9 Don't start service if scrobbling switched off 2021-09-10 09:24:48 +10:00
8ca52e6cc8 Resolve merge conflict 2021-09-09 18:04:37 +10:00
c4c0aa00c6 Merge scrobble to master 2021-09-09 18:01:32 +10:00
67bd25496e Testing when setting doesn't exist 2021-09-09 12:10:43 +10:00
0932e650f8 Notify service start 2021-09-09 11:48:06 +10:00
57141eed02 Merge branch 'scrobble' of github.com:warwickh/plugin.audio.subsonic into scrobble 2021-09-09 09:40:37 +10:00
2335973433 Some error fixes 2021-09-09 09:40:18 +10:00
150b5012da Update readme to include scrobbling 2021-09-08 17:00:23 +10:00
8c4f31db05 More efficient settings and return value 2021-09-08 16:47:08 +10:00
596ddaacdb First working scrobble service 2021-09-08 16:25:36 +10:00
c25e5848cf Update settings for merge default 2021-09-07 17:01:24 +10:00
469681bf5e Add insecure fix - still needs correct hostname 2021-09-07 15:29:59 +10:00
ced597b6d2 Update changelog for v3.0.1 2021-09-06 10:55:20 +10:00
03da132550 Added option for merging albums in browse mode 2021-09-05 15:13:23 +10:00
742c5b66fc Working with random update, need to format info better 2021-09-05 14:34:41 +10:00
640157dad0 Added artist info - first working 2021-09-04 16:40:44 +10:00
56dffe70bc Added artist info - some errors remaining 2021-09-04 16:00:49 +10:00
34a7c249be Update settings string 2021-09-04 14:13:08 +10:00
67d2302bc4 Update installation details in readme 2021-09-04 10:23:26 +10:00
9fbb67cc3c Update readme with repo details 2021-09-03 18:35:39 +10:00
0ac9d36187 Update readme to include repository zip 2021-09-03 18:34:06 +10:00
49754bccaa Update version number 2021-09-03 13:05:07 +10:00
358df44fb1 Cleanup and prepare for merge 2021-09-02 16:54:48 +10:00
a551df5c8c Clean up deprecation warnings and add some logging to resolve http client timeout 2021-09-02 12:56:34 +10:00
e640d0f81d Update Readme to include Navidrome compatibility 2021-08-31 09:22:18 +10:00
3aac9d1c54 Change to use GET as default 2021-08-31 09:16:03 +10:00
7564e75f63 Catch empty star response 2021-08-31 08:52:05 +10:00
ed3f7da8b5 Remove dependency for integer ids to suit Navidrome requirement 2021-08-30 16:42:28 +10:00
194844571c Fix ndent 2021-07-02 18:12:01 +10:00
6035ff49b8 Avoid errors in walk functions 2021-07-02 17:37:44 +10:00
15 changed files with 830 additions and 290 deletions

View File

@ -1,5 +1,19 @@
# Changelog
## v3.0.3
Released 29th September 2021 (by warwickh)
* Added enhanced data collection and display from musicbrainz and wikipedia
* Added aqlite storage of artist information to speed loading
## 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

View File

@ -1,5 +1,5 @@
# 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/warwickh/plugin.audio.subsonic/issues
@ -15,21 +15,26 @@ Leia compatible version available in alternate branch
* 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
* Enhanced data loading from Musicbrainz and wikipedia (switch on in settings and restart)
## Installation
From repository
[repository.warwickh-0.9.0.zip](https://github.com/warwickh/repository.warwickh/raw/master/matrix/zips/repository.warwickh/repository.warwickh-0.9.0.zip) (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
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

View File

@ -1,13 +1,12 @@
<?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">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
<import addon="script.module.dateutil" version="2.4.2"/>
<import addon="script.module.future" version="0.18.2"/>
</requires>
<extension point="xbmc.python.pluginsource" library="main.py">
<provides>audio</provides>
</extension>
<addon id="plugin.audio.subsonic" name="Subsonic" version="3.0.3" provider-name="BasilFX,warwickh">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
</requires>
<extension point="xbmc.python.pluginsource" library="main.py">
<provides>audio</provides>
</extension>
<extension point="xbmc.service" library="service.py" />
<extension point="xbmc.addon.metadata">
<summary lang="en">Subsonic music addon for Kodi.</summary>
<summary lang="fr">Extension Subsonic pour Kodi.</summary>

7
lib/dbutils/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""
Databse utilities for plugin.audio.subsonic
"""
from .dbutils import *
__version__ = '0.0.1'

118
lib/dbutils/dbutils.py Normal file
View File

@ -0,0 +1,118 @@
import sqlite3 as sql
import time
import xbmc
tbl_artist_info_ddl = str('CREATE TABLE artist_info ('
'artist_id TEXT NOT NULL PRIMARY KEY,'
'artist_name TEXT,'
'artist_info TEXT,'
'mb_artist_id TEXT,'
'image_url TEXT,'
'wikidata_url TEXT,'
'wikipedia_url TEXT,'
'wikipedia_image TEXT,'
'wikipedia_extract TEXT,'
'last_update TEXT);')
class SQLiteDatabase(object):
def __init__(self, db_filename):
print("Init %s"%db_filename)
self.db_filename = db_filename
self.conn = None
self.connect()
def connect(self):
try:
xbmc.log("Trying connection to the database %s"%self.db_filename, xbmc.LOGINFO)
#print("Trying connection to the database %s"%self.db_filename)
self.conn = sql.connect(self.db_filename)
cursor = self.conn.cursor()
cursor.execute(str('SELECT SQLITE_VERSION()'))
xbmc.log("Connection %s was successful %s"%(self.db_filename, cursor.fetchone()[0]), xbmc.LOGINFO)
#print("Connection %s was successful %s"%(self.db_filename, cursor.fetchone()[0]))
cursor.row_factory = lambda cursor, row: row[0]
cursor.execute(str('SELECT name FROM sqlite_master WHERE type=\'table\' ''AND name NOT LIKE \'sqlite_%\''))
list_tables = cursor.fetchall()
if not list_tables:
# If no tables exist create a new one
xbmc.log("Creating Subsonic local DB", xbmc.LOGINFO)
#print("Creating Subsonic local DB")
cursor.execute(tbl_artist_info_ddl)
except sql.Error as e:
xbmc.log("SQLite error %s"%e.args[0], xbmc.LOGINFO)
#print("SQLite error %s"%e.args[0])
def get_cursor(self):
return self.conn.cursor()
def run_query(self, query, params=None, cursor=None):
#print("Processing query %s params %s"%(str(query),str(params)))
try:
if cursor is None:
cursor = self.get_cursor()
if params is not None:
cursor.execute(query, params)
else:
cursor.execute(query)
#print("%s rows affected"%cursor.rowcount)
return cursor
except sql.Error as e:
print("SQLite error %s"%e.args[0])
except ValueError:
pass
#print("Error query %s"%str(query))
#print("Error query type %s"%type(query))
#print("Error params %s"%str(params))
#print("Error params type %s"%type(params))
def get_record_age(self, artist_id):
try:
last_update = self.get_value(artist_id, 'last_update')
record_age = round(time.time())-round(float(last_update[0][0]))
return record_age
except IndexError:
print("No existing record for artist %s" % artist_id)
except Exception as e:
print("get_record_age failed %s" % e)
return 0
def get_artist_info(self, artist_id):
query = 'SELECT * FROM artist_info WHERE artist_id = ?'# %str(artist_id)
params = [str(artist_id)]
cursor = self.run_query(query, params)
return cursor.fetchall()
def get_all(self):
query = 'SELECT * FROM artist_info'
#params = [str(artist_id)]
cursor = self.run_query(query)#, params)
return cursor.fetchall()
def update_value(self, artist_id, field_name, field_value):
success = False
query = 'UPDATE artist_info SET %s = ?, last_update = ? WHERE artist_id = ?' % str(field_name)
params = [str(field_value), str(time.time()), str(artist_id)]
cursor = self.run_query(query, params)
if (cursor.rowcount == 0):
query = 'INSERT OR IGNORE INTO artist_info (artist_id, %s, last_update) VALUES (?, ?, ?)' % str(field_name)
params = [str(artist_id), str(field_value), str(time.time())]
cursor = self.run_query(query, params)
try:
self.conn.commit()
success = True
except Exception as e:
print("Exception %s"%e)
pass
return success
def get_value(self, artist_id, field_name):
query = 'SELECT %s FROM artist_info WHERE artist_id = ?' % str(field_name)
params = [str(artist_id)]
cursor = self.run_query(query, params)
return cursor.fetchall()
def close(self):
if self.conn:
self.conn.close()

View File

@ -47,14 +47,14 @@ class HTTPSConnectionChain(http_client.HTTPSConnection):
def connect(self):
sock = self._create_sock()
try:
self.sock = ssl.create_default_context().wrap_socket(sock,
self.sock = self._context.wrap_socket(sock,
server_hostname=self.host)
except:
sock.close()
class HTTPSHandlerChain(urllib.request.HTTPSHandler):
def https_open(self, req):
return self.do_open(HTTPSConnectionChain, req)
return self.do_open(HTTPSConnectionChain, req, context=self._context)
# install opener
urllib.request.install_opener(urllib.request.build_opener(HTTPSHandlerChain()))
@ -228,11 +228,15 @@ class Connection(object):
"""
methodName = 'ping'
viewName = '%s.view' % methodName
req = self._getRequest(viewName)
#print("Pinging %s"%str(req.full_url)),level=xbmc.LOGDEBUG)
#xbmc.log("Pinging %s"%str(req.full_url),level=xbmc.LOGDEBUG)
try:
res = self._doInfoReq(req)
except:
#print(res)
except Exception as e:
#print("Ping failed %s"%e)
xbmc.log("Ping failed %s"%e,level=xbmc.LOGDEBUG)
return False
if res['status'] == 'ok':
return True
@ -894,11 +898,12 @@ class Connection(object):
'converted': converted})
req = self._getRequest(viewName, q)
xbmc.log("Requesting %s"%str(req.full_url),xbmc.LOGDEBUG)
res = self._doBinReq(req)
if isinstance(res, dict):
self._checkStatus(res)
return req.full_url
##xbmc.log("Requesting %s"%str(req.full_url),level=xbmc.LOGDEBUG)
return_url = req.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):
@ -942,10 +947,12 @@ class Connection(object):
q = self._getQueryDict({'id': aid, 'size': size})
req = self._getRequest(viewName, q)
res = self._doBinReq(req)
if isinstance(res, dict):
self._checkStatus(res)
return req.full_url
##xbmc.log("Requesting %s"%str(req.full_url),level=xbmc.LOGDEBUG)
return_url = req.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):
@ -1992,6 +1999,7 @@ class Connection(object):
q['musicFolderId'] = musicFolderId
req = self._getRequest(viewName, q)
#xbmc.log("Requesting %s"%str(req.full_url),level=xbmc.LOGDEBUG)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
@ -2483,6 +2491,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
@ -2672,7 +2682,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)
@ -2689,7 +2700,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)
@ -2706,7 +2718,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)
@ -2725,7 +2738,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)
@ -2797,12 +2811,13 @@ class Connection(object):
qdict.update(query)
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
viewName)
xbmc.log("Standard URL %s"%url,level=xbmc.LOGDEBUG)
xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGDEBUG)
#xbmc.log("Standard URL %s"%url,level=xbmc.LOGDEBUG)
#xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGDEBUG)
req = urllib.request.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)
xbmc.log("UseGET URL %s"%(url), xbmc.LOGDEBUG)
#print(url)
req = urllib.request.Request(url)
return req

View File

@ -0,0 +1,6 @@
"""
Musicbrainz utilities for plugin.audio.subsonic
"""
from .mbconnection import *
__version__ = '0.0.1'

View File

@ -0,0 +1,146 @@
import os
import json
import urllib.request
from urllib.parse import urlencode
import xml.etree.ElementTree as ET
import re
urllib.request.install_opener(urllib.request.build_opener(urllib.request.HTTPSHandler))
class MBConnection(object):
def __init__(self, lang = "en"):
self._lang = lang
self._baseUrl = "https://musicbrainz.org/ws/2"
self._wikipediaBaseUrl = "https://{}.wikipedia.org/wiki".format(self._lang)
self._wikidataBaseUrl = "https://www.wikidata.org/wiki/Special:EntityData"
self._opener = self._getOpener()
def _getOpener(self):
opener = urllib.request.build_opener(urllib.request.HTTPSHandler)
return opener
def search(self, entity, query, limit=None, offset=None):
viewName = '%s' % entity
q = self._getQueryDict({'query': query, 'limit': limit, 'offset': offset})
req = self._getRequest(self._baseUrl, viewName, q)
res = self._doInfoReqXml(req)
return res
def get_wiki_extract(self, title):
try:
if('http' in title):
#accepts search text or full url https://en.wikipedia.org/wiki/Alex_Lloyd
pattern = 'wikipedia.org/wiki/(.+)'
title = re.search(pattern, title).group(1)
viewName = 'api.php'
q = self._getQueryDict({'format' : 'json', 'action' : 'query', 'prop' : 'extracts', 'redirects' : 1, 'titles' : title})
req = self._getRequest(self._wikipediaBaseUrl[:-3], viewName, q, 'exintro&explaintext&')
res = self._doInfoReqJson(req)
pages = res['query']['pages']
extract = list(pages.values())[0]['extract']
return extract
except Exception as e:
print("get_artist_wikpedia failed %s"%e)
return
#https://en.wikipedia.org/w/api.php?exintro&explaintext&format=json&action=query&prop=extracts&redirects=1&titles=%C3%89milie_Simon
def get_wiki_image(self, title):
try:
if('http' in title):
#accepts search text or full url https://en.wikipedia.org/wiki/Alex_Lloyd
pattern = 'wikipedia.org/wiki/(.+)'
title = re.search(pattern, title).group(1)
viewName = 'api.php'
q = self._getQueryDict({'format' : 'json', 'action' : 'query', 'prop' : 'pageimages', 'pithumbsize' : 800, 'titles' : title})
req = self._getRequest(self._wikipediaBaseUrl[:-3], viewName, q)
res = self._doInfoReqJson(req)
pages = res['query']['pages']
print(res['query']['pages'])
image_url = list(pages.values())[0]['thumbnail']['source']
return image_url
except Exception as e:
print("get_wiki_image failed %s"%e)
return
def get_artist_id(self, query):
try:
dres = self.search('artist', query)
artist_list = dres.find('{http://musicbrainz.org/ns/mmd-2.0#}artist-list')
artist = artist_list.find('{http://musicbrainz.org/ns/mmd-2.0#}artist')
return artist.attrib['id']
except Exception as e:
print("get_artist_id failed %s"%e)
return
def get_relation(self, artist_id, rel_type):
try:
viewName = 'artist/%s' % artist_id
q = self._getQueryDict({'inc': "url-rels"})
req = self._getRequest(self._baseUrl, viewName, q)
res = self._doInfoReqXml(req)
for relation in res.iter('{http://musicbrainz.org/ns/mmd-2.0#}relation'):
if relation.attrib['type'] == rel_type:
return relation.find('{http://musicbrainz.org/ns/mmd-2.0#}target').text
except Exception as e:
print("get_artist_image failed %s"%e)
return
def get_artist_image(self, artist_id):
try:
image = self.get_relation(artist_id, 'image')
return image
except Exception as e:
print("get_artist_image failed %s"%e)
return
def get_artist_wikpedia(self, artist_id):
wikidata_url = self.get_relation(artist_id, 'wikidata')
pattern = 'www.wikidata.org/wiki/(Q\d+)'
try:
wikidata_ref = re.search(pattern, wikidata_url).group(1)
viewName = '%s.rdf' % wikidata_ref
q = self._getQueryDict({})
req = self._getRequest(self._wikidataBaseUrl, viewName, q )
res = self._doInfoReqXml(req)
for item in res.iter('{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description'):
try:
url = item.attrib['{http://www.w3.org/1999/02/22-rdf-syntax-ns#}about']
if self._wikipediaBaseUrl in url:
#print(urlencode(url))
#print((url.encode().decode('unicode-escape')))
return urllib.parse.unquote(url)
except KeyError:
pass
except Exception as e:
print("get_artist_wikpedia failed %s"%e)
return
def _getQueryDict(self, d):
"""
Given a dictionary, it cleans out all the values set to None
"""
for k, v in list(d.items()):
if v is None:
del d[k]
return d
def _getRequest(self, baseUrl, viewName, query={}, prefix=""):
qdict = {}
qdict.update(query)
url = '%s/%s' % (baseUrl, viewName)
if(prefix!='' or qdict!={}):
url += "?%s%s" % (prefix, urlencode(qdict))
print("UseGET URL %s" % (url))
req = urllib.request.Request(url)
return req
def _doInfoReqXml(self, req):
res = urllib.request.urlopen(req)
data = res.read().decode('utf-8')
dres = ET.fromstring(data)
return dres
def _doInfoReqJson(self, req):
res = urllib.request.urlopen(req)
dres = json.loads(res.read().decode('utf-8'))
return dres

View File

@ -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,17 +9,8 @@ SimplePlugin micro-framework for Kodi content plugins
**License**: `GPL v.3 <https://www.gnu.org/copyleft/gpl.html>`_
"""
from __future__ import unicode_literals
from future.builtins import (zip, super,
bytes, dict, int, list, object, str)
from future.utils import (PY2, PY3, iteritems, itervalues,
python_2_unicode_compatible)
# from future.standard_library import install_aliases
# install_aliases()
if PY3:
basestring = str
long = int
basestring = str
long = int
import os
import sys
@ -34,24 +26,16 @@ from shutil import copyfile
from contextlib import contextmanager
from pprint import pformat
from platform import uname
if PY3:
from urllib.parse import urlencode, quote_plus, urlparse, unquote_plus, parse_qs
else:
from future.backports.urllib.parse import urlencode, quote_plus, urlparse, unquote_plus
from urlparse import parse_qs
from urllib.parse import urlencode, quote_plus, urlparse, unquote_plus, parse_qs
import xbmcaddon
import xbmc
import xbmcgui
import xbmcvfs
__all__ = ['SimplePluginError', 'Storage', 'MemStorage', 'Addon', 'Plugin',
'RoutedPlugin', 'Params', 'log_exception', 'py2_encode',
'py2_decode', 'translate_path']
'RoutedPlugin', 'Params', 'log_exception', 'translate_path']
if PY3:
getargspec = inspect.getfullargspec
else:
getargspec = inspect.getargspec
getargspec = inspect.getfullargspec
Route = namedtuple('Route', ['pattern', 'func'])
@ -74,35 +58,13 @@ def _format_vars(variables):
:return: formatted string with sorted ``var = val`` pairs
:rtype: str
"""
var_list = [(var, val) for var, val in iteritems(variables)]
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 py2_encode(s, encoding='utf-8'):
"""
Encode Python 2 ``unicode`` to ``str``
In Python 3 the string is not changed.
"""
if PY2 and isinstance(s, str):
s = s.encode(encoding)
return s
def py2_decode(s, encoding='utf-8'):
"""
Decode Python 2 ``str`` to ``unicode``
In Python 3 the string is not changed.
"""
if PY2 and isinstance(s, bytes):
s = s.decode(encoding)
return s
def _kodi_major_version():
kodi_version = xbmc.getInfoLabel('System.BuildVersion').split(' ')[0]
return kodi_version.split('.')[0]
@ -146,12 +108,12 @@ def log_exception(logger=None):
yield
except:
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]
logger('Unhandled exception detected!')
logger('*** Start diagnostic info ***')
logger('System info: {0}'.format(uname()))
logger('OS info: {0}'.format(py2_decode(xbmc.getInfoLabel('System.OSVersionInfo'))))
logger('OS info: {0}'.format(xbmc.getInfoLabel('System.OSVersionInfo')))
logger('Kodi version: {0}'.format(
xbmc.getInfoLabel('System.BuildVersion'))
)
@ -170,7 +132,7 @@ def log_exception(logger=None):
raise
@python_2_unicode_compatible
class Params(dict):
"""
Params(**kwargs)
@ -198,7 +160,7 @@ class Params(dict):
return '<Params {0}>'.format(super(Params, self).__str__())
@python_2_unicode_compatible
class Storage(MutableMapping):
"""
Storage(storage_dir, filename='storage.pcl')
@ -303,7 +265,7 @@ class Storage(MutableMapping):
return deepcopy(self._storage)
@python_2_unicode_compatible
class MemStorage(MutableMapping):
"""
MemStorage(storage_id)
@ -358,7 +320,7 @@ class MemStorage(MutableMapping):
:rtype: str
"""
lines = []
for key, val in iteritems(self):
for key, val in iter(self.items()):
lines.append('{0}: {1}'.format(repr(key), repr(val)))
return ', '.join(lines)
@ -367,7 +329,7 @@ class MemStorage(MutableMapping):
def __getitem__(self, 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)
if raw_item:
try:
@ -379,7 +341,7 @@ class MemStorage(MutableMapping):
def __setitem__(self, key, value):
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
self._window.setProperty(full_key, pickle.dumps(value, protocol=0))
if key != '__keys__':
@ -389,7 +351,7 @@ class MemStorage(MutableMapping):
def __delitem__(self, 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)
if item:
self._window.clearProperty(full_key)
@ -402,7 +364,7 @@ class MemStorage(MutableMapping):
def __contains__(self, 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)
return bool(item)
@ -413,7 +375,7 @@ class MemStorage(MutableMapping):
return len(self['__keys__'])
@python_2_unicode_compatible
class Addon(object):
"""
Base addon class
@ -430,9 +392,7 @@ class Addon(object):
:type id_: str
"""
self._addon = xbmcaddon.Addon(id_)
self._profile_dir = py2_decode(
translate_path(self._addon.getAddonInfo('profile'))
)
self._profile_dir = translate_path(self._addon.getAddonInfo('profile'))
self._ui_strings_map = None
if not os.path.exists(self._profile_dir):
os.mkdir(self._profile_dir)
@ -468,7 +428,7 @@ class Addon(object):
:return: path to the addon folder
:rtype: unicode
"""
return py2_decode(self._addon.getAddonInfo('path'))
return self._addon.getAddonInfo('path')
@property
def icon(self):
@ -633,7 +593,7 @@ class Addon(object):
:type convert: bool
:return: setting value
"""
setting = py2_decode(self._addon.getSetting(id_))
setting = self._addon.getSetting(id_)
if convert:
if setting == 'true':
return True # Convert boolean strings to bool
@ -664,7 +624,7 @@ class Addon(object):
value = 'true' if value else 'false'
elif not isinstance(value, basestring):
value = str(value)
self._addon.setSetting(id_, py2_encode(value))
self._addon.setSetting(id_, value)
def log(self, message, level=xbmc.LOGDEBUG):
"""
@ -677,7 +637,7 @@ class Addon(object):
:type level: int
"""
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
)
@ -957,7 +917,7 @@ class Addon(object):
return ui_strings
@python_2_unicode_compatible
class Plugin(Addon):
"""
Plugin class with URL query string routing.
@ -1016,9 +976,9 @@ class Plugin(Addon):
"""
raw_params = parse_qs(paramstring)
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
params[key] = py2_decode(param_value)
params[key] = param_value
return params
def get_url(self, plugin_url='', **kwargs):
@ -1125,7 +1085,7 @@ class Plugin(Addon):
return action_callable(self._params)
@python_2_unicode_compatible
class RoutedPlugin(Plugin):
"""
Plugin class that implements "pretty URL" routing similar to Flask and Bottle
@ -1224,10 +1184,10 @@ class RoutedPlugin(Plugin):
for arg, match in zip(args, matches):
pattern = pattern.replace(
match,
quote_plus(py2_encode(str(arg)))
quote_plus(str(arg))
)
# list allows to manipulate the dict during iteration
for key, value in list(iteritems(kwargs)):
for key, value in list(iter(kwargs.items())):
for match in matches[len(args):]:
match_string = match[1:-1]
@ -1237,7 +1197,7 @@ class RoutedPlugin(Plugin):
if key == match_string:
pattern = pattern.replace(
match, quote_plus(py2_encode(str(value)))
match, quote_plus(str(value))
)
del kwargs[key]
url = 'plugin://{0}{1}'.format(self.id, pattern)
@ -1367,7 +1327,7 @@ class RoutedPlugin(Plugin):
if match is not None:
kwargs = match.groupdict()
# list allows to manipulate the dict during iteration
for key, value in list(iteritems(kwargs)):
for key, value in list(iter(kwargs.items())):
if key.startswith('int__') or key.startswith('float__'):
del kwargs[key]
if key.startswith('int__'):
@ -1378,7 +1338,7 @@ class RoutedPlugin(Plugin):
value = float(value)
kwargs[key] = value
else:
kwargs[key] = py2_decode(unquote_plus(value))
kwargs[key] = unquote_plus(value)
self.log_debug(
'Calling {0} with kwargs {1}'.format(route, kwargs))
with log_exception(self.log_error):

376
main.py
View File

@ -1,11 +1,6 @@
#!/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
@ -13,15 +8,18 @@ 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, namedtuple
from collections.abc import MutableMapping
from collections import namedtuple
# Add the /lib folder to sys
sys.path.append(xbmcvfs.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib")))
import libsonic#Removed libsonic_extra
import dbutils
import libsonic
from simpleplugin import Plugin
from simpleplugin import Addon
@ -29,13 +27,19 @@ 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({})
db = None
cachetime = int(Addon().get_setting('cachetime'))
try:
enhancedInfo = Addon().get_setting('enhanced_info')
except:
enhancedInfo = False
db_filename = "subsonic_sqlite.db"
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):
@ -46,7 +50,6 @@ def popup(text, time=5000, image=None):
def get_connection():
global connection
if connection==None:
connected = False
# Create connection
@ -60,10 +63,13 @@ def get_connection():
insecure=Addon().get_setting('insecure'),
legacyAuth=Addon().get_setting('legacyauth'),
useGET=Addon().get_setting('useget'),
appName="Kodi-Subsonic",
)
connected = connection.ping()
except:
pass
except Exception as e:
plugin.log("Exception: %s"%e)
#except:
# pass
if connected==False:
popup('Connection error')
@ -135,12 +141,7 @@ def root(params):
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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()
@ -177,7 +178,7 @@ def menu_albums(params):
}
}
# Iterate through categories
# Iterate through albums
for menu_id in menus:
@ -201,12 +202,6 @@ def menu_albums(params):
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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()
@ -253,16 +248,9 @@ def menu_tracks(params):
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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()
@ -295,7 +283,6 @@ def browse_folders(params):
add_directory_items(create_listing(listing))
@plugin.action()
#@plugin.cached(cachetime) # cache (in minutes)
def browse_indexes(params):
# get connection
connection = get_connection()
@ -327,10 +314,10 @@ def browse_indexes(params):
))
@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==False:
return
@ -339,7 +326,7 @@ def list_directory(params):
# Get items
id = params.get('id')
items = walk_directory(id)
items = walk_directory(id, merge_artist)
# Iterate through items
for item in items:
@ -365,7 +352,6 @@ def list_directory(params):
))
@plugin.action()
#@plugin.cached(cachetime) # cache (in minutes)
def browse_library(params):
"""
List artists from the library (ID3 tags)
@ -400,16 +386,12 @@ def browse_library(params):
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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):
"""
@ -477,16 +459,12 @@ def list_albums(params):
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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')
@ -557,7 +535,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;
@ -575,24 +554,13 @@ def list_tracks(params):
#link_next = navigate_next(params)
#listing.append(link_next)
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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==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
@ -614,15 +582,10 @@ def list_playlists(params):
add_directory_items(create_listing(
listing,
#succeeded = True, #if False Kodi wont open a new listing and stays on the current level.
#update_listing = False, #if True, Kodi wont 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()
@ -653,7 +616,6 @@ def search(params):
add_directory_items(create_listing(listing))
@plugin.action()
def play_track(params):
@ -728,7 +690,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)
@ -744,7 +707,6 @@ def star_item(params):
#return did_action
return
@plugin.action()
def download_item(params):
@ -776,7 +738,6 @@ def download_item(params):
return did_action
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_playlist(item,params):
image = connection.getCoverArtUrl(item.get('coverArt'))
return {
@ -789,7 +750,7 @@ def get_entry_playlist(item,params):
menu_id= params.get('menu_id')
),
'info': {'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'info': {'music': {
'title': item.get('name'),
'count': item.get('songCount'),
'duration': item.get('duration'),
@ -797,28 +758,76 @@ def get_entry_playlist(item,params):
}}
}
#star (or unstar) an item
#@plugin.cached(cachetime) #cache (in minutes)
def get_image(item):
db = get_db()
image = None
try:
if Addon().get_setting('enhanced_info'):
image = db.get_value(item.get('id'), 'wikipedia_image')[0][0]
#print("Checking image type %s %s %s"%(item.get('id'), image, type(image)))
except IndexError:
print("Wiki image not available for artist %s" % item.get('name'))
except Exception as e:
print("Error getting image for %s %s"%(item.get('name'),e))
return image
if (image is None) or (image =='') or (image =='None'):
connection = get_connection()
#print("Using coverart tag from item %s is it same as %s ?"%(item.get('coverArt'),item.get('id')))
image = connection.getCoverArtUrl(item.get('coverArt'))
return image
def get_artist_info(artist_id, forced=False):
db = get_db()
artist_info = ""
print("Retreiving artist info for id: %s"%(artist_id))
#popup("Updating artist info\nplease wait")
try:
if enhancedInfo:
artist_info = db.get_value(artist_id, 'artist_info')[0][0]
artist_wiki = db.get_value(artist_id, 'wikipedia_extract')[0][0]
if(len(artist_info)<10):
print("Using wiki data")
artist_info = artist_wiki
else:
return ""
if(artist_info is None):
print("artist_info is None making empty string")
artist_info = ""
except IndexError:
print("Enhanced info not available for artist %s" % artist_id)
except Exception as e:
print("Error getting artist info from DB %s"%e)
return artist_info
def get_entry_artist(item,params):
image = connection.getCoverArtUrl(item.get('coverArt'))
image = get_image(item)
artist_info = get_artist_info(item.get('id'))
#print("Checking for value %s %s"%(artist_info, type(artist_info)))
if(artist_info is None or artist_info == 'None' or artist_info == ''):
artist_lbl = '%s' % (item.get('name'))
else:
artist_lbl = '%s - %s' % (item.get('name'),artist_info)
#print("Using label %s"%artist_lbl)
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'),
menu_id= params.get('menu_id')
),
'info': {
'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'music': {
'count': item.get('albumCount'),
'artist': item.get('name')
'artist': artist_lbl,
}
}
}
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_album(item, params):
image = connection.getCoverArtUrl(item.get('coverArt'))
@ -834,7 +843,7 @@ def get_entry_album(item, params):
menu_id= params.get('menu_id')
),
'info': {
'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'music': {
'count': item.get('songCount'),
'date': convert_date_from_iso8601(item.get('created')), #date added
'duration': item.get('duration'),
@ -861,9 +870,7 @@ def get_entry_album(item, params):
return entry
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_track(item,params):
menu_id = params.get('menu_id')
image = connection.getCoverArtUrl(item.get('coverArt'))
@ -878,7 +885,7 @@ def get_entry_track(item,params):
),
'is_playable': True,
'mime': item.get("contentType"),
'info': {'music': { #http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'info': {'music': {
'title': item.get('title'),
'album': item.get('album'),
'artist': item.get('artist'),
@ -909,7 +916,6 @@ def get_entry_track(item,params):
return entry
#@plugin.cached(cachetime) #cache (in minutes)
def get_starred_label(id,label):
if is_starred(id):
label = '[COLOR=FF00FF00]%s[/COLOR]' % label
@ -917,13 +923,12 @@ def get_starred_label(id,label):
def is_starred(id):
starred = stars_cache_get()
id = int(id)
#id = int(id)
if id in starred:
return True
else:
return False
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_track_label(item,hide_artist = False):
if hide_artist:
label = item.get('title', '<Unknown>')
@ -935,7 +940,6 @@ def get_entry_track_label(item,hide_artist = False):
return get_starred_label(item.get('id'),label)
#@plugin.cached(cachetime) #cache (in minutes)
def get_entry_album_label(item,hide_artist = False):
if hide_artist:
label = item.get('name', '<Unknown>')
@ -944,7 +948,6 @@ def get_entry_album_label(item,hide_artist = False):
item.get('name', '<Unknown>'))
return get_starred_label(item.get('id'),label)
#@plugin.cached(cachetime) #cache (in minutes)
def get_sort_methods(type,params):
#sort method for list types
#https://github.com/xbmc/xbmc/blob/master/xbmc/SortFileItem.h
@ -1020,49 +1023,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==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(): #Retrieving stars from cache is too slow, so load to local variable
global local_starred
plugin.log(len(local_starred))
if(len(local_starred)>0):
plugin.log('stars already loaded:')
plugin.log(local_starred)
return(local_starred)
else:
with plugin.get_storage() as storage:
local_starred = storage.get('starred_ids',set())
plugin.log('stars_cache_get:')
plugin.log(local_starred)
return local_starred
def stars_cache_get():
global local_starred
cache_refresh()
return local_starred
def navigate_next(params):
@ -1088,7 +1080,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):
@ -1270,11 +1266,9 @@ def download_album(id):
download_tracks(ids)
#@plugin.cached(cachetime) #cache (in minutes)
def create_listing(listing, succeeded=True, update_listing=False, cache_to_disk=False, sort_methods=None,view_mode=None, content=None, category=None):
return ListContext(listing, succeeded, update_listing, cache_to_disk,sort_methods, view_mode, content, category)
def resolve_url(path='', play_item=None, succeeded=True):
"""
Create and return a context dict to resolve a playable URL
@ -1293,7 +1287,6 @@ def resolve_url(path='', play_item=None, succeeded=True):
"""
return PlayContext(path, play_item, succeeded)
#@plugin.cached(cachetime) #cache (in minutes)
def create_list_item(item):
"""
Create an :class:`xbmcgui.ListItem` instance from an item dict
@ -1349,7 +1342,6 @@ def create_list_item(item):
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)
@ -1358,7 +1350,6 @@ def _set_resolved_url(context):
xbmcplugin.setResolvedUrl(plugin.handle, context.succeeded, list_item)
#@plugin.cached(cachetime) #cache (in minutes)
def add_directory_items(context):
plugin.log_debug('Creating listing from {0}'.format(str(context)))
if context.category is not None:
@ -1403,53 +1394,60 @@ 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:
yield from ()
for index in response["indexes"]["index"]:
for artist in index["artist"]:
yield artist
def walk_playlists():
"""
Request Subsonic's playlists and iterate over each item.
"""
response = connection.getPlaylists()
for child in response["playlists"]["playlist"]:
yield child
try:
for child in response["playlists"]["playlist"]:
yield child
except KeyError:
yield from ()
def walk_playlist(playlist_id):
"""
Request Subsonic's playlist items and iterate over each item.
"""
response = connection.getPlaylist(playlist_id)
for child in response["playlist"]["entry"]:
yield child
try:
for child in response["playlist"]["entry"]:
yield child
except KeyError:
yield from ()
def walk_folders():
response = connection.getMusicFolders()
for child in response["musicFolders"]["musicFolder"]:
yield child
try:
for child in response["musicFolders"]["musicFolder"]:
yield child
except KeyError:
yield from ()
def walk_directory(directory_id):
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 child.get("isDir"):
for child in walk_directory(child["id"]):
if merge_artist and child.get("isDir"):
for child in walk_directory(child["id"], merge_artist):
yield child
else:
yield child
except:
except KeyError:
yield from ()
def walk_artist(artist_id):
@ -1458,32 +1456,36 @@ def walk_artist(artist_id):
"""
response = connection.getArtist(artist_id)
for child in response["artist"]["album"]:
yield child
try:
for child in response["artist"]["album"]:
yield child
except KeyError:
yield from ()
def walk_artists():
"""
(ID3 tags)
Request all artists and iterate over each item.
"""
response = connection.getArtists()
for index in response["artists"]["index"]:
for artist in index["artist"]:
yield artist
try:
for index in response["artists"]["index"]:
for artist in index["artist"]:
yield artist
except KeyError:
yield from ()
def walk_genres():
"""
(ID3 tags)
Request all genres and iterate over each item.
"""
response = connection.getGenres()
for genre in response["genres"]["genre"]:
yield genre
try:
for genre in response["genres"]["genre"]:
yield genre
except KeyError:
yield from ()
def walk_albums(ltype, size=None, fromYear=None,toYear=None, genre=None, offset=None):
"""
@ -1512,34 +1514,46 @@ def walk_album(album_id):
(ID3 tags)
Request an album and iterate over each item.
"""
response = connection.getAlbum(album_id)
for song in response["album"]["song"]:
yield song
try:
for song in response["album"]["song"]:
yield song
except KeyError:
yield from ()
def walk_tracks_random(size=None, genre=None, fromYear=None,toYear=None):
"""
Request random songs by genre and/or year and iterate over each song.
"""
response = connection.getRandomSongs(
size=size, genre=genre, fromYear=fromYear, toYear=toYear)
for song in response["randomSongs"]["song"]:
yield song
try:
for song in response["randomSongs"]["song"]:
yield song
except KeyError:
yield from ()
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:
yield from ()
for song in response["starred"]["song"]:
yield song
def get_db():
global db
global db_filename
db_path = os.path.join(plugin.profile_dir, db_filename)
plugin.log("Getting DB %s"%db_path)
try:
db = dbutils.SQLiteDatabase(db_path)
except Exception as e:
plugin.log("Connecting to DB failed: %s"%e)
return db
# Start plugin from within Kodi.
if __name__ == "__main__":

View File

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

View File

@ -169,3 +169,14 @@ msgctxt "#30042"
msgid "port"
msgstr ""
msgctxt "#30043"
msgid "Merge album folders"
msgstr ""
msgctxt "#30044"
msgid "Scrobble to Last.FM"
msgstr ""
msgctxt "#30045"
msgid "Enhanced Information"
msgstr ""

View File

@ -168,3 +168,14 @@ msgctxt "#30042"
msgid "port"
msgstr ""
msgctxt "#30043"
msgid "Merge album folders"
msgstr ""
msgctxt "#30044"
msgid "Scrobble to Last.FM"
msgstr ""
msgctxt "#30045"
msgid "Enhanced Information"
msgstr ""

View File

@ -22,10 +22,13 @@
<setting label="30001" type="lsep" />
<setting label="30014" id="apiversion" type="labelenum" values="1.11.0|1.12.0|1.13.0|1.14.0|1.15.0|1.16.0" default="1.15.0"/>
<setting label="30016" id="insecure" type="bool" default="false" />
<setting label="30040" id="useget" type="bool" default="false" />
<setting label="30040" id="useget" type="bool" default="true" />
<setting label="30041" id="legacyauth" type="bool" default="false" />
<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" />
<setting label="30045" id="enhanced_info" type="bool" default="false" />
</category>
</settings>

220
service.py Normal file
View File

@ -0,0 +1,220 @@
import re
import xbmc
import xbmcvfs
import os
import xbmcaddon
import time
import random
# Add the /lib folder to sys
sys.path.append(xbmcvfs.translatePath(os.path.join(xbmcaddon.Addon("plugin.audio.subsonic").getAddonInfo("path"), "lib")))
import dbutils
import libsonic
import musicbrainz
connection = None
db = None
mb = None
serviceEnabled = True
refresh_age = 86400#3600 #multiple of random to age info records - needs some validation
check_freq_init = 30 #How often to run a refresh cycle - needs some validation - updates afer first load to
check_freq_refresh = 86400
db_filename = "subsonic_sqlite.db"
last_db_check = 0
current_artist_index = 0
from simpleplugin import Plugin
from simpleplugin import Addon
# Create plugin instance
plugin = Plugin()
try:
enhancedInfo = Addon().get_setting('enhanced_info')
except:
enhancedInfo = False
try:
scrobbleEnabled = Addon().get_setting('scrobble')
except:
scrobbleEnabled = False
scrobbled = False
def check_address_format():
address = Addon().get_setting('subsonic_url')
port = Addon().get_setting('port')
if len(address.split(":"))>2:
found_port = address.split(":")[2]
plugin.log("Found port %s in address %s, splitting"%(found_port, address))
plugin.log("Changing port from %s to %s"%(port, found_port))
Addon().set_setting('port', int(found_port))
Addon().set_setting('subsonic_url', "%s:%s"%(address.split(":")[0],address.split(":")[1]))
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
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'),
appName="Kodi-Subsonic",
)
connected = connection.ping()
except:
pass
if connected==False:
popup('Connection error')
return False
return connection
def get_mb():
global mb
mb = musicbrainz.MBConnection()
return mb
def get_db():
global db
db_path = os.path.join(plugin.profile_dir, db_filename)
plugin.log("Getting DB %s"%db_path)
try:
db = dbutils.SQLiteDatabase(db_path)
except Exception as e:
plugin.log("Connecting to DB failed: %s"%e)
return db
def refresh_artist(artist_id):
db = get_db()
connection = get_connection()
artist = connection.getArtist(artist_id)#['subsonic-response']
artist_name = artist['artist']['name']
db.update_value(artist_id, 'artist_name', artist_name)
artist_info = connection.getArtistInfo2(artist_id)
try:
artist_info = artist_info['artistInfo2']['biography']
#pattern = '<a target=\'_blank\' href="https://www.last.fm/music/Afrojack">Read more on Last.fm</a>
artist_info = re.sub('<a.*?</a>', '', artist_info)
plugin.log("subbed: %s"%artist_info)
except:
artist_info = ""
if(enhancedInfo):
mb = get_mb()
mb_artist_id = mb.get_artist_id(artist_name)
artist_image_url = mb.get_artist_image(mb_artist_id)
wikipedia_url = mb.get_artist_wikpedia(mb_artist_id)
artist_wiki_extract = mb.get_wiki_extract(wikipedia_url)
wikipedia_image = mb.get_wiki_image(wikipedia_url)
db.update_value(artist_id, 'artist_info', artist_info)
db.update_value(artist_id, 'mb_artist_id', mb_artist_id)
db.update_value(artist_id, 'image_url', artist_image_url)
db.update_value(artist_id, 'wikipedia_url', wikipedia_url)
db.update_value(artist_id, 'wikipedia_extract', artist_wiki_extract)
db.update_value(artist_id, 'wikipedia_image', wikipedia_image)
def check_db_status():
global last_db_check
global check_freq_init
global current_artist_index
try:
if(time.time()-check_freq_init > last_db_check): #Won't check on every run uses check_freq_init
plugin.log("DB check starting %s %s" % (time.time(), last_db_check))
db = get_db()
connection = get_connection()
response = connection.getArtists()
current_index_content = response["artists"]["index"][current_artist_index] #Completes refresh for alpha index
plugin.log("Starting info load for index %s"%current_index_content['name'])
for artist in current_index_content["artist"]:
artist_id = artist['id']
record_age = db.get_record_age(artist_id)
rnd_age = random.randint(1,111)*refresh_age
#plugin.log("Record age %s vs %s for %s"%(record_age, rnd_age, artist_id))
if(not record_age or (record_age > rnd_age)):
#plugin.log("Refreshing %s" % (artist_id))
refresh_artist(artist_id)
#plugin.log("Refresh complete for %s" % (artist_id))
plugin.log("Finished info loading for index %s"%current_index_content['name'])
current_artist_index+=1
if(current_artist_index>=len(response["artists"]["index"])): #init load complete go to daily check freq
plugin.log("Finished info loading for all alpha index")
current_artist_index=0
check_freq_init = check_freq_refresh
plugin.log("check_freq_init is now %s"%check_freq_init)
last_db_check = time.time()
except Exception as e:
plugin.log("Refresh check failed %s"%e)
return
def check_player_status():
global scrobbled
if (scrobbleEnabled and 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:
plugin.log ("Not a Subsonic track")
scrobbled = True
except Exception as e:
xbmc.log("Subsonic scrobble check failed %e"%e, xbmc.LOGINFO)
return
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")
xbmc.log("Scrobble failed", xbmc.LOGERROR)
return False
if __name__ == '__main__':
if serviceEnabled:
check_address_format()
monitor = xbmc.Monitor()
xbmc.log("Subsonic service started", xbmc.LOGINFO)
popup("Subsonic service started")
while not monitor.abortRequested():
if monitor.waitForAbort(10):
break
check_player_status()
check_db_status()
else:
plugin.log("Subsonic service not enabled")