Kodi.plugin.audio.subsonic/main.py

1056 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Module: main
# Author: G.Breant
# Created on: 04.10.2016
# License: GPL v.3 https://www.gnu.org/copyleft/gpl.html
import xbmcgui
import json
import os
import shutil
import dateutil.parser
from datetime import datetime
#check for Simpleplugin. Official repos are not up to date so let's do this nasty trick.
#TO FIX : version check. https://github.com/romanvm/script.module.simpleplugin/issues/4
try:
from simpleplugin import Plugin
from simpleplugin import Addon
except:
xbmcgui.Dialog().ok('SimplePlugin 2.0.1 required', "The Subsonic Addon requires SimplePlugin 2.0.1 framework.", "Please download and install it !", "https://github.com/romanvm/script.module.simpleplugin/releases")
sys.exit()
# Create plugin instance
plugin = Plugin()
# Make sure library folder is on the path
sys.path.append(xbmc.translatePath(
os.path.join(plugin.addon.getAddonInfo('path'), 'lib')))
# initialize_gettext
#_ = plugin.initialize_gettext()
connection = None
cachetime = int(Addon().get_setting('cachetime'))
import libsonic_extra
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 is None:
connected = False
# Create connection
try:
connection = libsonic_extra.SubsonicClient(
Addon().get_setting('subsonic_url'),
Addon().get_setting('username'),
Addon().get_setting('password'),
Addon().get_setting('apiversion'),
Addon().get_setting('insecure') == 'true',
Addon().get_setting('legacyauth') == 'true',
)
connected = connection.ping()
except:
pass
if connected is False:
popup('Connection error')
return False
return connection
@plugin.action()
def root(params):
# get connection
connection = get_connection()
if connection is False:
return
listing = []
menus = {
'artists': {
'name': 'Artists',
'callback': 'list_artists',
'thumb': None
},
'albums': {
'name': 'Albums',
'callback': 'menu_albums',
'thumb': None
},
'tracks': {
'name': 'Tracks',
'callback': 'menu_tracks',
'thumb': None
},
'playlists': {
'name': 'Playlists',
'callback': 'list_playlists',
'thumb': None
}
}
# Iterate through categories
for mid in menus:
# image
if 'thumb' in menus[mid]:
thumb = menus[mid]['thumb']
listing.append({
'label': menus[mid]['name'],
'thumb': thumb, # Item thumbnail
'fanart': thumb, # Item thumbnail
'url': plugin.get_url(
action=menus[mid]['callback'],
menu_id=mid
)
}) # Item label
return plugin.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()
def menu_albums(params):
# get connection
connection = get_connection()
if connection is False:
return
listing = []
menus = {
'albums_newest': {
'name': 'Newest albums',
'thumb': None,
'args': {"ltype": "newest"}
},
'albums_frequent': {
'name': 'Most played albums',
'thumb': None,
'args': {"ltype": "frequent"}
},
'albums_recent': {
'name': 'Recently played albums',
'thumb': None,
'args': {"ltype": "recent"}
},
'albums_random': {
'name': 'Random albums',
'thumb': None,
'args': {"ltype": "random"}
}
}
# Iterate through categories
for menu_id in menus:
menu = menus.get(menu_id)
# image
if 'thumb' in menu:
thumb = menu.get('thumb')
listing.append({
'label': menu.get('name'),
'thumb': menu.get('thumb'), # Item thumbnail
'fanart': menu.get('thumb'), # Item thumbnail
'url': plugin.get_url(
action= 'list_albums',
page= 1,
query_args= json.dumps(menu.get('args')),
menu_id= menu_id
)
}) # Item label
return plugin.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()
def menu_tracks(params):
# get connection
connection = get_connection()
if connection is False:
return
listing = []
menus = {
'tracks_starred': {
'name': 'Starred tracks',
'thumb': None,
'is_stars_list': True
},
'tracks_random': {
'name': 'Random tracks',
'thumb': None
}
}
# Iterate through categories
for menu_id in menus:
menu = menus.get(menu_id)
# image
if 'thumb' in menu:
thumb = menu.get('thumb')
listing.append({
'label': menu.get('name'),
'thumb': menu.get('thumb'), # Item thumbnail
'fanart': menu.get('thumb'), # Item thumbnail
'url': plugin.get_url(
action= 'list_tracks',
menu_id= menu_id,
is_stars_list= menu.get('is_stars_list')
)
}) # Item label
return plugin.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) #if cache is enabled, cache data for the following function
def list_artists(params):
# get connection
connection = get_connection()
if connection is False:
return
listing = []
# Get items
items = connection.walk_artists()
# Iterate through items
for item in items:
entry = {
'label': item['name'],
'thumb': connection.getCoverArtUrl(item.get('id')),
'fanart': connection.getCoverArtUrl(item.get('id')),
'url': plugin.get_url(
action= 'list_artist_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
'count': item.get('albumCount'),
'artist': item.get('name')
}
}
}
#context menu actions
context_actions = []
if can_star('artist',item.get('id')):
action_star = context_action_star('artist',item.get('id'),params.get('is_stars_list'))
context_actions.append(action_star)
if len(context_actions) > 0:
entry['context_menu'] = context_actions
listing.append(entry)
# Sort methods - List of integer constants representing virtual folder sort methods. - see SortFileItem.h from Kodi core
sortable_by = (
0, #SORT_METHOD_NONE
11, #SORT_METHOD_ARTIST
40 #SORT_METHOD_UNSORTED
)
return plugin.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 = sortable_by, #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) #if cache is enabled, cache data for the following function
def list_albums(params):
listing = []
# get connection
connection = get_connection()
if connection is False:
return
#query
query_args = {}
try:
query_args_json = params['query_args']
query_args = json.loads(query_args_json)
except:
pass
#size
albums_per_page = int(Addon().get_setting('albums_per_page'))
query_args["size"] = albums_per_page
#offset
offset = int(params.get('page',1)) - 1;
if offset > 0:
query_args["offset"] = offset * albums_per_page
#debug
query_args_json = json.dumps(query_args)
plugin.log('list_albums with args:' + query_args_json);
#Get items
items = connection.walk_albums(**query_args)
# Iterate through items
for item in items:
album = get_album_entry(item, params)
listing.append(album)
# Root menu
link_root = navigate_root()
listing.append(link_root)
# Pagination if we've not reached the end of the lsit
# if type(items) != type(True): TO FIX
link_next = navigate_next(params)
listing.append(link_next)
# Sort methods - List of integer constants representing virtual folder sort methods. - see SortFileItem.h from Kodi core
sortable_by = (
0, #SORT_METHOD_NONE
1, #SORT_METHOD_LABEL
#3, #SORT_METHOD_DATE
11, #SORT_METHOD_ARTIST
#14, #SORT_METHOD_ALBUM
18, #SORT_METHOD_YEAR
#21 #SORT_METHOD_DATEADDED
40 #SORT_METHOD_UNSORTED
)
return plugin.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 = sortable_by,
#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) #if cache is enabled, cache data for the following function
def list_artist_albums(params):
# get connection
connection = get_connection()
if connection is False:
return
listing = []
# Get items
artist_id = params['artist_id']
params['hide_artist'] = True
items = connection.walk_artist(artist_id)
# Iterate through items
for item in items:
album = get_album_entry(item, params)
listing.append(album)
return plugin.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 = 'albums' #string - current plugin content, e.g. movies or episodes.
)
def get_album_entry(item, params):
menu_id = params.get('menu_id')
is_stars_list= params.get('is_stars_list')
# name
if 'hide_artist' in params:
title = item.get('name', '<Unknown>')
else:
title = '%s - %s' % (item.get('artist', '<Unknown>'),
item.get('name', '<Unknown>'))
entry = {
'label': title,
'thumb': item.get('coverArt'),
'fanart': item.get('coverArt'),
'url': plugin.get_url(
action= 'list_tracks',
album_id= item.get('id'),
hide_artist= item.get('hide_artist'),
menu_id= menu_id,
is_stars_list= is_stars_list
),
'info': {
'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'count': item.get('songCount'),
'date': convert_date_from_iso8601(item.get('created')), #date added
'duration': item.get('duration'),
'artist': item.get('artist'),
'album': item.get('name'),
'year': item.get('year')
}
}
}
#context menu actions
context_actions = []
if can_star('album',item.get('id')):
action_star = context_action_star('album',item.get('id'),is_stars_list)
context_actions.append(action_star)
if can_download('album',item.get('id')):
action_download = context_action_download('album',item.get('id'))
context_actions.append(action_download)
if len(context_actions) > 0:
entry['context_menu'] = context_actions
return entry
@plugin.action()
#@plugin.cached(cachetime) #if cache is enabled, cache data for the following function
def list_tracks(params):
menu_id = params.get('menu_id')
listing = []
#query
query_args = {}
try:
query_args_json = params['query_args']
query_args = json.loads(query_args_json)
except:
pass
#size
tracks_per_page = int(Addon().get_setting('tracks_per_page'))
query_args["size"] = tracks_per_page
#offset
offset = int(params.get('page',1)) - 1;
if offset > 0:
query_args["offset"] = offset * tracks_per_page
#debug
query_args_json = json.dumps(query_args)
plugin.log('list_tracks with args:' + query_args_json);
# get connection
connection = get_connection()
if connection is False:
return
# Album
if 'album_id' in params:
items = connection.walk_album(params['album_id'])
# Playlist
elif 'playlist_id' in params:
items = connection.walk_playlist(params['playlist_id'])
#TO FIX
#tracknumber = 0
#for item in items:
# tracknumber += 1
# items[item]['tracknumber'] = tracknumber
# Starred
elif menu_id == 'tracks_starred':
items = connection.walk_tracks_starred()
# Random
elif menu_id == 'tracks_random':
items = connection.walk_tracks_random(**query_args)
# Filters
#else:
#TO WORK
# Iterate through items
key = 0;
for item in items:
track = get_track_entry(item,params)
listing.append(track)
key +=1
# Root menu
#link_root = navigate_root()
#listing.append(link_root)
# Pagination if we've not reached the end of the lsit
# if type(items) != type(True): TO FIX
#link_next = navigate_next(params)
#listing.append(link_next)
# Sort methods - List of integer constants representing virtual folder sort methods. - see SortFileItem.h from Kodi core
sortable_by = (
0, #SORT_METHOD_NONE
1, #SORT_METHOD_LABEL
#3, #SORT_METHOD_DATE
7, #SORT_METHOD_TRACKNUM
11, #SORT_METHOD_ARTIST
#14,#SORT_METHOD_ALBUM
18, #SORT_METHOD_YEAR
#21 #SORT_METHOD_DATEADDED
40 #SORT_METHOD_UNSORTED
)
return plugin.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 = sortable_by, #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 = 'songs' #string - current plugin content, e.g. movies or episodes.
)
def get_track_entry(item,params):
menu_id = params.get('menu_id')
is_stars_list = params.get('is_stars_list')
# name
if 'hide_artist' in params:
title = item.get('title', '<Unknown>')
else:
title = '%s - %s' % (
item.get('artist', '<Unknown>'),
item.get('title', '<Unknown>')
)
#date_create
item_date = item.get('created')
# star
if (is_stars_list):
item_date = item.get('starred')
#TO FIX
#starAscii = '★'
#star =starAscii.encode('utf-8')
#title = "%s %s" % (star,title)
entry = {
'label': title,
'thumb': item.get('coverArt'),
'fanart': item.get('coverArt'),
'url': plugin.get_url(
action= 'play_track',
id= item.get('id'),
menu_id= menu_id
),
'is_playable': True,
'mime': item.get("contentType"),
'info': {'music': { #http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'title': item.get('title'),
'album': item.get('album'),
'artist': item.get('artist'),
'tracknumber': item.get('tracknumber'),
'year': item.get('year'),
'genre': item.get('genre'),
'size': item.get('size'),
'duration': item.get('duration'),
'date': item_date
}
}
}
#context menu actions
context_actions = []
if can_star('track',item.get('id')):
action_star = context_action_star('track',item.get('id'),is_stars_list)
context_actions.append(action_star)
if can_download('track',item.get('id')):
action_download = context_action_download('track',item.get('id'))
context_actions.append(action_download)
if len(context_actions) > 0:
entry['context_menu'] = context_actions
return entry
@plugin.action()
def play_track(params):
id = params['id']
plugin.log('play_track #' + id);
# get connection
connection = get_connection()
if connection is False:
return
url = connection.streamUrl(sid=id,
maxBitRate=Addon().get_setting('bitrate_streaming'),
tformat=Addon().get_setting('transcode_format_streaming')
)
return url
def navigate_next(params):
page = int(params['page'])
page += 1
title = "Next page (%d)" % (page)
return {
'label': title,
'url': plugin.get_url(
action= params['action'],
page= page,
query_args= params['query_args']
)
}
def navigate_root():
return {
'label': "Back to menu",
'url': plugin.get_url(action='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)
return date_obj.strftime('%d.%m.%Y')
@plugin.action()
#@plugin.cached(cachetime) #if cache is enabled, cache data for the following function
def list_playlists(params):
# get connection
connection = get_connection()
if connection is False:
return
listing = []
# Get items
items = connection.walk_playlists()
# Iterate through items
for item in items:
listing.append({
'label': item['name'],
'thumb': connection.getCoverArtUrl(item.get('id')),
'fanart': connection.getCoverArtUrl(item.get('id')),
'url': plugin.get_url(
action= 'list_tracks',
playlist_id= item.get('id'),
menu_id= params.get('menu_id')
),
'info': {'music': { ##http://romanvm.github.io/Kodistubs/_autosummary/xbmcgui.html#xbmcgui.ListItem.setInfo
'title': item.get('name'),
'count': item.get('songCount'),
'duration': item.get('duration'),
'date': convert_date_from_iso8601(item.get('created'))
}}
})
return plugin.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.
)
#star (or unstar) an item
@plugin.action()
def star_item(params):
ids= params.get('ids'); #can be single or lists of IDs
unstar= params.get('unstar',False);
unstar = (unstar) and (unstar != 'None') and (unstar != 'False') #TO FIX better statement ?
type= params.get('type');
sids = albumIds = artistIds = None
#validate type
if type == 'track':
sids = ids
elif type == 'artist':
artistIds = ids
elif type == 'album':
albumIds = ids
#validate capability
if not can_star(type,ids):
return;
#validate IDs
if (not sids and not artistIds and not albumIds):
return;
# get connection
connection = get_connection()
if connection is False:
return
###
did_action = False
try:
if unstar:
request = connection.unstar(sids, albumIds, artistIds)
else:
request = connection.star(sids, albumIds, artistIds)
if request['status'] == 'ok':
did_action = True
except:
pass
###
if did_action:
if unstar:
message = 'Item has been unstarred.'
plugin.log('Unstarred %s #%s' % (type,json.dumps(ids)))
else: #star
message = 'Item has been starred!'
plugin.log('Starred %s #%s' % (type,json.dumps(ids)))
popup(message)
#TO FIX clear starred lists caches ?
#TO FIX refresh current list after star set ?
else:
if unstar:
plugin.log_error('Unable to unstar %s #%s' % (type,json.dumps(ids)))
else:
plugin.log_error('Unable to star %s #%s' % (type,json.dumps(ids)))
return did_action
def context_action_star(type,id,is_stars_list):
unstar = (is_stars_list) and (is_stars_list != 'None') and (is_stars_list != 'False') #TO FIX better statement ?
if not unstar:
if type == 'track':
label = 'Star track'
elif type == 'artist':
label = 'Star artist'
elif type == 'album':
label = 'Star album'
else:
#Should be available only in the stars lists;
#so we don't have to fetch the starred status for each item
#(since it is not available into the XML response from the server)
if type == 'track':
label = 'Unstar track'
elif type == 'artist':
label = 'Unstar artist'
elif type == 'album':
label = 'Unstar album'
return (
label,
'XBMC.RunPlugin(%s)' % plugin.get_url(action='star_item',type=type,ids=id,unstar=unstar)
)
#Subsonic API says this is supported for artist,tracks and albums,
#But I can see it available only for tracks on Subsonic 5.3, so disable it.
def can_star(type,ids = None):
if not ids:
return False
if not isinstance(ids, list) or isinstance(ids, tuple):
ids = [ids]
if len(ids) == 0:
return False
if type == 'track':
return True
elif type == 'artist':
return False
elif type == 'album':
return False
def context_action_download(type,id):
if type == 'track':
label = 'Download track'
elif type == 'album':
label = 'Download album'
return (
label,
'XBMC.RunPlugin(%s)' % plugin.get_url(action='download_item',type=type,id=id)
)
def can_download(type,id = None):
if id is None:
return False
if type == 'track':
return True
elif type == 'album':
return True
@plugin.action()
def download_item(params):
id= params.get('id'); #can be single or lists of IDs
type= params.get('type');
#validate path
download_folder = Addon().get_setting('download_folder')
if not download_folder:
popup("Please set a directory for your downloads")
plugin.log_error("No directory set for downloads")
#validate capability
if not can_download(type,id):
return;
if type == 'track':
did_action = download_tracks(id)
elif type == 'album':
did_action = download_album(id)
if did_action:
plugin.log('Downloaded %s #%s' % (type,id))
popup('Item has been downloaded!')
else:
plugin.log_error('Unable to downloaded %s #%s' % (type,id))
return did_action
def download_tracks(ids):
#popup is fired before, in download_item
download_folder = Addon().get_setting('download_folder')
if not download_folder:
return
if not ids:
return False
#make list
if not isinstance(ids, list) or isinstance(ids, tuple):
ids = [ids]
ids_count = len(ids)
#check if empty
if ids_count == 0:
return False
plugin.log('download_tracks IDs:')
plugin.log(json.dumps(ids))
# get connection
connection = get_connection()
if connection is False:
return
#progress...
pc_step = 100/ids_count
pc_progress = 0
ids_parsed = 0
progressdialog = xbmcgui.DialogProgress()
progressdialog.create("Downloading tracks...") #Title
for id in ids:
if (progressdialog.iscanceled()):
return False
# debug
plugin.log('Trying to download track #'+str(id))
# get track infos
response = connection.getSong(id);
track = response.get('song')
plugin.log('Track info :')
plugin.log(track)
# progress bar
pc_progress = ids_parsed * pc_step
progressdialog.update(pc_progress, 'Getting track informations...',"%s - %s" % (track.get('artist','<Unknown>'),track.get('title','<Unknown>')))
track_path_relative = track.get("path", None) # 'Radiohead/Kid A/Idioteque.mp3'
track_path = os.path.join(download_folder, track_path_relative) # 'C:/users/.../Radiohead/Kid A/Idioteque.mp3'
track_directory = os.path.dirname(os.path.abspath(track_path)) # 'C:/users/.../Radiohead/Kid A'
#check if file exists
if os.path.isfile(track_path):
progressdialog.update(pc_progress, 'Track has already been downloaded!')
plugin.log("File '%s' already exists" % (id))
else:
progressdialog.update(pc_progress, "Downloading track...",track_path)
try:
#get remote file (file-object like)
file_obj = connection.download(id)
#create directory if it does not exists
if not os.path.exists(track_directory):
os.makedirs(track_directory)
#create blank file
file = open(track_path, 'a') #create a new file but don't erase the existing one if it exists
#fill blank file
shutil.copyfileobj(file_obj, file)
file.close()
except:
popup("Error while downloading track #%s" % (id))
plugin.log("Error while downloading track #%s" % (id))
pass
ids_parsed += 1
progressdialog.update(100, "Done !","Enjoy !")
xbmc.sleep(1000)
progressdialog.close()
def download_album(id):
# get connection
connection = get_connection()
if connection is False:
return
# get album infos
response = connection.getAlbum(id);
album = response.get('album')
tracks = album.get('song')
plugin.log('getAlbum:')
plugin.log(json.dumps(album))
ids = [] #list of track IDs
for i, track in enumerate(tracks):
track_id = track.get('id')
ids.append(track_id)
download_tracks(ids)
# Start plugin from within Kodi.
if __name__ == "__main__":
# Map actions
# Note that we map callable objects without brackets ()
plugin.actions['list_playlists'] = list_playlists
plugin.run()