mirror of
https://github.com/gordielachance/plugin.audio.subsonic
synced 2024-12-29 09:40:41 +01:00
2984 lines
109 KiB
Python
2984 lines
109 KiB
Python
"""
|
|
This file is part of py-sonic.
|
|
|
|
py-sonic is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
py-sonic is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with py-sonic. If not, see <http://www.gnu.org/licenses/>
|
|
"""
|
|
|
|
from libsonic.errors import *
|
|
from netrc import netrc
|
|
from hashlib import md5
|
|
import urllib.request
|
|
import urllib.error
|
|
import urllib.parse
|
|
from http import client as http_client
|
|
from urllib.parse import urlencode
|
|
from io import StringIO
|
|
|
|
import json
|
|
import logging
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import os
|
|
import xbmc
|
|
|
|
API_VERSION = '1.16.1'
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class HTTPSConnectionChain(http_client.HTTPSConnection):
|
|
def _create_sock(self):
|
|
sock = socket.create_connection((self.host, self.port), self.timeout)
|
|
if self._tunnel_host:
|
|
self.sock = sock
|
|
self._tunnel()
|
|
return sock
|
|
|
|
def connect(self):
|
|
sock = self._create_sock()
|
|
try:
|
|
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, context=self._context)
|
|
|
|
# install opener
|
|
urllib.request.install_opener(urllib.request.build_opener(HTTPSHandlerChain()))
|
|
|
|
class PysHTTPRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""
|
|
This class is used to override the default behavior of the
|
|
HTTPRedirectHandler, which does *not* redirect POST data
|
|
"""
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
m = req.get_method()
|
|
if (code in (301, 302, 303, 307) and m in ("GET", "HEAD")
|
|
or code in (301, 302, 303) and m == "POST"):
|
|
newurl = newurl.replace(' ', '%20')
|
|
newheaders = dict((k, v) for k, v in list(req.headers.items())
|
|
if k.lower() not in ("content-length", "content-type")
|
|
)
|
|
data = None
|
|
if req.data:
|
|
data = req.data
|
|
return urllib.request.Request(newurl,
|
|
data=data,
|
|
headers=newheaders,
|
|
origin_req_host=req.origin_req_host,
|
|
unverifiable=True)
|
|
else:
|
|
raise urllib.error.HTTPError(
|
|
req.get_full_url(),
|
|
code,
|
|
msg,
|
|
headers,
|
|
fp,
|
|
)
|
|
|
|
class Connection(object):
|
|
def __init__(self, baseUrl, username=None, password=None, port=4040,
|
|
serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION,
|
|
insecure=False, useNetrc=None, legacyAuth=False, useGET=True):
|
|
"""
|
|
This will create a connection to your subsonic server
|
|
|
|
baseUrl:str The base url for your server. Be sure to use
|
|
"https" for SSL connections. If you are using
|
|
a port other than the default 4040, be sure to
|
|
specify that with the port argument. Do *not*
|
|
append it here.
|
|
|
|
ex: http://subsonic.example.com
|
|
|
|
If you are running subsonic under a different
|
|
path, specify that with the "serverPath" arg,
|
|
*not* here. For example, if your subsonic
|
|
lives at:
|
|
|
|
https://mydomain.com:8080/path/to/subsonic/rest
|
|
|
|
You would set the following:
|
|
|
|
baseUrl = "https://mydomain.com"
|
|
port = 8080
|
|
serverPath = "/path/to/subsonic/rest"
|
|
username:str The username to use for the connection. This
|
|
can be None if `useNetrc' is True (and you
|
|
have a valid entry in your netrc file)
|
|
password:str The password to use for the connection. This
|
|
can be None if `useNetrc' is True (and you
|
|
have a valid entry in your netrc file)
|
|
port:int The port number to connect on. The default for
|
|
unencrypted subsonic connections is 4040
|
|
serverPath:str The base resource path for the subsonic views.
|
|
This is useful if you have your subsonic server
|
|
behind a proxy and the path that you are proxying
|
|
is different from the default of '/rest'.
|
|
Ex:
|
|
serverPath='/path/to/subs'
|
|
|
|
The full url that would be built then would be
|
|
(assuming defaults and using "example.com" and
|
|
you are using the "ping" view):
|
|
|
|
http://example.com:4040/path/to/subs/ping.view
|
|
appName:str The name of your application.
|
|
apiVersion:str The API version you wish to use for your
|
|
application. Subsonic will throw an error if you
|
|
try to use/send an api version higher than what
|
|
the server supports. See the Subsonic API docs
|
|
to find the Subsonic version -> API version table.
|
|
This is useful if you are connecting to an older
|
|
version of Subsonic.
|
|
insecure:bool This will allow you to use self signed
|
|
certificates when connecting if set to True.
|
|
useNetrc:str|bool You can either specify a specific netrc
|
|
formatted file or True to use your default
|
|
netrc file ($HOME/.netrc).
|
|
legacyAuth:bool Use pre-1.13.0 API version authentication
|
|
useGET:bool Use a GET request instead of the default POST
|
|
request. This is not recommended as request
|
|
URLs can get very long with some API calls
|
|
"""
|
|
|
|
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 = urllib.parse.urlparse(self._baseUrl)
|
|
self._baseUrl = "%s://%s" % (parts.scheme, parts.hostname)
|
|
self._hostname = parts.hostname
|
|
self._serverPath = parts.path.strip('/') + '/rest'
|
|
else:
|
|
self._serverPath = serverPath.strip('/')
|
|
self._username = username
|
|
self._rawPass = password
|
|
self._legacyAuth = legacyAuth
|
|
self._useGET = useGET
|
|
|
|
self._netrc = None
|
|
if useNetrc is not None:
|
|
self._process_netrc(useNetrc)
|
|
elif username is None or password is None:
|
|
raise CredentialError('You must specify either a username/password '
|
|
'combination or "useNetrc" must be either True or a string '
|
|
'representing a path to a netrc file')
|
|
|
|
self._port = int(port)
|
|
self._apiVersion = apiVersion
|
|
self._appName = appName
|
|
self._insecure = insecure
|
|
self._opener = self._getOpener(self._username, self._rawPass)
|
|
|
|
# Properties
|
|
def setBaseUrl(self, url):
|
|
self._baseUrl = url
|
|
self._opener = self._getOpener(self._username, self._rawPass)
|
|
baseUrl = property(lambda s: s._baseUrl, setBaseUrl)
|
|
|
|
def setPort(self, port):
|
|
self._port = int(port)
|
|
port = property(lambda s: s._port, setPort)
|
|
|
|
def setUsername(self, username):
|
|
self._username = username
|
|
self._opener = self._getOpener(self._username, self._rawPass)
|
|
username = property(lambda s: s._username, setUsername)
|
|
|
|
def setPassword(self, password):
|
|
self._rawPass = password
|
|
# Redo the opener with the new creds
|
|
self._opener = self._getOpener(self._username, self._rawPass)
|
|
password = property(lambda s: s._rawPass, setPassword)
|
|
|
|
apiVersion = property(lambda s: s._apiVersion)
|
|
|
|
def setAppName(self, appName):
|
|
self._appName = appName
|
|
appName = property(lambda s: s._appName, setAppName)
|
|
|
|
def setServerPath(self, path):
|
|
self._serverPath = path.strip('/')
|
|
serverPath = property(lambda s: s._serverPath, setServerPath)
|
|
|
|
def setInsecure(self, insecure):
|
|
self._insecure = insecure
|
|
insecure = property(lambda s: s._insecure, setInsecure)
|
|
|
|
def setLegacyAuth(self, lauth):
|
|
self._legacyAuth = lauth
|
|
legacyAuth = property(lambda s: s._legacyAuth, setLegacyAuth)
|
|
|
|
def setGET(self, get):
|
|
self._useGET = get
|
|
useGET = property(lambda s: s._useGET, setGET)
|
|
|
|
# API methods
|
|
def ping(self):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns a boolean True if the server is alive, False otherwise
|
|
"""
|
|
methodName = 'ping'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
xbmc.log("Pinging %s"%str(req.full_url),xbmc.LOGDEBUG)
|
|
try:
|
|
res = self._doInfoReq(req)
|
|
except Exception as e:
|
|
print("Ping failed %s"%e)
|
|
return False
|
|
if res['status'] == 'ok':
|
|
return True
|
|
elif res['status'] == 'failed':
|
|
exc = getExcByCode(res['error']['code'])
|
|
raise exc(res['error']['message'])
|
|
return False
|
|
|
|
def getLicense(self):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Gets details related to the software license
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'license': {u'date': u'2010-05-21T11:14:39',
|
|
u'email': u'email@example.com',
|
|
u'key': u'12345678901234567890123456789012',
|
|
u'valid': True},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getLicense'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getScanStatus(self):
|
|
"""
|
|
since: 1.15.0
|
|
|
|
returns the current status for media library scanning.
|
|
takes no extra parameters.
|
|
|
|
returns a dict like the following:
|
|
|
|
{'status': 'ok', 'version': '1.15.0',
|
|
'scanstatus': {'scanning': true, 'count': 4680}}
|
|
|
|
'count' is the total number of items to be scanned
|
|
"""
|
|
methodName = 'getScanStatus'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def startScan(self):
|
|
"""
|
|
since: 1.15.0
|
|
|
|
Initiates a rescan of the media libraries.
|
|
Takes no extra parameters.
|
|
|
|
returns a dict like the following:
|
|
|
|
{'status': 'ok', 'version': '1.15.0',
|
|
'scanstatus': {'scanning': true, 'count': 0}}
|
|
|
|
'scanning' changes to false when a scan is complete
|
|
'count' starts a 0 and ends at the total number of items scanned
|
|
|
|
"""
|
|
methodName = 'startScan'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getMusicFolders(self):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns all configured music folders
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'musicFolders': {u'musicFolder': [{u'id': 0, u'name': u'folder1'},
|
|
{u'id': 1, u'name': u'folder2'},
|
|
{u'id': 2, u'name': u'folder3'}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getMusicFolders'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getNowPlaying(self):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns what is currently being played by all users
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'nowPlaying': {u'entry': {u'album': u"Jazz 'Round Midnight 12",
|
|
u'artist': u'Astrud Gilberto',
|
|
u'bitRate': 172,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': u'98349284',
|
|
u'duration': 325,
|
|
u'genre': u'Jazz',
|
|
u'id': u'2424324',
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'minutesAgo': 0,
|
|
u'parent': u'542352',
|
|
u'path': u"Astrud Gilberto/Jazz 'Round Midnight 12/01 - The Girl From Ipanema.mp3",
|
|
u'playerId': 1,
|
|
u'size': 7004089,
|
|
u'suffix': u'mp3',
|
|
u'title': u'The Girl From Ipanema',
|
|
u'track': 1,
|
|
u'username': u'user1',
|
|
u'year': 1996}},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getNowPlaying'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getIndexes(self, musicFolderId=None, ifModifiedSince=0):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns an indexed structure of all artists
|
|
|
|
musicFolderId:int If this is specified, it will only return
|
|
artists for the given folder ID from
|
|
the getMusicFolders call
|
|
ifModifiedSince:int If specified, return a result if the artist
|
|
collection has changed since the given
|
|
unix timestamp
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'indexes': {u'index': [{u'artist': [{u'id': u'29834728934',
|
|
u'name': u'A Perfect Circle'},
|
|
{u'id': u'238472893',
|
|
u'name': u'A Small Good Thing'},
|
|
{u'id': u'9327842983',
|
|
u'name': u'A Tribe Called Quest'},
|
|
{u'id': u'29348729874',
|
|
u'name': u'A-Teens, The'},
|
|
{u'id': u'298472938',
|
|
u'name': u'ABA STRUCTURE'}],
|
|
u'lastModified': 1303318347000L},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getIndexes'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'musicFolderId': musicFolderId,
|
|
'ifModifiedSince': self._ts2milli(ifModifiedSince)})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
self._fixLastModified(res)
|
|
return res
|
|
|
|
def getMusicDirectory(self, mid):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns a listing of all files in a music directory. Typically used
|
|
to get a list of albums for an artist or list of songs for an album.
|
|
|
|
mid:str The string ID value which uniquely identifies the
|
|
folder. Obtained via calls to getIndexes or
|
|
getMusicDirectory. REQUIRED
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'directory': {u'child': [{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'223484',
|
|
u'id': u'329084',
|
|
u'isDir': True,
|
|
u'parent': u'234823940',
|
|
u'title': u'Beats, Rhymes And Life'},
|
|
{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'234823794',
|
|
u'id': u'238472893',
|
|
u'isDir': True,
|
|
u'parent': u'2308472938',
|
|
u'title': u'Midnight Marauders'},
|
|
{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'39284792374',
|
|
u'id': u'983274892',
|
|
u'isDir': True,
|
|
u'parent': u'9823749',
|
|
u'title': u"People's Instinctive Travels And The Paths Of Rhythm"},
|
|
{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'289347293',
|
|
u'id': u'3894723934',
|
|
u'isDir': True,
|
|
u'parent': u'9832942',
|
|
u'title': u'The Anthology'},
|
|
{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'923847923',
|
|
u'id': u'29834729',
|
|
u'isDir': True,
|
|
u'parent': u'2934872893',
|
|
u'title': u'The Love Movement'},
|
|
{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'9238742893',
|
|
u'id': u'238947293',
|
|
u'isDir': True,
|
|
u'parent': u'9432878492',
|
|
u'title': u'The Low End Theory'}],
|
|
u'id': u'329847293',
|
|
u'name': u'A Tribe Called Quest'},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getMusicDirectory'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName, {'id': mid})
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def search(self, artist=None, album=None, title=None, any=None,
|
|
count=20, offset=0, newerThan=None):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
DEPRECATED SINCE API 1.4.0! USE search2() INSTEAD!
|
|
|
|
Returns a listing of files matching the given search criteria.
|
|
Supports paging with offset
|
|
|
|
artist:str Search for artist
|
|
album:str Search for album
|
|
title:str Search for title of song
|
|
any:str Search all fields
|
|
count:int Max number of results to return [default: 20]
|
|
offset:int Search result offset. For paging [default: 0]
|
|
newerThan:int Return matches newer than this timestamp
|
|
"""
|
|
if artist == album == title == any == None:
|
|
raise ArgumentError('Invalid search. You must supply search '
|
|
'criteria')
|
|
methodName = 'search'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'artist': artist, 'album': album,
|
|
'title': title, 'any': any, 'count': count, 'offset': offset,
|
|
'newerThan': self._ts2milli(newerThan)})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def search2(self, query, artistCount=20, artistOffset=0, albumCount=20,
|
|
albumOffset=0, songCount=20, songOffset=0, musicFolderId=None):
|
|
"""
|
|
since: 1.4.0
|
|
|
|
Returns albums, artists and songs matching the given search criteria.
|
|
Supports paging through the result.
|
|
|
|
query:str The search query
|
|
artistCount:int Max number of artists to return [default: 20]
|
|
artistOffset:int Search offset for artists (for paging) [default: 0]
|
|
albumCount:int Max number of albums to return [default: 20]
|
|
albumOffset:int Search offset for albums (for paging) [default: 0]
|
|
songCount:int Max number of songs to return [default: 20]
|
|
songOffset:int Search offset for songs (for paging) [default: 0]
|
|
musicFolderId:int Only return results from the music folder
|
|
with the given ID. See getMusicFolders
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'searchResult2': {u'album': [{u'artist': u'A Tribe Called Quest',
|
|
u'coverArt': u'289347',
|
|
u'id': u'32487298',
|
|
u'isDir': True,
|
|
u'parent': u'98374289',
|
|
u'title': u'The Love Movement'}],
|
|
u'artist': [{u'id': u'2947839',
|
|
u'name': u'A Tribe Called Quest'},
|
|
{u'id': u'239847239',
|
|
u'name': u'Tribe'}],
|
|
u'song': [{u'album': u'Beats, Rhymes And Life',
|
|
u'artist': u'A Tribe Called Quest',
|
|
u'bitRate': 224,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': u'329847',
|
|
u'duration': 148,
|
|
u'genre': u'default',
|
|
u'id': u'3928472893',
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': u'23984728394',
|
|
u'path': u'A Tribe Called Quest/Beats, Rhymes And Life/A Tribe Called Quest - Beats, Rhymes And Life - 03 - Motivators.mp3',
|
|
u'size': 4171913,
|
|
u'suffix': u'mp3',
|
|
u'title': u'Motivators',
|
|
u'track': 3}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'search2'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'query': query, 'artistCount': artistCount,
|
|
'artistOffset': artistOffset, 'albumCount': albumCount,
|
|
'albumOffset': albumOffset, 'songCount': songCount,
|
|
'songOffset': songOffset, 'musicFolderId': musicFolderId})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def search3(self, query, artistCount=20, artistOffset=0, albumCount=20,
|
|
albumOffset=0, songCount=20, songOffset=0, musicFolderId=None):
|
|
"""
|
|
since: 1.8.0
|
|
|
|
Works the same way as search2, but uses ID3 tags for
|
|
organization
|
|
|
|
query:str The search query
|
|
artistCount:int Max number of artists to return [default: 20]
|
|
artistOffset:int Search offset for artists (for paging) [default: 0]
|
|
albumCount:int Max number of albums to return [default: 20]
|
|
albumOffset:int Search offset for albums (for paging) [default: 0]
|
|
songCount:int Max number of songs to return [default: 20]
|
|
songOffset:int Search offset for songs (for paging) [default: 0]
|
|
musicFolderId:int Only return results from the music folder
|
|
with the given ID. See getMusicFolders
|
|
|
|
Returns a dict like the following (search for "Tune Yards":
|
|
{u'searchResult3': {u'album': [{u'artist': u'Tune-Yards',
|
|
u'artistId': 1,
|
|
u'coverArt': u'al-7',
|
|
u'created': u'2012-01-30T12:35:33',
|
|
u'duration': 3229,
|
|
u'id': 7,
|
|
u'name': u'Bird-Brains',
|
|
u'songCount': 13},
|
|
{u'artist': u'Tune-Yards',
|
|
u'artistId': 1,
|
|
u'coverArt': u'al-8',
|
|
u'created': u'2011-03-22T15:08:00',
|
|
u'duration': 2531,
|
|
u'id': 8,
|
|
u'name': u'W H O K I L L',
|
|
u'songCount': 10}],
|
|
u'artist': {u'albumCount': 2,
|
|
u'coverArt': u'ar-1',
|
|
u'id': 1,
|
|
u'name': u'Tune-Yards'},
|
|
u'song': [{u'album': u'Bird-Brains',
|
|
u'albumId': 7,
|
|
u'artist': u'Tune-Yards',
|
|
u'artistId': 1,
|
|
u'bitRate': 160,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': 105,
|
|
u'created': u'2012-01-30T12:35:33',
|
|
u'duration': 328,
|
|
u'genre': u'Lo-Fi',
|
|
u'id': 107,
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': 105,
|
|
u'path': u'Tune Yards/Bird-Brains/10-tune-yards-fiya.mp3',
|
|
u'size': 6588498,
|
|
u'suffix': u'mp3',
|
|
u'title': u'Fiya',
|
|
u'track': 10,
|
|
u'type': u'music',
|
|
u'year': 2009}]},
|
|
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'search3'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'query': query, 'artistCount': artistCount,
|
|
'artistOffset': artistOffset, 'albumCount': albumCount,
|
|
'albumOffset': albumOffset, 'songCount': songCount,
|
|
'songOffset': songOffset, 'musicFolderId': musicFolderId})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getPlaylists(self, username=None):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns the ID and name of all saved playlists
|
|
The "username" option was added in 1.8.0.
|
|
|
|
username:str If specified, return playlists for this user
|
|
rather than for the authenticated user. The
|
|
authenticated user must have admin role
|
|
if this parameter is used
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'playlists': {u'playlist': [{u'id': u'62656174732e6d3375',
|
|
u'name': u'beats'},
|
|
{u'id': u'766172696574792e6d3375',
|
|
u'name': u'variety'}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getPlaylists'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'username': username})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getPlaylist(self, pid):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns a listing of files in a saved playlist
|
|
|
|
id:str The ID of the playlist as returned in getPlaylists()
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'playlist': {u'entry': {u'album': u'The Essential Bob Dylan',
|
|
u'artist': u'Bob Dylan',
|
|
u'bitRate': 32,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': u'2983478293',
|
|
u'duration': 984,
|
|
u'genre': u'Classic Rock',
|
|
u'id': u'982739428',
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': u'98327428974',
|
|
u'path': u"Bob Dylan/Essential Bob Dylan Disc 1/Bob Dylan - The Essential Bob Dylan - 03 - The Times They Are A-Changin'.mp3",
|
|
u'size': 3921899,
|
|
u'suffix': u'mp3',
|
|
u'title': u"The Times They Are A-Changin'",
|
|
u'track': 3},
|
|
u'id': u'44796c616e2e6d3375',
|
|
u'name': u'Dylan'},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getPlaylist'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName, {'id': pid})
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def createPlaylist(self, playlistId=None, name=None, songIds=[]):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
Creates OR updates a playlist. If updating the list, the
|
|
playlistId is required. If creating a list, the name is required.
|
|
|
|
playlistId:str The ID of the playlist to UPDATE
|
|
name:str The name of the playlist to CREATE
|
|
songIds:list The list of songIds to populate the list with in
|
|
either create or update mode. Note that this
|
|
list will replace the existing list if updating
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'createPlaylist'
|
|
viewName = '%s.view' % methodName
|
|
|
|
if playlistId == name == None:
|
|
raise ArgumentError('You must supply either a playlistId or a name')
|
|
if playlistId is not None and name is not None:
|
|
raise ArgumentError('You can only supply either a playlistId '
|
|
'OR a name, not both')
|
|
|
|
q = self._getQueryDict({'playlistId': playlistId, 'name': name})
|
|
|
|
req = self._getRequestWithList(viewName, 'songId', songIds, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deletePlaylist(self, pid):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
Deletes a saved playlist
|
|
|
|
pid:str ID of the playlist to delete, as obtained by getPlaylists
|
|
|
|
Returns a dict like the following:
|
|
|
|
"""
|
|
methodName = 'deletePlaylist'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName, {'id': pid})
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def download(self, sid):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Downloads a given music file.
|
|
|
|
sid:str The ID of the music file to download.
|
|
|
|
Returns the file-like object for reading or raises an exception
|
|
on error
|
|
"""
|
|
methodName = 'download'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName, {'id': sid})
|
|
res = self._doBinReq(req)
|
|
if isinstance(res, dict):
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def stream(self, sid, maxBitRate=0, tformat=None, timeOffset=None,
|
|
size=None, estimateContentLength=False, converted=False):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Downloads a given music file.
|
|
|
|
sid:str The ID of the music file to download.
|
|
maxBitRate:int (since: 1.2.0) If specified, the server will
|
|
attempt to limit the bitrate to this value, in
|
|
kilobits per second. If set to zero (default), no limit
|
|
is imposed. Legal values are: 0, 32, 40, 48, 56, 64,
|
|
80, 96, 112, 128, 160, 192, 224, 256 and 320.
|
|
tformat:str (since: 1.6.0) Specifies the target format
|
|
(e.g. "mp3" or "flv") in case there are multiple
|
|
applicable transcodings (since: 1.9.0) You can use
|
|
the special value "raw" to disable transcoding
|
|
timeOffset:int (since: 1.6.0) Only applicable to video
|
|
streaming. Start the stream at the given
|
|
offset (in seconds) into the video
|
|
size:str (since: 1.6.0) The requested video size in
|
|
WxH, for instance 640x480
|
|
estimateContentLength:bool (since: 1.8.0) If set to True,
|
|
the HTTP Content-Length header
|
|
will be set to an estimated
|
|
value for trancoded media
|
|
converted:bool (since: 1.14.0) Only applicable to video streaming.
|
|
Subsonic can optimize videos for streaming by
|
|
converting them to MP4. If a conversion exists for
|
|
the video in question, then setting this parameter
|
|
to "true" will cause the converted video to be
|
|
returned instead of the original.
|
|
|
|
Returns the file-like object for reading or raises an exception
|
|
on error
|
|
"""
|
|
methodName = 'stream'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate,
|
|
'format': tformat, 'timeOffset': timeOffset, 'size': size,
|
|
'estimateContentLength': estimateContentLength,
|
|
'converted': converted})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doBinReq(req)
|
|
if isinstance(res, dict):
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def streamUrl(self, sid, maxBitRate=0, tformat=None, timeOffset=None,
|
|
size=None, estimateContentLength=False, converted=False):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Downloads a given music file.
|
|
|
|
sid:str The ID of the music file to download.
|
|
maxBitRate:int (since: 1.2.0) If specified, the server will
|
|
attempt to limit the bitrate to this value, in
|
|
kilobits per second. If set to zero (default), no limit
|
|
is imposed. Legal values are: 0, 32, 40, 48, 56, 64,
|
|
80, 96, 112, 128, 160, 192, 224, 256 and 320.
|
|
tformat:str (since: 1.6.0) Specifies the target format
|
|
(e.g. "mp3" or "flv") in case there are multiple
|
|
applicable transcodings (since: 1.9.0) You can use
|
|
the special value "raw" to disable transcoding
|
|
timeOffset:int (since: 1.6.0) Only applicable to video
|
|
streaming. Start the stream at the given
|
|
offset (in seconds) into the video
|
|
size:str (since: 1.6.0) The requested video size in
|
|
WxH, for instance 640x480
|
|
estimateContentLength:bool (since: 1.8.0) If set to True,
|
|
the HTTP Content-Length header
|
|
will be set to an estimated
|
|
value for trancoded media
|
|
converted:bool (since: 1.14.0) Only applicable to video streaming.
|
|
Subsonic can optimize videos for streaming by
|
|
converting them to MP4. If a conversion exists for
|
|
the video in question, then setting this parameter
|
|
to "true" will cause the converted video to be
|
|
returned instead of the original.
|
|
|
|
Returns the file-like object for reading or raises an exception
|
|
on error
|
|
"""
|
|
methodName = 'stream'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate,
|
|
'format': tformat, 'timeOffset': timeOffset, 'size': size,
|
|
'estimateContentLength': estimateContentLength,
|
|
'converted': converted})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
#xbmc.log("Requesting %s"%str(req.full_url),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):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns a cover art image
|
|
|
|
aid:str ID string for the cover art image to download
|
|
size:int If specified, scale image to this size
|
|
|
|
Returns the file-like object for reading or raises an exception
|
|
on error
|
|
"""
|
|
methodName = 'getCoverArt'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': aid, 'size': size})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doBinReq(req)
|
|
if isinstance(res, dict):
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getCoverArtUrl(self, aid, size=None):
|
|
"""
|
|
since: 1.0.0
|
|
|
|
Returns a cover art image
|
|
|
|
aid:str ID string for the cover art image to download
|
|
size:int If specified, scale image to this size
|
|
|
|
Returns the file-like object for reading or raises an exception
|
|
on error
|
|
"""
|
|
methodName = 'getCoverArt'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': aid, 'size': size})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
#xbmc.log("Requesting %s"%str(req.full_url),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):
|
|
"""
|
|
since: 1.5.0
|
|
|
|
"Scrobbles" a given music file on last.fm. Requires that the user
|
|
has set this up.
|
|
|
|
Since 1.8.0 you may specify multiple id (and optionally time)
|
|
parameters to scrobble multiple files.
|
|
|
|
Since 1.11.0 this method will also update the play count and
|
|
last played timestamp for the song and album. It will also make
|
|
the song appear in the "Now playing" page in the web app, and
|
|
appear in the list of songs returned by getNowPlaying
|
|
|
|
sid:str The ID of the file to scrobble
|
|
submission:bool Whether this is a "submission" or a "now playing"
|
|
notification
|
|
listenTime:int (Since 1.8.0) The time (unix timestamp) at
|
|
which the song was listened to.
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'scrobble'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': sid, 'submission': submission,
|
|
'time': self._ts2milli(listenTime)})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def changePassword(self, username, password):
|
|
"""
|
|
since: 1.1.0
|
|
|
|
Changes the password of an existing Subsonic user. Note that the
|
|
user performing this must have admin privileges
|
|
|
|
username:str The username whose password is being changed
|
|
password:str The new password of the user
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'changePassword'
|
|
viewName = '%s.view' % methodName
|
|
hexPass = 'enc:%s' % self._hexEnc(password)
|
|
|
|
# There seems to be an issue with some subsonic implementations
|
|
# not recognizing the "enc:" precursor to the encoded password and
|
|
# encodes the whole "enc:<hex>" as the password. Weird.
|
|
#q = {'username': username, 'password': hexPass.lower()}
|
|
q = {'username': username, 'password': password}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getUser(self, username):
|
|
"""
|
|
since: 1.3.0
|
|
|
|
Get details about a given user, including which auth roles it has.
|
|
Can be used to enable/disable certain features in the client, such
|
|
as jukebox control
|
|
|
|
username:str The username to retrieve. You can only retrieve
|
|
your own user unless you have admin privs.
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'user': {u'adminRole': False,
|
|
u'commentRole': False,
|
|
u'coverArtRole': False,
|
|
u'downloadRole': True,
|
|
u'jukeboxRole': False,
|
|
u'playlistRole': True,
|
|
u'podcastRole': False,
|
|
u'settingsRole': True,
|
|
u'streamRole': True,
|
|
u'uploadRole': True,
|
|
u'username': u'test'},
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getUser'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'username': username}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getUsers(self):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Gets a list of users
|
|
|
|
returns a dict like the following
|
|
|
|
{u'status': u'ok',
|
|
u'users': {u'user': [{u'adminRole': True,
|
|
u'commentRole': True,
|
|
u'coverArtRole': True,
|
|
u'downloadRole': True,
|
|
u'jukeboxRole': True,
|
|
u'playlistRole': True,
|
|
u'podcastRole': True,
|
|
u'scrobblingEnabled': True,
|
|
u'settingsRole': True,
|
|
u'shareRole': True,
|
|
u'streamRole': True,
|
|
u'uploadRole': True,
|
|
u'username': u'user1'},
|
|
...
|
|
...
|
|
]},
|
|
u'version': u'1.10.2',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getUsers'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def createUser(self, username, password, email,
|
|
ldapAuthenticated=False, adminRole=False, settingsRole=True,
|
|
streamRole=True, jukeboxRole=False, downloadRole=False,
|
|
uploadRole=False, playlistRole=False, coverArtRole=False,
|
|
commentRole=False, podcastRole=False, shareRole=False,
|
|
videoConversionRole=False, musicFolderId=None):
|
|
"""
|
|
since: 1.1.0
|
|
|
|
Creates a new subsonic user, using the parameters defined. See the
|
|
documentation at http://subsonic.org for more info on all the roles.
|
|
|
|
username:str The username of the new user
|
|
password:str The password for the new user
|
|
email:str The email of the new user
|
|
<For info on the boolean roles, see http://subsonic.org for more info>
|
|
musicFolderId:int These are the only folders the user has access to
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'createUser'
|
|
viewName = '%s.view' % methodName
|
|
hexPass = 'enc:%s' % self._hexEnc(password)
|
|
|
|
q = self._getQueryDict({
|
|
'username': username, 'password': hexPass, 'email': email,
|
|
'ldapAuthenticated': ldapAuthenticated, 'adminRole': adminRole,
|
|
'settingsRole': settingsRole, 'streamRole': streamRole,
|
|
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
|
|
'uploadRole': uploadRole, 'playlistRole': playlistRole,
|
|
'coverArtRole': coverArtRole, 'commentRole': commentRole,
|
|
'podcastRole': podcastRole, 'shareRole': shareRole,
|
|
'videoConversionRole': videoConversionRole,
|
|
'musicFolderId': musicFolderId
|
|
})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def updateUser(self, username, password=None, email=None,
|
|
ldapAuthenticated=False, adminRole=False, settingsRole=True,
|
|
streamRole=True, jukeboxRole=False, downloadRole=False,
|
|
uploadRole=False, playlistRole=False, coverArtRole=False,
|
|
commentRole=False, podcastRole=False, shareRole=False,
|
|
videoConversionRole=False, musicFolderId=None, maxBitRate=0):
|
|
"""
|
|
since 1.10.1
|
|
|
|
Modifies an existing Subsonic user.
|
|
|
|
username:str The username of the user to update.
|
|
musicFolderId:int Only return results from the music folder
|
|
with the given ID. See getMusicFolders
|
|
maxBitRate:int The max bitrate for the user. 0 is unlimited
|
|
|
|
All other args are the same as create user and you can update
|
|
whatever item you wish to update for the given username.
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'updateUser'
|
|
viewName = '%s.view' % methodName
|
|
if password is not None:
|
|
password = 'enc:%s' % self._hexEnc(password)
|
|
q = self._getQueryDict({'username': username, 'password': password,
|
|
'email': email, 'ldapAuthenticated': ldapAuthenticated,
|
|
'adminRole': adminRole,
|
|
'settingsRole': settingsRole, 'streamRole': streamRole,
|
|
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
|
|
'uploadRole': uploadRole, 'playlistRole': playlistRole,
|
|
'coverArtRole': coverArtRole, 'commentRole': commentRole,
|
|
'podcastRole': podcastRole, 'shareRole': shareRole,
|
|
'videoConversionRole': videoConversionRole,
|
|
'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate
|
|
})
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deleteUser(self, username):
|
|
"""
|
|
since: 1.3.0
|
|
|
|
Deletes an existing Subsonic user. Of course, you must have admin
|
|
rights for this.
|
|
|
|
username:str The username of the user to delete
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'deleteUser'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'username': username}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getChatMessages(self, since=1):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
Returns the current visible (non-expired) chat messages.
|
|
|
|
since:int Only return messages newer than this timestamp
|
|
|
|
NOTE: All times returned are in MILLISECONDS since the Epoch, not
|
|
seconds!
|
|
|
|
Returns a dict like the following:
|
|
{u'chatMessages': {u'chatMessage': {u'message': u'testing 123',
|
|
u'time': 1303411919872L,
|
|
u'username': u'admin'}},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getChatMessages'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'since': self._ts2milli(since)}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def addChatMessage(self, message):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
Adds a message to the chat log
|
|
|
|
message:str The message to add
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'addChatMessage'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'message': message}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getAlbumList(self, ltype, size=10, offset=0, fromYear=None,
|
|
toYear=None, genre=None, musicFolderId=None):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
Returns a list of random, newest, highest rated etc. albums.
|
|
Similar to the album lists on the home page of the Subsonic
|
|
web interface
|
|
|
|
ltype:str The list type. Must be one of the following: random,
|
|
newest, highest, frequent, recent,
|
|
(since 1.8.0 -> )starred, alphabeticalByName,
|
|
alphabeticalByArtist
|
|
Since 1.10.1 you can use byYear and byGenre to
|
|
list albums in a given year range or genre.
|
|
size:int The number of albums to return. Max 500
|
|
offset:int The list offset. Use for paging. Max 5000
|
|
fromYear:int If you specify the ltype as "byYear", you *must*
|
|
specify fromYear
|
|
toYear:int If you specify the ltype as "byYear", you *must*
|
|
specify toYear
|
|
genre:str The name of the genre e.g. "Rock". You must specify
|
|
genre if you set the ltype to "byGenre"
|
|
musicFolderId:str Only return albums in the music folder with
|
|
the given ID. See getMusicFolders()
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'albumList': {u'album': [{u'artist': u'Hank Williams',
|
|
u'id': u'3264928374',
|
|
u'isDir': True,
|
|
u'parent': u'9238479283',
|
|
u'title': u'The Original Singles Collection...Plus'},
|
|
{u'artist': u'Freundeskreis',
|
|
u'coverArt': u'9823749823',
|
|
u'id': u'23492834',
|
|
u'isDir': True,
|
|
u'parent': u'9827492374',
|
|
u'title': u'Quadratur des Kreises'}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getAlbumList'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'type': ltype, 'size': size,
|
|
'offset': offset, 'fromYear': fromYear, 'toYear': toYear,
|
|
'genre': genre, 'musicFolderId': musicFolderId})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getAlbumList2(self, ltype, size=10, offset=0, fromYear=None,
|
|
toYear=None, genre=None):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Returns a list of random, newest, highest rated etc. albums.
|
|
This is similar to getAlbumList, but uses ID3 tags for
|
|
organization
|
|
|
|
ltype:str The list type. Must be one of the following: random,
|
|
newest, highest, frequent, recent,
|
|
(since 1.8.0 -> )starred, alphabeticalByName,
|
|
alphabeticalByArtist
|
|
Since 1.10.1 you can use byYear and byGenre to
|
|
list albums in a given year range or genre.
|
|
size:int The number of albums to return. Max 500
|
|
offset:int The list offset. Use for paging. Max 5000
|
|
fromYear:int If you specify the ltype as "byYear", you *must*
|
|
specify fromYear
|
|
toYear:int If you specify the ltype as "byYear", you *must*
|
|
specify toYear
|
|
genre:str The name of the genre e.g. "Rock". You must specify
|
|
genre if you set the ltype to "byGenre"
|
|
|
|
Returns a dict like the following:
|
|
{u'albumList2': {u'album': [{u'artist': u'Massive Attack',
|
|
u'artistId': 0,
|
|
u'coverArt': u'al-0',
|
|
u'created': u'2009-08-28T10:00:44',
|
|
u'duration': 3762,
|
|
u'id': 0,
|
|
u'name': u'100th Window',
|
|
u'songCount': 9},
|
|
{u'artist': u'Massive Attack',
|
|
u'artistId': 0,
|
|
u'coverArt': u'al-5',
|
|
u'created': u'2003-11-03T22:00:00',
|
|
u'duration': 2715,
|
|
u'id': 5,
|
|
u'name': u'Blue Lines',
|
|
u'songCount': 9}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getAlbumList2'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'type': ltype, 'size': size,
|
|
'offset': offset, 'fromYear': fromYear, 'toYear': toYear,
|
|
'genre': genre})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getRandomSongs(self, size=10, genre=None, fromYear=None,
|
|
toYear=None, musicFolderId=None):
|
|
"""
|
|
since 1.2.0
|
|
|
|
Returns random songs matching the given criteria
|
|
|
|
size:int The max number of songs to return. Max 500
|
|
genre:str Only return songs from this genre
|
|
fromYear:int Only return songs after or in this year
|
|
toYear:int Only return songs before or in this year
|
|
musicFolderId:str Only return songs in the music folder with the
|
|
given ID. See getMusicFolders
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'randomSongs': {u'song': [{u'album': u'1998 EP - Airbag (How Am I Driving)',
|
|
u'artist': u'Radiohead',
|
|
u'bitRate': 320,
|
|
u'contentType': u'audio/mpeg',
|
|
u'duration': 129,
|
|
u'id': u'9284728934',
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': u'983249823',
|
|
u'path': u'Radiohead/1998 EP - Airbag (How Am I Driving)/06 - Melatonin.mp3',
|
|
u'size': 5177469,
|
|
u'suffix': u'mp3',
|
|
u'title': u'Melatonin'},
|
|
{u'album': u'Mezmerize',
|
|
u'artist': u'System Of A Down',
|
|
u'bitRate': 214,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': u'23849372894',
|
|
u'duration': 176,
|
|
u'id': u'28937492834',
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': u'92837492837',
|
|
u'path': u'System Of A Down/Mesmerize/10 - System Of A Down - Old School Hollywood.mp3',
|
|
u'size': 4751360,
|
|
u'suffix': u'mp3',
|
|
u'title': u'Old School Hollywood',
|
|
u'track': 10}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getRandomSongs'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'size': size, 'genre': genre,
|
|
'fromYear': fromYear, 'toYear': toYear,
|
|
'musicFolderId': musicFolderId})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getLyrics(self, artist=None, title=None):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
Searches for and returns lyrics for a given song
|
|
|
|
artist:str The artist name
|
|
title:str The song title
|
|
|
|
Returns a dict like the following for
|
|
getLyrics('Bob Dylan', 'Blowin in the wind'):
|
|
|
|
{u'lyrics': {u'artist': u'Bob Dylan',
|
|
u'content': u"How many roads must a man walk down<snip>",
|
|
u'title': u"Blowin' in the Wind"},
|
|
u'status': u'ok',
|
|
u'version': u'1.5.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getLyrics'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'artist': artist, 'title': title})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def jukeboxControl(self, action, index=None, sids=[], gain=None,
|
|
offset=None):
|
|
"""
|
|
since: 1.2.0
|
|
|
|
NOTE: Some options were added as of API version 1.7.0
|
|
|
|
Controls the jukebox, i.e., playback directly on the server's
|
|
audio hardware. Note: The user must be authorized to control
|
|
the jukebox
|
|
|
|
action:str The operation to perform. Must be one of: get,
|
|
start, stop, skip, add, clear, remove, shuffle,
|
|
setGain, status (added in API 1.7.0),
|
|
set (added in API 1.7.0)
|
|
index:int Used by skip and remove. Zero-based index of the
|
|
song to skip to or remove.
|
|
sids:str Used by "add" and "set". ID of song to add to the
|
|
jukebox playlist. Use multiple id parameters to
|
|
add many songs in the same request. Whether you
|
|
are passing one song or many into this, this
|
|
parameter MUST be a list
|
|
gain:float Used by setGain to control the playback volume.
|
|
A float value between 0.0 and 1.0
|
|
offset:int (added in API 1.7.0) Used by "skip". Start playing
|
|
this many seconds into the track.
|
|
"""
|
|
methodName = 'jukeboxControl'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'action': action, 'index': index,
|
|
'gain': gain, 'offset': offset})
|
|
|
|
req = None
|
|
if action == 'add':
|
|
# We have to deal with the sids
|
|
if not (isinstance(sids, list) or isinstance(sids, tuple)):
|
|
raise ArgumentError('If you are adding songs, "sids" must '
|
|
'be a list or tuple!')
|
|
req = self._getRequestWithList(viewName, 'id', sids, q)
|
|
else:
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getPodcasts(self, incEpisodes=True, pid=None):
|
|
"""
|
|
since: 1.6.0
|
|
|
|
Returns all podcast channels the server subscribes to and their
|
|
episodes.
|
|
|
|
incEpisodes:bool (since: 1.9.0) Whether to include Podcast
|
|
episodes in the returned result.
|
|
pid:str (since: 1.9.0) If specified, only return
|
|
the Podcast channel with this ID.
|
|
|
|
Returns a dict like the following:
|
|
{u'status': u'ok',
|
|
u'version': u'1.6.0',
|
|
u'xmlns': u'http://subsonic.org/restapi',
|
|
u'podcasts': {u'channel': {u'description': u"Dr Chris Smith...",
|
|
u'episode': [{u'album': u'Dr Karl and the Naked Scientist',
|
|
u'artist': u'BBC Radio 5 live',
|
|
u'bitRate': 64,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': u'2f6f7074',
|
|
u'description': u'Dr Karl answers all your science related questions.',
|
|
u'duration': 2902,
|
|
u'genre': u'Podcast',
|
|
u'id': 0,
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e74697374',
|
|
u'publishDate': u'2011-08-17 22:06:00.0',
|
|
u'size': 23313059,
|
|
u'status': u'completed',
|
|
u'streamId': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e746973742f64726b61726c5f32303131303831382d30343036612e6d7033',
|
|
u'suffix': u'mp3',
|
|
u'title': u'DrKarl: Peppermints, Chillies & Receptors',
|
|
u'year': 2011},
|
|
{u'description': u'which is warmer, a bath with bubbles in it or one without? Just one of the stranger science stories tackled this week by Dr Chris Smith and the Naked Scientists!',
|
|
u'id': 1,
|
|
u'publishDate': u'2011-08-14 21:05:00.0',
|
|
u'status': u'skipped',
|
|
u'title': u'DrKarl: how many bubbles in your bath? 15 AUG 11'},
|
|
...
|
|
{u'description': u'Dr Karl joins Rhod to answer all your science questions',
|
|
u'id': 9,
|
|
u'publishDate': u'2011-07-06 22:12:00.0',
|
|
u'status': u'skipped',
|
|
u'title': u'DrKarl: 8 Jul 11 The Strange Sound of the MRI Scanner'}],
|
|
u'id': 0,
|
|
u'status': u'completed',
|
|
u'title': u'Dr Karl and the Naked Scientist',
|
|
u'url': u'http://downloads.bbc.co.uk/podcasts/fivelive/drkarl/rss.xml'}}
|
|
}
|
|
|
|
See also: http://subsonic.svn.sourceforge.net/viewvc/subsonic/trunk/subsonic-main/src/main/webapp/xsd/podcasts_example_1.xml?view=markup
|
|
"""
|
|
methodName = 'getPodcasts'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'includeEpisodes': incEpisodes,
|
|
'id': pid})
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getShares(self):
|
|
"""
|
|
since: 1.6.0
|
|
|
|
Returns information about shared media this user is allowed to manage
|
|
|
|
Note that entry can be either a single dict or a list of dicts
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'status': u'ok',
|
|
u'version': u'1.6.0',
|
|
u'xmlns': u'http://subsonic.org/restapi',
|
|
u'shares': {u'share': [
|
|
{u'created': u'2011-08-18T10:01:35',
|
|
u'entry': {u'artist': u'Alice In Chains',
|
|
u'coverArt': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e732f636f7665722e6a7067',
|
|
u'id': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e73',
|
|
u'isDir': True,
|
|
u'parent': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e73',
|
|
u'title': u'Alice In Chains'},
|
|
u'expires': u'2012-08-18T10:01:35',
|
|
u'id': 0,
|
|
u'url': u'http://crustymonkey.subsonic.org/share/BuLbF',
|
|
u'username': u'admin',
|
|
u'visitCount': 0
|
|
}]}
|
|
}
|
|
"""
|
|
methodName = 'getShares'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def createShare(self, shids=[], description=None, expires=None):
|
|
"""
|
|
since: 1.6.0
|
|
|
|
Creates a public URL that can be used by anyone to stream music
|
|
or video from the Subsonic server. The URL is short and suitable
|
|
for posting on Facebook, Twitter etc. Note: The user must be
|
|
authorized to share (see Settings > Users > User is allowed to
|
|
share files with anyone).
|
|
|
|
shids:list[str] A list of ids of songs, albums or videos
|
|
to share.
|
|
description:str A description that will be displayed to
|
|
people visiting the shared media
|
|
(optional).
|
|
expires:float A timestamp pertaining to the time at
|
|
which this should expire (optional)
|
|
|
|
This returns a structure like you would get back from getShares()
|
|
containing just your new share.
|
|
"""
|
|
methodName = 'createShare'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'description': description,
|
|
'expires': self._ts2milli(expires)})
|
|
req = self._getRequestWithList(viewName, 'id', shids, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def updateShare(self, shid, description=None, expires=None):
|
|
"""
|
|
since: 1.6.0
|
|
|
|
Updates the description and/or expiration date for an existing share
|
|
|
|
shid:str The id of the share to update
|
|
description:str The new description for the share (optional).
|
|
expires:float The new timestamp for the expiration time of this
|
|
share (optional).
|
|
"""
|
|
methodName = 'updateShare'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': shid, 'description': description,
|
|
expires: self._ts2milli(expires)})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deleteShare(self, shid):
|
|
"""
|
|
since: 1.6.0
|
|
|
|
Deletes an existing share
|
|
|
|
shid:str The id of the share to delete
|
|
|
|
Returns a standard response dict
|
|
"""
|
|
methodName = 'deleteShare'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': shid})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def setRating(self, id, rating):
|
|
"""
|
|
since: 1.6.0
|
|
|
|
Sets the rating for a music file
|
|
|
|
id:str The id of the item (song/artist/album) to rate
|
|
rating:int The rating between 1 and 5 (inclusive), or 0 to remove
|
|
the rating
|
|
|
|
Returns a standard response dict
|
|
"""
|
|
methodName = 'setRating'
|
|
viewName = '%s.view' % methodName
|
|
|
|
try:
|
|
rating = int(rating)
|
|
except:
|
|
raise ArgumentError('Rating must be an integer between 0 and 5: '
|
|
'%r' % rating)
|
|
if rating < 0 or rating > 5:
|
|
raise ArgumentError('Rating must be an integer between 0 and 5: '
|
|
'%r' % rating)
|
|
|
|
q = self._getQueryDict({'id': id, 'rating': rating})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getArtists(self):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Similar to getIndexes(), but this method uses the ID3 tags to
|
|
determine the artist
|
|
|
|
Returns a dict like the following:
|
|
{u'artists': {u'index': [{u'artist': {u'albumCount': 7,
|
|
u'coverArt': u'ar-0',
|
|
u'id': 0,
|
|
u'name': u'Massive Attack'},
|
|
u'name': u'M'},
|
|
{u'artist': {u'albumCount': 2,
|
|
u'coverArt': u'ar-1',
|
|
u'id': 1,
|
|
u'name': u'Tune-Yards'},
|
|
u'name': u'T'}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getArtists'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getArtist(self, id):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Returns the info (albums) for an artist. This method uses
|
|
the ID3 tags for organization
|
|
|
|
id:str The artist ID
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'artist': {u'album': [{u'artist': u'Tune-Yards',
|
|
u'artistId': 1,
|
|
u'coverArt': u'al-7',
|
|
u'created': u'2012-01-30T12:35:33',
|
|
u'duration': 3229,
|
|
u'id': 7,
|
|
u'name': u'Bird-Brains',
|
|
u'songCount': 13},
|
|
{u'artist': u'Tune-Yards',
|
|
u'artistId': 1,
|
|
u'coverArt': u'al-8',
|
|
u'created': u'2011-03-22T15:08:00',
|
|
u'duration': 2531,
|
|
u'id': 8,
|
|
u'name': u'W H O K I L L',
|
|
u'songCount': 10}],
|
|
u'albumCount': 2,
|
|
u'coverArt': u'ar-1',
|
|
u'id': 1,
|
|
u'name': u'Tune-Yards'},
|
|
u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getArtist'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': id})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getAlbum(self, id):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Returns the info and songs for an album. This method uses
|
|
the ID3 tags for organization
|
|
|
|
id:str The album ID
|
|
|
|
Returns a dict like the following:
|
|
|
|
{u'album': {u'artist': u'Massive Attack',
|
|
u'artistId': 0,
|
|
u'coverArt': u'al-0',
|
|
u'created': u'2009-08-28T10:00:44',
|
|
u'duration': 3762,
|
|
u'id': 0,
|
|
u'name': u'100th Window',
|
|
u'song': [{u'album': u'100th Window',
|
|
u'albumId': 0,
|
|
u'artist': u'Massive Attack',
|
|
u'artistId': 0,
|
|
u'bitRate': 192,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': 2,
|
|
u'created': u'2009-08-28T10:00:57',
|
|
u'duration': 341,
|
|
u'genre': u'Rock',
|
|
u'id': 14,
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': 2,
|
|
u'path': u'Massive Attack/100th Window/01 - Future Proof.mp3',
|
|
u'size': 8184445,
|
|
u'suffix': u'mp3',
|
|
u'title': u'Future Proof',
|
|
u'track': 1,
|
|
u'type': u'music',
|
|
u'year': 2003}],
|
|
u'songCount': 9},
|
|
u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getAlbum'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': id})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getSong(self, id):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Returns the info for a song. This method uses the ID3
|
|
tags for organization
|
|
|
|
id:str The song ID
|
|
|
|
Returns a dict like the following:
|
|
{u'song': {u'album': u'W H O K I L L',
|
|
u'albumId': 8,
|
|
u'artist': u'Tune-Yards',
|
|
u'artistId': 1,
|
|
u'bitRate': 320,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': 106,
|
|
u'created': u'2011-03-22T15:08:00',
|
|
u'discNumber': 1,
|
|
u'duration': 192,
|
|
u'genre': u'Indie Rock',
|
|
u'id': 120,
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': 106,
|
|
u'path': u'Tune Yards/Who Kill/10 Killa.mp3',
|
|
u'size': 7692656,
|
|
u'suffix': u'mp3',
|
|
u'title': u'Killa',
|
|
u'track': 10,
|
|
u'type': u'music',
|
|
u'year': 2011},
|
|
u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getSong'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': id})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getVideos(self):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Returns all video files
|
|
|
|
Returns a dict like the following:
|
|
{u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'videos': {u'video': {u'bitRate': 384,
|
|
u'contentType': u'video/x-matroska',
|
|
u'created': u'2012-08-26T13:36:44',
|
|
u'duration': 1301,
|
|
u'id': 130,
|
|
u'isDir': False,
|
|
u'isVideo': True,
|
|
u'path': u'South Park - 16x07 - Cartman Finds Love.mkv',
|
|
u'size': 287309613,
|
|
u'suffix': u'mkv',
|
|
u'title': u'South Park - 16x07 - Cartman Finds Love',
|
|
u'transcodedContentType': u'video/x-flv',
|
|
u'transcodedSuffix': u'flv'}},
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getVideos'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getStarred(self, musicFolderId=None):
|
|
"""
|
|
since 1.8.0
|
|
|
|
musicFolderId:int Only return results from the music folder
|
|
with the given ID. See getMusicFolders
|
|
|
|
Returns starred songs, albums and artists
|
|
|
|
Returns a dict like the following:
|
|
{u'starred': {u'album': {u'album': u'Bird-Brains',
|
|
u'artist': u'Tune-Yards',
|
|
u'coverArt': 105,
|
|
u'created': u'2012-01-30T13:16:58',
|
|
u'id': 105,
|
|
u'isDir': True,
|
|
u'parent': 104,
|
|
u'starred': u'2012-08-26T13:18:34',
|
|
u'title': u'Bird-Brains'},
|
|
u'song': [{u'album': u'Mezzanine',
|
|
u'albumId': 4,
|
|
u'artist': u'Massive Attack',
|
|
u'artistId': 0,
|
|
u'bitRate': 256,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': 6,
|
|
u'created': u'2009-06-15T07:48:28',
|
|
u'duration': 298,
|
|
u'genre': u'Dub',
|
|
u'id': 72,
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': 6,
|
|
u'path': u'Massive Attack/Mezzanine/Massive Attack_02_mezzanine.mp3',
|
|
u'size': 9564160,
|
|
u'starred': u'2012-08-26T13:19:26',
|
|
u'suffix': u'mp3',
|
|
u'title': u'Risingson',
|
|
u'track': 2,
|
|
u'type': u'music'},
|
|
{u'album': u'Mezzanine',
|
|
u'albumId': 4,
|
|
u'artist': u'Massive Attack',
|
|
u'artistId': 0,
|
|
u'bitRate': 256,
|
|
u'contentType': u'audio/mpeg',
|
|
u'coverArt': 6,
|
|
u'created': u'2009-06-15T07:48:25',
|
|
u'duration': 380,
|
|
u'genre': u'Dub',
|
|
u'id': 71,
|
|
u'isDir': False,
|
|
u'isVideo': False,
|
|
u'parent': 6,
|
|
u'path': u'Massive Attack/Mezzanine/Massive Attack_01_mezzanine.mp3',
|
|
u'size': 12179456,
|
|
u'starred': u'2012-08-26T13:19:03',
|
|
u'suffix': u'mp3',
|
|
u'title': u'Angel',
|
|
u'track': 1,
|
|
u'type': u'music'}]},
|
|
u'status': u'ok',
|
|
u'version': u'1.8.0',
|
|
u'xmlns': u'http://subsonic.org/restapi'}
|
|
"""
|
|
methodName = 'getStarred'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {}
|
|
if musicFolderId:
|
|
q['musicFolderId'] = musicFolderId
|
|
|
|
req = self._getRequest(viewName, q)
|
|
xbmc.log("Requesting %s"%str(req.full_url),xbmc.LOGDEBUG)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getStarred2(self, musicFolderId=None):
|
|
"""
|
|
since 1.8.0
|
|
|
|
musicFolderId:int Only return results from the music folder
|
|
with the given ID. See getMusicFolders
|
|
|
|
Returns starred songs, albums and artists like getStarred(),
|
|
but this uses ID3 tags for organization
|
|
|
|
Returns a dict like the following:
|
|
|
|
**See the output from getStarred()**
|
|
"""
|
|
methodName = 'getStarred2'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {}
|
|
if musicFolderId:
|
|
q['musicFolderId'] = musicFolderId
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def updatePlaylist(self, lid, name=None, comment=None, songIdsToAdd=[],
|
|
songIndexesToRemove=[]):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Updates a playlist. Only the owner of a playlist is allowed to
|
|
update it.
|
|
|
|
lid:str The playlist id
|
|
name:str The human readable name of the playlist
|
|
comment:str The playlist comment
|
|
songIdsToAdd:list A list of song IDs to add to the playlist
|
|
songIndexesToRemove:list Remove the songs at the
|
|
0 BASED INDEXED POSITIONS in the
|
|
playlist, NOT the song ids. Note that
|
|
this is always a list.
|
|
|
|
Returns a normal status response dict
|
|
"""
|
|
methodName = 'updatePlaylist'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'playlistId': lid, 'name': name,
|
|
'comment': comment})
|
|
if not isinstance(songIdsToAdd, list) or isinstance(songIdsToAdd,
|
|
tuple):
|
|
songIdsToAdd = [songIdsToAdd]
|
|
if not isinstance(songIndexesToRemove, list) or isinstance(
|
|
songIndexesToRemove, tuple):
|
|
songIndexesToRemove = [songIndexesToRemove]
|
|
listMap = {'songIdToAdd': songIdsToAdd,
|
|
'songIndexToRemove': songIndexesToRemove}
|
|
req = self._getRequestWithLists(viewName, listMap, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getAvatar(self, username):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Returns the avatar for a user or None if the avatar does not exist
|
|
|
|
username:str The user to retrieve the avatar for
|
|
|
|
Returns the file-like object for reading or raises an exception
|
|
on error
|
|
"""
|
|
methodName = 'getAvatar'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'username': username}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
try:
|
|
res = self._doBinReq(req)
|
|
except urllib.error.HTTPError:
|
|
# Avatar is not set/does not exist, return None
|
|
return None
|
|
if isinstance(res, dict):
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def star(self, sids=[], albumIds=[], artistIds=[]):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Attaches a star to songs, albums or artists
|
|
|
|
sids:list A list of song IDs to star
|
|
albumIds:list A list of album IDs to star. Use this rather than
|
|
"sids" if the client access the media collection
|
|
according to ID3 tags rather than file
|
|
structure
|
|
artistIds:list The ID of an artist to star. Use this rather
|
|
than sids if the client access the media
|
|
collection according to ID3 tags rather
|
|
than file structure
|
|
|
|
Returns a normal status response dict
|
|
"""
|
|
methodName = 'star'
|
|
viewName = '%s.view' % methodName
|
|
|
|
if not isinstance(sids, list) or isinstance(sids, tuple):
|
|
sids = [sids]
|
|
if not isinstance(albumIds, list) or isinstance(albumIds, tuple):
|
|
albumIds = [albumIds]
|
|
if not isinstance(artistIds, list) or isinstance(artistIds, tuple):
|
|
artistIds = [artistIds]
|
|
listMap = {'id': sids,
|
|
'albumId': albumIds,
|
|
'artistId': artistIds}
|
|
req = self._getRequestWithLists(viewName, listMap)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def unstar(self, sids=[], albumIds=[], artistIds=[]):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Removes a star to songs, albums or artists. Basically, the
|
|
same as star in reverse
|
|
|
|
sids:list A list of song IDs to star
|
|
albumIds:list A list of album IDs to star. Use this rather than
|
|
"sids" if the client access the media collection
|
|
according to ID3 tags rather than file
|
|
structure
|
|
artistIds:list The ID of an artist to star. Use this rather
|
|
than sids if the client access the media
|
|
collection according to ID3 tags rather
|
|
than file structure
|
|
|
|
Returns a normal status response dict
|
|
"""
|
|
methodName = 'unstar'
|
|
viewName = '%s.view' % methodName
|
|
|
|
if not isinstance(sids, list) or isinstance(sids, tuple):
|
|
sids = [sids]
|
|
if not isinstance(albumIds, list) or isinstance(albumIds, tuple):
|
|
albumIds = [albumIds]
|
|
if not isinstance(artistIds, list) or isinstance(artistIds, tuple):
|
|
artistIds = [artistIds]
|
|
listMap = {'id': sids,
|
|
'albumId': albumIds,
|
|
'artistId': artistIds}
|
|
req = self._getRequestWithLists(viewName, listMap)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getGenres(self):
|
|
"""
|
|
since 1.9.0
|
|
|
|
Returns all genres
|
|
"""
|
|
methodName = 'getGenres'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getSongsByGenre(self, genre, count=10, offset=0, musicFolderId=None):
|
|
"""
|
|
since 1.9.0
|
|
|
|
Returns songs in a given genre
|
|
|
|
genre:str The genre, as returned by getGenres()
|
|
count:int The maximum number of songs to return. Max is 500
|
|
default: 10
|
|
offset:int The offset if you are paging. default: 0
|
|
musicFolderId:int Only return results from the music folder
|
|
with the given ID. See getMusicFolders
|
|
"""
|
|
methodName = 'getSongsByGenre'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'genre': genre,
|
|
'count': count,
|
|
'offset': offset,
|
|
'musicFolderId': musicFolderId,
|
|
})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def hls (self, mid, bitrate=None):
|
|
"""
|
|
since 1.8.0
|
|
|
|
Creates an HTTP live streaming playlist for streaming video or
|
|
audio HLS is a streaming protocol implemented by Apple and
|
|
works by breaking the overall stream into a sequence of small
|
|
HTTP-based file downloads. It's supported by iOS and newer
|
|
versions of Android. This method also supports adaptive
|
|
bitrate streaming, see the bitRate parameter.
|
|
|
|
mid:str The ID of the media to stream
|
|
bitrate:str If specified, the server will attempt to limit the
|
|
bitrate to this value, in kilobits per second. If
|
|
this parameter is specified more than once, the
|
|
server will create a variant playlist, suitable
|
|
for adaptive bitrate streaming. The playlist will
|
|
support streaming at all the specified bitrates.
|
|
The server will automatically choose video dimensions
|
|
that are suitable for the given bitrates.
|
|
(since: 1.9.0) you may explicitly request a certain
|
|
width (480) and height (360) like so:
|
|
bitRate=1000@480x360
|
|
|
|
Returns the raw m3u8 file as a string
|
|
"""
|
|
methodName = 'hls'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': mid, 'bitrate': bitrate})
|
|
req = self._getRequest(viewName, q)
|
|
try:
|
|
res = self._doBinReq(req)
|
|
except urllib.error.HTTPError:
|
|
# Avatar is not set/does not exist, return None
|
|
return None
|
|
if isinstance(res, dict):
|
|
self._checkStatus(res)
|
|
return res.read()
|
|
|
|
def refreshPodcasts(self):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Tells the server to check for new Podcast episodes. Note: The user
|
|
must be authorized for Podcast administration
|
|
"""
|
|
methodName = 'refreshPodcasts'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def createPodcastChannel(self, url):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Adds a new Podcast channel. Note: The user must be authorized
|
|
for Podcast administration
|
|
|
|
url:str The URL of the Podcast to add
|
|
"""
|
|
methodName = 'createPodcastChannel'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'url': url}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deletePodcastChannel(self, pid):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Deletes a Podcast channel. Note: The user must be authorized
|
|
for Podcast administration
|
|
|
|
pid:str The ID of the Podcast channel to delete
|
|
"""
|
|
methodName = 'deletePodcastChannel'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': pid}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deletePodcastEpisode(self, pid):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Deletes a Podcast episode. Note: The user must be authorized
|
|
for Podcast administration
|
|
|
|
pid:str The ID of the Podcast episode to delete
|
|
"""
|
|
methodName = 'deletePodcastEpisode'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': pid}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def downloadPodcastEpisode(self, pid):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Tells the server to start downloading a given Podcast episode.
|
|
Note: The user must be authorized for Podcast administration
|
|
|
|
pid:str The ID of the Podcast episode to download
|
|
"""
|
|
methodName = 'downloadPodcastEpisode'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': pid}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getInternetRadioStations(self):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Returns all internet radio stations
|
|
"""
|
|
methodName = 'getInternetRadioStations'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def createInternetRadioStation(self, streamUrl, name, homepageUrl=None):
|
|
"""
|
|
since 1.16.0
|
|
|
|
Create an internet radio station
|
|
|
|
streamUrl:str The stream URL for the station
|
|
name:str The user-defined name for the station
|
|
homepageUrl:str The homepage URL for the station
|
|
"""
|
|
methodName = 'createInternetRadioStation'
|
|
viewName = '{}.view'.format(methodName)
|
|
|
|
q = self._getQueryDict({
|
|
'streamUrl': streamUrl, 'name': name, 'homepageUrl': homepageUrl})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def updateInternetRadioStation(self, iid, streamUrl, name,
|
|
homepageUrl=None):
|
|
"""
|
|
since 1.16.0
|
|
|
|
Create an internet radio station
|
|
|
|
iid:str The ID for the station
|
|
streamUrl:str The stream URL for the station
|
|
name:str The user-defined name for the station
|
|
homepageUrl:str The homepage URL for the station
|
|
"""
|
|
methodName = 'updateInternetRadioStation'
|
|
viewName = '{}.view'.format(methodName)
|
|
|
|
q = self._getQueryDict({
|
|
'id': iid, 'streamUrl': streamUrl, 'name': name,
|
|
'homepageUrl': homepageUrl,
|
|
})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deleteInternetRadioStation(self, iid):
|
|
"""
|
|
since 1.16.0
|
|
|
|
Create an internet radio station
|
|
|
|
iid:str The ID for the station
|
|
"""
|
|
methodName = 'deleteInternetRadioStation'
|
|
viewName = '{}.view'.format(methodName)
|
|
|
|
q = {'id': iid}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getBookmarks(self):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Returns all bookmarks for this user. A bookmark is a position
|
|
within a media file
|
|
"""
|
|
methodName = 'getBookmarks'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def createBookmark(self, mid, position, comment=None):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Creates or updates a bookmark (position within a media file).
|
|
Bookmarks are personal and not visible to other users
|
|
|
|
mid:str The ID of the media file to bookmark. If a bookmark
|
|
already exists for this file, it will be overwritten
|
|
position:int The position (in milliseconds) within the media file
|
|
comment:str A user-defined comment
|
|
"""
|
|
methodName = 'createBookmark'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = self._getQueryDict({'id': mid, 'position': position,
|
|
'comment': comment})
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def deleteBookmark(self, mid):
|
|
"""
|
|
since: 1.9.0
|
|
|
|
Deletes the bookmark for a given file
|
|
|
|
mid:str The ID of the media file to delete the bookmark from.
|
|
Other users' bookmarks are not affected
|
|
"""
|
|
methodName = 'deleteBookmark'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': mid}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getArtistInfo(self, aid, count=20, includeNotPresent=False):
|
|
"""
|
|
since: 1.11.0
|
|
|
|
Returns artist info with biography, image URLS and similar artists
|
|
using data from last.fm
|
|
|
|
aid:str The ID of the artist, album or song
|
|
count:int The max number of similar artists to return
|
|
includeNotPresent:bool Whether to return artists that are not
|
|
present in the media library
|
|
"""
|
|
methodName = 'getArtistInfo'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': aid, 'count': count,
|
|
'includeNotPresent': includeNotPresent}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
print(req.get_full_url())
|
|
print(res)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getArtistInfo2(self, aid, count=20, includeNotPresent=False):
|
|
"""
|
|
since: 1.11.0
|
|
|
|
Similar to getArtistInfo(), but organizes music according to ID3 tags
|
|
|
|
aid:str The ID of the artist, album or song
|
|
count:int The max number of similar artists to return
|
|
includeNotPresent:bool Whether to return artists that are not
|
|
present in the media library
|
|
"""
|
|
methodName = 'getArtistInfo2'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': aid, 'count': count,
|
|
'includeNotPresent': includeNotPresent}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getSimilarSongs(self, iid, count=50):
|
|
"""
|
|
since 1.11.0
|
|
|
|
Returns a random collection of songs from the given artist and
|
|
similar artists, using data from last.fm. Typically used for
|
|
artist radio features.
|
|
|
|
iid:str The artist, album, or song ID
|
|
count:int Max number of songs to return
|
|
"""
|
|
methodName = 'getSimilarSongs'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': iid, 'count': count}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getSimilarSongs2(self, iid, count=50):
|
|
"""
|
|
since 1.11.0
|
|
|
|
Similar to getSimilarSongs(), but organizes music according to
|
|
ID3 tags
|
|
|
|
iid:str The artist, album, or song ID
|
|
count:int Max number of songs to return
|
|
"""
|
|
methodName = 'getSimilarSongs2'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'id': iid, 'count': count}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def savePlayQueue(self, qids, current=None, position=None):
|
|
"""
|
|
since 1.12.0
|
|
|
|
qid:list[int] The list of song ids in the play queue
|
|
current:int The id of the current playing song
|
|
position:int The position, in milliseconds, within the current
|
|
playing song
|
|
|
|
Saves the state of the play queue for this user. This includes
|
|
the tracks in the play queue, the currently playing track, and
|
|
the position within this track. Typically used to allow a user to
|
|
move between different clients/apps while retaining the same play
|
|
queue (for instance when listening to an audio book).
|
|
"""
|
|
methodName = 'savePlayQueue'
|
|
viewName = '%s.view' % methodName
|
|
if not isinstance(qids, (tuple, list)):
|
|
qids = [qids]
|
|
|
|
q = self._getQueryDict({'current': current, 'position': position})
|
|
|
|
req = self._getRequestWithLists(viewName, {'id': qids}, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getPlayQueue(self):
|
|
"""
|
|
since 1.12.0
|
|
|
|
Returns the state of the play queue for this user (as set by
|
|
savePlayQueue). This includes the tracks in the play queue,
|
|
the currently playing track, and the position within this track.
|
|
Typically used to allow a user to move between different
|
|
clients/apps while retaining the same play queue (for instance
|
|
when listening to an audio book).
|
|
"""
|
|
methodName = 'getPlayQueue'
|
|
viewName = '%s.view' % methodName
|
|
|
|
req = self._getRequest(viewName)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getTopSongs(self, artist, count=50):
|
|
"""
|
|
since 1.13.0
|
|
|
|
Returns the top songs for a given artist
|
|
|
|
artist:str The artist to get songs for
|
|
count:int The number of songs to return
|
|
"""
|
|
methodName = 'getTopSongs'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'artist': artist, 'count': count}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getNewestPodcasts(self, count=20):
|
|
"""
|
|
since 1.13.0
|
|
|
|
Returns the most recently published Podcast episodes
|
|
|
|
count:int The number of episodes to return
|
|
"""
|
|
methodName = 'getNewestPodcasts'
|
|
viewName = '%s.view' % methodName
|
|
|
|
q = {'count': count}
|
|
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def scanMediaFolders(self):
|
|
"""
|
|
This is not an officially supported method of the API
|
|
|
|
Same as selecting 'Settings' > 'Scan media folders now' with
|
|
Subsonic web GUI
|
|
|
|
Returns True if refresh successful, False otherwise
|
|
"""
|
|
methodName = 'scanNow'
|
|
return self._unsupportedAPIFunction(methodName)
|
|
|
|
def cleanupDatabase(self):
|
|
"""
|
|
This is not an officially supported method of the API
|
|
|
|
Same as selecting 'Settings' > 'Clean-up Database' with Subsonic
|
|
web GUI
|
|
|
|
Returns True if cleanup initiated successfully, False otherwise
|
|
|
|
Subsonic stores information about all media files ever encountered.
|
|
By cleaning up the database, information about files that are
|
|
no longer in your media collection is permanently removed.
|
|
"""
|
|
methodName = 'expunge'
|
|
return self._unsupportedAPIFunction(methodName)
|
|
|
|
def getVideoInfo(self, vid):
|
|
"""
|
|
since 1.14.0
|
|
|
|
Returns details for a video, including information about available
|
|
audio tracks, subtitles (captions) and conversions.
|
|
|
|
vid:int The video ID
|
|
"""
|
|
methodName = 'getVideoInfo'
|
|
viewName = '%s.view' % methodName
|
|
|
|
#q = {'id': int(vid)}
|
|
q = {'id': vid}
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getAlbumInfo(self, aid):
|
|
"""
|
|
since 1.14.0
|
|
|
|
Returns the album notes, image URLs, etc., using data from last.fm
|
|
|
|
aid:int The album ID
|
|
"""
|
|
methodName = 'getAlbumInfo'
|
|
viewName = '%s.view' % methodName
|
|
|
|
#q = {'id': int(aid)}
|
|
q = {'id': aid}
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getAlbumInfo2(self, aid):
|
|
"""
|
|
since 1.14.0
|
|
|
|
Same as getAlbumInfo, but uses ID3 tags
|
|
|
|
aid:int The album ID
|
|
"""
|
|
methodName = 'getAlbumInfo2'
|
|
viewName = '%s.view' % methodName
|
|
|
|
#q = {'id': int(aid)}
|
|
q = {'id': aid}
|
|
req = self._getRequest(viewName, q)
|
|
res = self._doInfoReq(req)
|
|
self._checkStatus(res)
|
|
return res
|
|
|
|
def getCaptions(self, vid, fmt=None):
|
|
"""
|
|
since 1.14.0
|
|
|
|
Returns captions (subtitles) for a video. Use getVideoInfo for a list
|
|
of captions.
|
|
|
|
vid:int The ID of the video
|
|
fmt:str Preferred captions format ("srt" or "vtt")
|
|
"""
|
|
methodName = 'getCaptions'
|
|
viewName = '%s.view' % methodName
|
|
|
|
#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)
|
|
return res
|
|
|
|
def _unsupportedAPIFunction(self, methodName):
|
|
"""
|
|
base function to call unsupported API methods
|
|
|
|
Returns True if refresh successful, False otherwise
|
|
:rtype : boolean
|
|
"""
|
|
baseMethod = 'musicFolderSettings'
|
|
viewName = '%s.view' % baseMethod
|
|
|
|
url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port,
|
|
self._separateServerPath(), viewName, methodName)
|
|
req = urllib.request.Request(url)
|
|
res = self._opener.open(req)
|
|
res_msg = res.msg.lower()
|
|
return res_msg == 'ok'
|
|
|
|
#
|
|
# Private internal methods
|
|
#
|
|
def _getOpener(self, username, passwd):
|
|
# Context is only relevent in >= python 2.7.9
|
|
https_chain = HTTPSHandlerChain()
|
|
if sys.version_info[:3] >= (2, 7, 9) and self._insecure:
|
|
https_chain = HTTPSHandlerChain(
|
|
context=ssl._create_unverified_context())
|
|
opener = urllib.request.build_opener(
|
|
PysHTTPRedirectHandler,
|
|
https_chain,
|
|
)
|
|
return opener
|
|
|
|
def _getQueryDict(self, d):
|
|
"""
|
|
Given a dictionary, it cleans out all the values set to None
|
|
"""
|
|
for k, v in list(d.items()):
|
|
if v is None:
|
|
del d[k]
|
|
return d
|
|
|
|
def _getBaseQdict(self):
|
|
qdict = {
|
|
'f': 'json',
|
|
'v': self._apiVersion,
|
|
'c': self._appName,
|
|
'u': self._username,
|
|
}
|
|
|
|
if self._legacyAuth:
|
|
qdict['p'] = 'enc:%s' % self._hexEnc(self._rawPass)
|
|
else:
|
|
salt = self._getSalt()
|
|
token = md5((self._rawPass + salt).encode('utf-8')).hexdigest()
|
|
qdict.update({
|
|
's': salt,
|
|
't': token,
|
|
})
|
|
|
|
return qdict
|
|
|
|
def _getRequest(self, viewName, query={}):
|
|
qdict = self._getBaseQdict()
|
|
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)
|
|
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)
|
|
req = urllib.request.Request(url)
|
|
return req
|
|
|
|
def _getRequestWithList(self, viewName, listName, alist, query={}):
|
|
"""
|
|
Like _getRequest, but allows appending a number of items with the
|
|
same key (listName). This bypasses the limitation of urlencode()
|
|
"""
|
|
qdict = self._getBaseQdict()
|
|
qdict.update(query)
|
|
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
|
viewName)
|
|
data = StringIO()
|
|
data.write(urlencode(qdict))
|
|
for i in alist:
|
|
data.write('&%s' % urlencode({listName: i}))
|
|
req = urllib.request.Request(url, data.getvalue().encode('utf-8'))
|
|
|
|
if self._useGET:
|
|
url += '?%s' % data.getvalue()
|
|
req = urllib2.Request(url)
|
|
|
|
return req
|
|
|
|
def _getRequestWithLists(self, viewName, listMap, query={}):
|
|
"""
|
|
Like _getRequestWithList(), but you must pass a dictionary
|
|
that maps the listName to the list. This allows for multiple
|
|
list parameters to be used, like in updatePlaylist()
|
|
|
|
viewName:str The name of the view
|
|
listMap:dict A mapping of listName to a list of entries
|
|
query:dict The normal query dict
|
|
"""
|
|
qdict = self._getBaseQdict()
|
|
qdict.update(query)
|
|
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
|
viewName)
|
|
data = StringIO()
|
|
data.write(urlencode(qdict))
|
|
for k, l in listMap.items():
|
|
for i in l:
|
|
data.write('&%s' % urlencode({k: i}))
|
|
req = urllib.request.Request(url, data.getvalue().encode('utf-8'))
|
|
|
|
if self._useGET:
|
|
url += '?%s' % data.getvalue()
|
|
req = urllib2.Request(url)
|
|
|
|
return req
|
|
|
|
def _doInfoReq(self, req):
|
|
# Returns a parsed dictionary version of the result
|
|
res = self._opener.open(req)
|
|
dres = json.loads(res.read().decode('utf-8'))
|
|
return dres['subsonic-response']
|
|
|
|
def _doBinReq(self, req):
|
|
res = self._opener.open(req)
|
|
info = res.info()
|
|
if hasattr(info, 'getheader'):
|
|
contType = info.getheader('Content-Type')
|
|
else:
|
|
contType = info.get('Content-Type')
|
|
|
|
if contType:
|
|
if contType.startswith('text/html') or \
|
|
contType.startswith('application/json'):
|
|
dres = json.loads(res.read())
|
|
return dres['subsonic-response']
|
|
return res
|
|
|
|
def _checkStatus(self, result):
|
|
if result['status'] == 'ok':
|
|
return True
|
|
elif result['status'] == 'failed':
|
|
exc = getExcByCode(result['error']['code'])
|
|
raise exc(result['error']['message'])
|
|
|
|
def _hexEnc(self, raw):
|
|
"""
|
|
Returns a "hex encoded" string per the Subsonic api docs
|
|
|
|
raw:str The string to hex encode
|
|
"""
|
|
ret = ''
|
|
for c in raw:
|
|
ret += '%02X' % ord(c)
|
|
return ret
|
|
|
|
def _ts2milli(self, ts):
|
|
"""
|
|
For whatever reason, Subsonic uses timestamps in milliseconds since
|
|
the unix epoch. I have no idea what need there is of this precision,
|
|
but this will just multiply the timestamp times 1000 and return the int
|
|
"""
|
|
if ts is None:
|
|
return None
|
|
return int(ts * 1000)
|
|
|
|
def _separateServerPath(self):
|
|
"""
|
|
separate REST portion of URL from base server path.
|
|
"""
|
|
return urllib.parse.splithost(self._serverPath)[1].split('/')[0]
|
|
|
|
def _fixLastModified(self, data):
|
|
"""
|
|
This will recursively walk through a data structure and look for
|
|
a dict key/value pair where the key is "lastModified" and change
|
|
the shitty java millisecond timestamp to a real unix timestamp
|
|
of SECONDS since the unix epoch. JAVA SUCKS!
|
|
"""
|
|
if isinstance(data, dict):
|
|
for k, v in list(data.items()):
|
|
if k == 'lastModified':
|
|
data[k] = int(v) / 1000.0
|
|
return
|
|
elif isinstance(v, (tuple, list, dict)):
|
|
return self._fixLastModified(v)
|
|
elif isinstance(data, (list, tuple)):
|
|
for item in data:
|
|
if isinstance(item, (list, tuple, dict)):
|
|
return self._fixLastModified(item)
|
|
|
|
def _process_netrc(self, use_netrc):
|
|
"""
|
|
The use_netrc var is either a boolean, which means we should use
|
|
the user's default netrc, or a string specifying a path to a
|
|
netrc formatted file
|
|
|
|
use_netrc:bool|str Either set to True to use the user's default
|
|
netrc file or a string specifying a specific
|
|
netrc file to use
|
|
"""
|
|
if not use_netrc:
|
|
raise CredentialError('useNetrc must be either a boolean "True" '
|
|
'or a string representing a path to a netrc file, '
|
|
'not {0}'.format(repr(use_netrc)))
|
|
if isinstance(use_netrc, bool) and use_netrc:
|
|
self._netrc = netrc()
|
|
else:
|
|
# This should be a string specifying a path to a netrc file
|
|
self._netrc = netrc(os.path.expanduser(use_netrc))
|
|
auth = self._netrc.authenticators(self._hostname)
|
|
if not auth:
|
|
raise CredentialError('No machine entry found for {0} in '
|
|
'your netrc file'.format(self._hostname))
|
|
|
|
# If we get here, we have credentials
|
|
self._username = auth[0]
|
|
self._rawPass = auth[2]
|
|
|
|
def _getSalt(self, length=12):
|
|
salt = md5(os.urandom(100)).hexdigest()
|
|
return salt[:length]
|