Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef6ba710e2 | |||
| 4179ca8ada | |||
| fcbcccaeb9 | |||
| fcfee371f8 | |||
| 68a8fec4f8 | |||
| 2285d5ca0b | |||
| 537524d7fe | |||
| 3cb2bc702b | |||
| b66129b448 | |||
| 8bbeabc0dc | |||
| 5117218f86 | |||
| f80b6458a1 | |||
| 8bae5abd88 | |||
| c026d31836 | |||
| f905da4f43 | |||
| 8a786b1eae | |||
| 73c6bd5721 | |||
| 32f706ffbe | |||
| 5c1ba5015b | |||
| 5307f5d8c4 | |||
| ff52a69087 | |||
| 8d3d425089 | |||
| c0a7386ff1 | |||
| 3b9642ea85 | |||
| 52c2c2d2d7 | |||
| 48dc6a555b | |||
| d92a831743 | |||
| 19d39e4da6 | |||
| a9b2f10015 | |||
| d94a6985e2 | |||
| d2ca6218ce | |||
| 99196c32eb | |||
| c08c8d28ee | |||
| a4e8f2376d | |||
| 6fee1db7ec | |||
| ac9160ea2c | |||
| f16f234679 | |||
| 4073737008 | |||
| 03d77f0c83 | |||
| 44418a384e | |||
| b35dfd278d | |||
| 47d0fcbe77 | |||
| 6b4807bbf5 | |||
| fe158c33fb | |||
| a6c18a2eb0 | |||
| 86888b5a7a | |||
| 7be23bfd8d | |||
| 2580050353 | |||
| 81e6bd7ae0 | |||
| 16e8f4996e | |||
| e8aa3c38a8 | |||
| f929cdbdef | |||
| fb01d0eaa9 |
32
CHANGELOG.md
32
CHANGELOG.md
@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## v2.0.5
|
||||
Released 15 October 2016
|
||||
* Fixed images when listing entries
|
||||
|
||||
## v2.0.4
|
||||
Released 5 October 2016
|
||||
* Cache (permanently) starred items so we can know everywhere if an item is starred or not without querying it
|
||||
* Colorize starred items
|
||||
* Hide artist in lists if it is unique
|
||||
* Removed list_artist_albums()
|
||||
* Include simpleplugin v2.0.1 as library and remove KODI dependency
|
||||
|
||||
## v2.0.3
|
||||
Released 4 October 2016
|
||||
* Random tracks submenu
|
||||
* Download tracks
|
||||
* Star tracks
|
||||
* Context menu for downloading or marking items as favorite
|
||||
|
||||
## v2.0.2
|
||||
* main code (main.py) fully rewritten
|
||||
* Use SimplePlugin framework (http://romanvm.github.io/script.module.simpleplugin/index.html)
|
||||
* Cache datas
|
||||
|
||||
## v2.0.1
|
||||
* New setting 'albums_per_page'
|
||||
* New menu structure
|
||||
* Albums : Newest / Most played / Rencently played / by Genre
|
||||
* Tracks : Starred / Random by genre / Random by year
|
||||
* Upgraded: libsonic to v.0.5.0 (https://github.com/crustymonkey/py-sonic)
|
||||
* Code cleanup
|
||||
|
||||
## v2.0.0
|
||||
Released 14 September 2015
|
||||
|
||||
|
||||
27
README.md
27
README.md
@ -1,19 +1,30 @@
|
||||
# Subsonic for Kodi
|
||||
Kodi plugin to stream music from Subsonic.
|
||||
# Subsonic
|
||||
Kodi plugin to stream, star and download music from Subsonic.
|
||||
For feature requests / issues:
|
||||
https://github.com/gordielachance/plugin.audio.subsonic/issues
|
||||
Contributions are welcome:
|
||||
https://github.com/gordielachance/plugin.audio.subsonic
|
||||
|
||||
## Features
|
||||
* Playlist support
|
||||
* Browse by artist, album and genre
|
||||
* Random playlists
|
||||
* Browse by artist, albums (newest/most played/recently played/random), tracks (starred/random), and playlists
|
||||
* Download songs
|
||||
* Star songs
|
||||
|
||||
## Installation
|
||||
* Navigate to your `.kodi/addons/` folder
|
||||
* Clone this repository: `git clone https://github.com/basilfx/plugin.audio.subsonic.git`
|
||||
* Clone this repository: `git clone https://github.com/gordielachance/plugin.audio.subsonic.git`
|
||||
* (Re)start Kodi.
|
||||
|
||||
## TODO
|
||||
* Scrobble to Last.FM (http://forum.kodi.tv/showthread.php?tid=195597&pid=2429362#pid2429362)
|
||||
* Improve the caching system
|
||||
* Search filter GUI for tracks and albums
|
||||
|
||||
## License
|
||||
See the `LICENSE` file.
|
||||
|
||||
Additional copyright notices:
|
||||
* The original [SubKodi](https://github.com/DarkAllMan/SubKodi) plugin.
|
||||
* [`py-sonic`](https://github.com/crustymonkey/py-sonic) Python module.
|
||||
* [Previous version of this plugin](https://github.com/basilfx/plugin.audio.subsonic) by basilfx
|
||||
* [SimplePlugin](https://github.com/romanvm/script.module.simpleplugin/stargazers) by romanvm
|
||||
* The original [SubKodi](https://github.com/DarkAllMan/SubKodi) plugin
|
||||
* [`py-sonic`](https://github.com/crustymonkey/py-sonic) Python module
|
||||
|
||||
383
addon.py
383
addon.py
@ -1,383 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import urllib
|
||||
import urlparse
|
||||
|
||||
import xbmc
|
||||
import xbmcgui
|
||||
import xbmcaddon
|
||||
import xbmcplugin
|
||||
|
||||
# Make sure library folder is on the path
|
||||
addon = xbmcaddon.Addon("plugin.audio.subsonic")
|
||||
sys.path.append(xbmc.translatePath(os.path.join(
|
||||
addon.getAddonInfo("path"), "lib")))
|
||||
|
||||
import libsonic_extra
|
||||
|
||||
|
||||
class Plugin(object):
|
||||
"""
|
||||
Plugin container.
|
||||
"""
|
||||
|
||||
def __init__(self, addon_url, addon_handle, addon_args):
|
||||
self.addon_url = addon_url
|
||||
self.addon_handle = addon_handle
|
||||
self.addon_args = addon_args
|
||||
|
||||
# Retrieve plugin settings
|
||||
self.url = addon.getSetting("subsonic_url")
|
||||
self.username = addon.getSetting("username")
|
||||
self.password = addon.getSetting("password")
|
||||
|
||||
self.random_count = addon.getSetting("random_count")
|
||||
self.bitrate = addon.getSetting("bitrate")
|
||||
self.transcode_format = addon.getSetting("transcode_format")
|
||||
|
||||
# Create connection
|
||||
self.connection = libsonic_extra.SubsonicClient(
|
||||
self.url, self.username, self.password)
|
||||
|
||||
def build_url(self, query):
|
||||
"""
|
||||
Create URL for page.
|
||||
"""
|
||||
|
||||
parts = list(urlparse.urlparse(self.addon_url))
|
||||
parts[4] = urllib.urlencode(query)
|
||||
|
||||
return urlparse.urlunparse(parts)
|
||||
|
||||
def route(self):
|
||||
"""
|
||||
Map a Kodi request to certain action. This takes the `mode' query
|
||||
parameter and executed the function in this instance with that name.
|
||||
"""
|
||||
|
||||
mode = self.addon_args.get("mode", ["main_page"])[0]
|
||||
|
||||
if not mode.startswith("_"):
|
||||
getattr(self, mode)()
|
||||
|
||||
def add_track(self, track, show_artist=False):
|
||||
"""
|
||||
Display one track in the list.
|
||||
"""
|
||||
|
||||
url = self.connection.streamUrl(
|
||||
sid=track["id"], maxBitRate=self.bitrate,
|
||||
tformat=self.transcode_format)
|
||||
|
||||
# Create list item
|
||||
if show_artist:
|
||||
title = "%s - %s" % (
|
||||
track.get("artist", "<Unknown>"),
|
||||
track.get("title", "<Unknown>"))
|
||||
else:
|
||||
title = track.get("title", "<Unknown>")
|
||||
|
||||
# Create item
|
||||
li = xbmcgui.ListItem(title)
|
||||
|
||||
# Handle cover art
|
||||
if "coverArt" in track:
|
||||
cover_art_url = self.connection.getCoverArtUrl(track["coverArt"])
|
||||
|
||||
li.setIconImage(cover_art_url)
|
||||
li.setThumbnailImage(cover_art_url)
|
||||
li.setProperty("fanart_image", cover_art_url)
|
||||
|
||||
# Handle metadata
|
||||
li.setProperty("IsPlayable", "true")
|
||||
li.setMimeType(track.get("contentType"))
|
||||
li.setInfo(type="Music", infoLabels={
|
||||
"Artist": track.get("artist"),
|
||||
"Title": track.get("title"),
|
||||
"Year": track.get("year"),
|
||||
"Duration": track.get("duration"),
|
||||
"Genre": track.get("genre"),
|
||||
"TrackNumber": track.get("track")})
|
||||
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li)
|
||||
|
||||
def add_album(self, album, show_artist=False):
|
||||
"""
|
||||
Display one album in the list.
|
||||
"""
|
||||
|
||||
url = self.build_url({
|
||||
"mode": "track_list",
|
||||
"album_id": album["id"]})
|
||||
|
||||
# Create list item
|
||||
if show_artist:
|
||||
title = "%s - %s" % (
|
||||
album.get("artist", "<Unknown>"),
|
||||
album.get("name", "<Unknown>"))
|
||||
else:
|
||||
title = album.get("name", "<Unknown>")
|
||||
|
||||
# Add year if applicable
|
||||
if album.get("year"):
|
||||
title = "%s [%d]" % (title, album.get("year"))
|
||||
|
||||
# Create item
|
||||
li = xbmcgui.ListItem()
|
||||
li.setLabel(title)
|
||||
|
||||
# Handle cover art
|
||||
if "coverArt" in album:
|
||||
cover_art_url = self.connection.getCoverArtUrl(album["coverArt"])
|
||||
|
||||
li.setIconImage(cover_art_url)
|
||||
li.setThumbnailImage(cover_art_url)
|
||||
li.setProperty("fanart_image", cover_art_url)
|
||||
|
||||
# Handle metadata
|
||||
li.setInfo(type="music", infoLabels={
|
||||
"Artist": album.get("artist"),
|
||||
"Album": album.get("name"),
|
||||
"Year": album.get("year")})
|
||||
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
def main_page(self):
|
||||
"""
|
||||
Display main menu.
|
||||
"""
|
||||
|
||||
menu = [
|
||||
{"mode": "starred_list", "foldername": "Starred"},
|
||||
{"mode": "playlists_list", "foldername": "Playlists"},
|
||||
{"mode": "artist_list", "foldername": "Artists"},
|
||||
{"mode": "genre_list", "foldername": "Genres"},
|
||||
{"mode": "random_list", "foldername": "Random songs"}]
|
||||
|
||||
for entry in menu:
|
||||
url = self.build_url(entry)
|
||||
|
||||
li = xbmcgui.ListItem(entry["foldername"])
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def starred_list(self):
|
||||
"""
|
||||
Display starred songs.
|
||||
"""
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "songs")
|
||||
|
||||
for starred in self.connection.walk_starred():
|
||||
self.add_track(starred, show_artist=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def playlists_list(self):
|
||||
"""
|
||||
Display playlists.
|
||||
"""
|
||||
|
||||
for playlist in self.connection.walk_playlists():
|
||||
cover_art_url = self.connection.getCoverArtUrl(
|
||||
playlist["coverArt"])
|
||||
url = self.build_url({
|
||||
"mode": "playlist_list", "playlist_id": playlist["id"]})
|
||||
|
||||
li = xbmcgui.ListItem(playlist["name"], iconImage=cover_art_url)
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def playlist_list(self):
|
||||
"""
|
||||
Display playlist tracks.
|
||||
"""
|
||||
|
||||
playlist_id = self.addon_args["playlist_id"][0]
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "songs")
|
||||
|
||||
for track in self.connection.walk_playlist(playlist_id):
|
||||
self.add_track(track, show_artist=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def genre_list(self):
|
||||
"""
|
||||
Display list of genres menu.
|
||||
"""
|
||||
|
||||
for genre in self.connection.walk_genres():
|
||||
url = self.build_url({
|
||||
"mode": "albums_by_genre_list",
|
||||
"foldername": genre["value"].encode("utf-8")})
|
||||
|
||||
li = xbmcgui.ListItem(genre["value"])
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def albums_by_genre_list(self):
|
||||
"""
|
||||
Display album list by genre menu.
|
||||
"""
|
||||
|
||||
genre = self.addon_args["foldername"][0].decode("utf-8")
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "albums")
|
||||
|
||||
for album in self.connection.walk_album_list_genre(genre):
|
||||
self.add_album(album, show_artist=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def artist_list(self):
|
||||
"""
|
||||
Display artist list
|
||||
"""
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "artists")
|
||||
|
||||
for artist in self.connection.walk_artists():
|
||||
cover_art_url = self.connection.getCoverArtUrl(artist["id"])
|
||||
url = self.build_url({
|
||||
"mode": "album_list",
|
||||
"artist_id": artist["id"]})
|
||||
|
||||
li = xbmcgui.ListItem(artist["name"])
|
||||
li.setIconImage(cover_art_url)
|
||||
li.setThumbnailImage(cover_art_url)
|
||||
li.setProperty("fanart_image", cover_art_url)
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def album_list(self):
|
||||
"""
|
||||
Display list of albums for certain artist.
|
||||
"""
|
||||
|
||||
artist_id = self.addon_args["artist_id"][0]
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "albums")
|
||||
|
||||
for album in self.connection.walk_artist(artist_id):
|
||||
self.add_album(album)
|
||||
|
||||
xbmcplugin.addSortMethod(
|
||||
self.addon_handle, xbmcplugin.SORT_METHOD_UNSORTED)
|
||||
xbmcplugin.addSortMethod(
|
||||
self.addon_handle, xbmcplugin.SORT_METHOD_ALBUM)
|
||||
xbmcplugin.addSortMethod(
|
||||
self.addon_handle, xbmcplugin.SORT_METHOD_ARTIST)
|
||||
xbmcplugin.addSortMethod(
|
||||
self.addon_handle, xbmcplugin.SORT_METHOD_VIDEO_YEAR)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def track_list(self):
|
||||
"""
|
||||
Display track list.
|
||||
"""
|
||||
|
||||
album_id = self.addon_args["album_id"][0]
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "songs")
|
||||
|
||||
for track in self.connection.walk_album(album_id):
|
||||
self.add_track(track)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def random_list(self):
|
||||
"""
|
||||
Display random options.
|
||||
"""
|
||||
|
||||
menu = [
|
||||
{"mode": "random_by_genre_list", "foldername": "By genre"},
|
||||
{"mode": "random_by_year_list", "foldername": "By year"}]
|
||||
|
||||
for entry in menu:
|
||||
url = self.build_url(entry)
|
||||
|
||||
li = xbmcgui.ListItem(entry["foldername"])
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def random_by_genre_list(self):
|
||||
"""
|
||||
Display random genre list.
|
||||
"""
|
||||
|
||||
for genre in self.connection.walk_genres():
|
||||
url = self.build_url({
|
||||
"mode": "random_by_genre_track_list",
|
||||
"foldername": genre["value"].encode("utf-8")})
|
||||
|
||||
li = xbmcgui.ListItem(genre["value"])
|
||||
xbmcplugin.addDirectoryItem(
|
||||
handle=self.addon_handle, url=url, listitem=li, isFolder=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def random_by_genre_track_list(self):
|
||||
"""
|
||||
Display random tracks by genre
|
||||
"""
|
||||
|
||||
genre = self.addon_args["foldername"][0].decode("utf-8")
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "songs")
|
||||
|
||||
for track in self.connection.walk_random_songs(
|
||||
size=self.random_count, genre=genre):
|
||||
self.add_track(track, show_artist=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
def random_by_year_list(self):
|
||||
"""
|
||||
Display random tracks by year.
|
||||
"""
|
||||
|
||||
from_year = xbmcgui.Dialog().input(
|
||||
"From year", type=xbmcgui.INPUT_NUMERIC)
|
||||
to_year = xbmcgui.Dialog().input(
|
||||
"To year", type=xbmcgui.INPUT_NUMERIC)
|
||||
|
||||
xbmcplugin.setContent(self.addon_handle, "songs")
|
||||
|
||||
for track in self.connection.walk_random_songs(
|
||||
size=self.random_count, from_year=from_year, to_year=to_year):
|
||||
self.add_track(track, show_artist=True)
|
||||
|
||||
xbmcplugin.endOfDirectory(self.addon_handle)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Entry point for this plugin. Instantiates a new plugin object and runs the
|
||||
action that is given.
|
||||
"""
|
||||
|
||||
addon_url = sys.argv[0]
|
||||
addon_handle = int(sys.argv[1])
|
||||
addon_args = urlparse.parse_qs(sys.argv[2][1:])
|
||||
|
||||
# Route request to action.
|
||||
Plugin(addon_url, addon_handle, addon_args).route()
|
||||
|
||||
# Start plugin from within Kodi.
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
addon.xml
23
addon.xml
@ -1,21 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="plugin.audio.subsonic" name="Subsonic" version="2.0.0" provider-name="BasilFX">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="2.19.0"/>
|
||||
</requires>
|
||||
<extension point="xbmc.python.pluginsource" library="addon.py">
|
||||
<addon id="plugin.audio.subsonic" name="Subsonic" version="2.0.5" provider-name="BasilFX,grosbouff">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="2.14.0"/>
|
||||
<import addon="script.module.dateutil" version="2.4.2"/>
|
||||
</requires>
|
||||
<extension point="xbmc.python.pluginsource" library="main.py">
|
||||
<provides>audio</provides>
|
||||
</extension>
|
||||
</extension>
|
||||
<extension point="xbmc.addon.metadata">
|
||||
<summary lang="en">Subsonic music addon for Kodi.</summary>
|
||||
<description lang="en">Subsonic music addon for Kodi. Stream your tunes directly to Kodi.</description>
|
||||
<description lang="en">
|
||||
Stream, star and download your tunes, directly to Kodi !
|
||||
For feature requests / issues:
|
||||
https://github.com/gordielachance/plugin.audio.subsonic/issues
|
||||
Contributions are welcome:
|
||||
https://github.com/gordielachance/plugin.audio.subsonic
|
||||
</description>
|
||||
<disclaimer lang="en"></disclaimer>
|
||||
<language>en</language>
|
||||
<platform>all</platform>
|
||||
<license>MIT</license>
|
||||
<forum></forum>
|
||||
<website>http://www.subsonic.org</website>
|
||||
<source>https://github.com/basilfx/plugin.audio.subsonic</source>
|
||||
<source>https://github.com/gordielachance/plugin.audio.subsonic</source>
|
||||
<email></email>
|
||||
</extension>
|
||||
</addon>
|
||||
|
||||
@ -29,4 +29,4 @@ print conn.ping()
|
||||
|
||||
from connection import *
|
||||
|
||||
__version__ = '0.3.4'
|
||||
__version__ = '0.6.2'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,6 @@ import urllib
|
||||
import urlparse
|
||||
import libsonic
|
||||
|
||||
|
||||
def force_list(value):
|
||||
"""
|
||||
Coerce the input value to a list.
|
||||
@ -34,7 +33,7 @@ class SubsonicClient(libsonic.Connection):
|
||||
- Add conventient `walk_*' methods to iterate over the API responses.
|
||||
"""
|
||||
|
||||
def __init__(self, url, username, password):
|
||||
def __init__(self, url, username, password, apiversion, insecure, legacyauth):
|
||||
"""
|
||||
Construct a new SubsonicClient.
|
||||
|
||||
@ -64,7 +63,7 @@ class SubsonicClient(libsonic.Connection):
|
||||
|
||||
# Invoke original constructor
|
||||
super(SubsonicClient, self).__init__(
|
||||
host, username, password, port=port)
|
||||
host, username, password, port=port, appName='Kodi', apiVersion=apiversion, insecure=insecure, legacyAuth=legacyauth)
|
||||
|
||||
def getIndexes(self, *args, **kwargs):
|
||||
"""
|
||||
@ -336,16 +335,6 @@ class SubsonicClient(libsonic.Connection):
|
||||
for child in response["playlist"]["entry"]:
|
||||
yield child
|
||||
|
||||
def walk_starred(self):
|
||||
"""
|
||||
Request Subsonic's starred songs and iterate over each item.
|
||||
"""
|
||||
|
||||
response = self.getStarred()
|
||||
|
||||
for song in response["starred"]["song"]:
|
||||
yield song
|
||||
|
||||
def walk_directory(self, directory_id):
|
||||
"""
|
||||
Request a Subsonic music directory and iterate over each item.
|
||||
@ -391,24 +380,26 @@ class SubsonicClient(libsonic.Connection):
|
||||
for genre in response["genres"]["genre"]:
|
||||
yield genre
|
||||
|
||||
def walk_album_list_genre(self, genre):
|
||||
def walk_albums(self, ltype, size=None, fromYear=None,toYear=None, genre=None, offset=None):
|
||||
"""
|
||||
Request all albums for a given genre and iterate over each album.
|
||||
"""
|
||||
|
||||
offset = 0
|
||||
if ltype == 'byGenre' and genre is None:
|
||||
return
|
||||
|
||||
if ltype == 'byYear' and (fromYear is None or toYear is None):
|
||||
return
|
||||
|
||||
while True:
|
||||
response = self.getAlbumList2(
|
||||
ltype="byGenre", genre=genre, size=500, offset=offset)
|
||||
ltype=ltype, size=size, fromYear=fromYear, toYear=toYear,genre=genre, offset=offset)
|
||||
|
||||
if not response["albumList2"]["album"]:
|
||||
break
|
||||
return
|
||||
|
||||
for album in response["albumList2"]["album"]:
|
||||
yield album
|
||||
|
||||
offset += 500
|
||||
|
||||
def walk_album(self, album_id):
|
||||
"""
|
||||
@ -420,14 +411,24 @@ class SubsonicClient(libsonic.Connection):
|
||||
for song in response["album"]["song"]:
|
||||
yield song
|
||||
|
||||
def walk_random_songs(self, size, genre=None, from_year=None,
|
||||
to_year=None):
|
||||
def walk_tracks_random(self, size=None, genre=None, fromYear=None,toYear=None):
|
||||
"""
|
||||
Request random songs by genre and/or year and iterate over each song.
|
||||
"""
|
||||
|
||||
response = self.getRandomSongs(
|
||||
size=size, genre=genre, fromYear=from_year, toYear=to_year)
|
||||
size=size, genre=genre, fromYear=fromYear, toYear=toYear)
|
||||
|
||||
for song in response["randomSongs"]["song"]:
|
||||
yield song
|
||||
|
||||
|
||||
def walk_tracks_starred(self):
|
||||
"""
|
||||
Request Subsonic's starred songs and iterate over each item.
|
||||
"""
|
||||
|
||||
response = self.getStarred()
|
||||
|
||||
for song in response["starred"]["song"]:
|
||||
yield song
|
||||
|
||||
4
lib/simpleplugin/__init__.py
Normal file
4
lib/simpleplugin/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
#v2.0.1
|
||||
#https://github.com/romanvm/script.module.simpleplugin/releases
|
||||
|
||||
from simpleplugin import *
|
||||
915
lib/simpleplugin/simpleplugin.py
Normal file
915
lib/simpleplugin/simpleplugin.py
Normal file
@ -0,0 +1,915 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Created on: 03.06.2015
|
||||
"""
|
||||
SimplePlugin micro-framework for Kodi content plugins
|
||||
|
||||
**Author**: Roman Miroshnychenko aka Roman V.M.
|
||||
|
||||
**License**: `GPL v.3 <https://www.gnu.org/copyleft/gpl.html>`_
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
import cPickle as pickle
|
||||
from urlparse import parse_qs
|
||||
from urllib import urlencode
|
||||
from functools import wraps
|
||||
from collections import MutableMapping, namedtuple
|
||||
from copy import deepcopy
|
||||
from types import GeneratorType
|
||||
from hashlib import md5
|
||||
from shutil import move
|
||||
import xbmcaddon
|
||||
import xbmc
|
||||
import xbmcplugin
|
||||
import xbmcgui
|
||||
|
||||
__all__ = ['SimplePluginError', 'Storage', 'Addon', 'Plugin', 'Params']
|
||||
|
||||
ListContext = namedtuple('ListContext', ['listing', 'succeeded', 'update_listing', 'cache_to_disk',
|
||||
'sort_methods', 'view_mode', 'content'])
|
||||
PlayContext = namedtuple('PlayContext', ['path', 'play_item', 'succeeded'])
|
||||
|
||||
|
||||
class SimplePluginError(Exception):
|
||||
"""Custom exception"""
|
||||
pass
|
||||
|
||||
|
||||
class Params(dict):
|
||||
"""
|
||||
Params(**kwargs)
|
||||
|
||||
A class that stores parsed plugin call parameters
|
||||
|
||||
Parameters can be accessed both through :class:`dict` keys and
|
||||
instance properties.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@plugin.action('foo')
|
||||
def action(params):
|
||||
foo = params['foo'] # Access by key
|
||||
bar = params.bar # Access through property. Both variants are equal
|
||||
"""
|
||||
def __getattr__(self, item):
|
||||
if item not in self:
|
||||
raise AttributeError('Invalid parameter: "{0}"!'.format(item))
|
||||
return self[item]
|
||||
|
||||
def __str__(self):
|
||||
return '<Params {0}>'.format(super(Params, self).__repr__())
|
||||
|
||||
def __repr__(self):
|
||||
return '<simpleplugin.Params object {0}>'.format(super(Params, self).__repr__())
|
||||
|
||||
|
||||
class Storage(MutableMapping):
|
||||
"""
|
||||
Persistent storage for arbitrary data with a dictionary-like interface
|
||||
|
||||
It is designed as a context manager and better be used
|
||||
with 'with' statement.
|
||||
|
||||
:param storage_dir: directory for storage
|
||||
:type storage_dir: str
|
||||
:param filename: the name of a storage file (optional)
|
||||
:type filename: str
|
||||
|
||||
Usage::
|
||||
|
||||
with Storage('/foo/bar/storage/') as storage:
|
||||
storage['key1'] = value1
|
||||
value2 = storage['key2']
|
||||
|
||||
.. note:: After exiting :keyword:`with` block a :class:`Storage` instance is invalidated.
|
||||
Storage contents are saved to disk only for a new storage or if the contents have been changed.
|
||||
"""
|
||||
def __init__(self, storage_dir, filename='storage.pcl'):
|
||||
"""
|
||||
Class constructor
|
||||
"""
|
||||
self._storage = {}
|
||||
self._hash = None
|
||||
self._filename = os.path.join(storage_dir, filename)
|
||||
try:
|
||||
with open(self._filename, 'rb') as fo:
|
||||
contents = fo.read()
|
||||
self._storage = pickle.loads(contents)
|
||||
self._hash = md5(contents).hexdigest()
|
||||
except (IOError, pickle.PickleError, EOFError):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.flush()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._storage[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self._storage[key] = value
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self._storage[key]
|
||||
|
||||
def __iter__(self):
|
||||
return self._storage.__iter__()
|
||||
|
||||
def __len__(self):
|
||||
return len(self._storage)
|
||||
|
||||
def __str__(self):
|
||||
return '<Storage {0}>'.format(self._storage)
|
||||
|
||||
def __repr__(self):
|
||||
return '<simpleplugin.Storage object {0}>'.format(self._storage)
|
||||
|
||||
def flush(self):
|
||||
"""
|
||||
Save storage contents to disk
|
||||
|
||||
This method saves new and changed :class:`Storage` contents to disk
|
||||
and invalidates the Storage instance. Unchanged Storage is not saved
|
||||
but simply invalidated.
|
||||
"""
|
||||
contents = pickle.dumps(self._storage)
|
||||
if self._hash is None or md5(contents).hexdigest() != self._hash:
|
||||
tmp = self._filename + '.tmp'
|
||||
try:
|
||||
with open(tmp, 'wb') as fo:
|
||||
fo.write(contents)
|
||||
except:
|
||||
os.remove(tmp)
|
||||
raise
|
||||
move(tmp, self._filename) # Atomic save
|
||||
del self._storage
|
||||
|
||||
def copy(self):
|
||||
"""
|
||||
Make a copy of storage contents
|
||||
|
||||
.. note:: this method performs a *deep* copy operation.
|
||||
|
||||
:return: a copy of storage contents
|
||||
:rtype: dict
|
||||
"""
|
||||
return deepcopy(self._storage)
|
||||
|
||||
|
||||
class Addon(object):
|
||||
"""
|
||||
Base addon class
|
||||
|
||||
Provides access to basic addon parameters
|
||||
|
||||
:param id_: addon id, e.g. 'plugin.video.foo' (optional)
|
||||
:type id_: str
|
||||
"""
|
||||
def __init__(self, id_=''):
|
||||
"""
|
||||
Class constructor
|
||||
"""
|
||||
self._addon = xbmcaddon.Addon(id_)
|
||||
self._configdir = xbmc.translatePath(self._addon.getAddonInfo('profile')).decode('utf-8')
|
||||
self._ui_strings_map = None
|
||||
if not os.path.exists(self._configdir):
|
||||
os.mkdir(self._configdir)
|
||||
|
||||
def __getattr__(self, item):
|
||||
"""
|
||||
Get addon setting as an Addon instance attribute
|
||||
|
||||
E.g. addon.my_setting is equal to addon.get_setting('my_setting')
|
||||
|
||||
:param item:
|
||||
:type item: str
|
||||
"""
|
||||
return self.get_setting(item)
|
||||
|
||||
def __str__(self):
|
||||
return '<Addon [{0}]>'.format(self.id)
|
||||
|
||||
def __repr__(self):
|
||||
return '<simpleplugin.Addon object [{0}]>'.format(self.id)
|
||||
|
||||
@property
|
||||
def addon(self):
|
||||
"""
|
||||
Kodi Addon instance that represents this Addon
|
||||
|
||||
:return: Addon instance
|
||||
:rtype: xbmcaddon.Addon
|
||||
"""
|
||||
return self._addon
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""
|
||||
Addon ID
|
||||
|
||||
:return: Addon ID, e.g. 'plugin.video.foo'
|
||||
:rtype: str
|
||||
"""
|
||||
return self._addon.getAddonInfo('id')
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""
|
||||
Addon path
|
||||
|
||||
:return: path to the addon folder
|
||||
:rtype: str
|
||||
"""
|
||||
return self._addon.getAddonInfo('path').decode('utf-8')
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""
|
||||
Addon icon
|
||||
|
||||
:return: path to the addon icon image
|
||||
:rtype: str
|
||||
"""
|
||||
icon = os.path.join(self.path, 'icon.png')
|
||||
if os.path.exists(icon):
|
||||
return icon
|
||||
else:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def fanart(self):
|
||||
"""
|
||||
Addon fanart
|
||||
|
||||
:return: path to the addon fanart image
|
||||
:rtype: str
|
||||
"""
|
||||
fanart = os.path.join(self.path, 'fanart.jpg')
|
||||
if os.path.exists(fanart):
|
||||
return fanart
|
||||
else:
|
||||
return ''
|
||||
|
||||
@property
|
||||
def config_dir(self):
|
||||
"""
|
||||
Addon config dir
|
||||
|
||||
:return: path to the addon config dir
|
||||
:rtype: str
|
||||
"""
|
||||
return self._configdir
|
||||
|
||||
def get_localized_string(self, id_):
|
||||
"""
|
||||
Get localized UI string
|
||||
|
||||
:param id_: UI string ID
|
||||
:type id_: int
|
||||
:return: UI string in the current language
|
||||
:rtype: unicode
|
||||
"""
|
||||
return self._addon.getLocalizedString(id_).encode('utf-8')
|
||||
|
||||
def get_setting(self, id_, convert=True):
|
||||
"""
|
||||
Get addon setting
|
||||
|
||||
If ``convert=True``, 'bool' settings are converted to Python :class:`bool` values,
|
||||
and numeric strings to Python :class:`long` or :class:`float` depending on their format.
|
||||
|
||||
.. note:: Settings can also be read via :class:`Addon` instance poperties named as the respective settings.
|
||||
I.e. ``addon.foo`` is equal to ``addon.get_setting('foo')``.
|
||||
|
||||
:param id_: setting ID
|
||||
:type id_: str
|
||||
:param convert: try to guess and convert the setting to an appropriate type
|
||||
E.g. ``'1.0'`` will be converted to float ``1.0`` number, ``'true'`` to ``True`` and so on.
|
||||
:type convert: bool
|
||||
:return: setting value
|
||||
"""
|
||||
setting = self._addon.getSetting(id_)
|
||||
if convert:
|
||||
if setting == 'true':
|
||||
return True # Convert boolean strings to bool
|
||||
elif setting == 'false':
|
||||
return False
|
||||
elif re.search(r'^-?\d+$', setting) is not None:
|
||||
return long(setting) # Convert numeric strings to long
|
||||
elif re.search(r'^-?\d+\.\d+$', setting) is not None:
|
||||
return float(setting) # Convert numeric strings with a dot to float
|
||||
return setting
|
||||
|
||||
def set_setting(self, id_, value):
|
||||
"""
|
||||
Set addon setting
|
||||
|
||||
Python :class:`bool` type are converted to ``'true'`` or ``'false'``
|
||||
Non-string/non-unicode values are converted to strings.
|
||||
|
||||
.. warning:: Setting values via :class:`Addon` instance properties is not supported!
|
||||
Values can only be set using :meth:`Addon.set_setting` method.
|
||||
|
||||
:param id_: setting ID
|
||||
:type id_: str
|
||||
:param value: setting value
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
value = 'true' if value else 'false'
|
||||
elif not isinstance(value, basestring):
|
||||
value = str(value)
|
||||
self._addon.setSetting(id_, value)
|
||||
|
||||
def log(self, message, level=0):
|
||||
"""
|
||||
Add message to Kodi log starting with Addon ID
|
||||
|
||||
:param message: message to be written into the Kodi log
|
||||
:type message: str
|
||||
:param level: log level. :mod:`xbmc` module provides the necessary symbolic constants.
|
||||
Default: ``xbmc.LOGDEBUG``
|
||||
:type level: int
|
||||
"""
|
||||
if isinstance(message, unicode):
|
||||
message = message.encode('utf-8')
|
||||
xbmc.log('{0}: {1}'.format(self.id, message), level)
|
||||
|
||||
def log_notice(self, message):
|
||||
"""
|
||||
Add NOTICE message to the Kodi log
|
||||
|
||||
:param message: message to write to the Kodi log
|
||||
:type message: str
|
||||
"""
|
||||
self.log(message, xbmc.LOGINFO)
|
||||
|
||||
def log_warning(self, message):
|
||||
"""
|
||||
Add WARNING message to the Kodi log
|
||||
|
||||
:param message: message to write to the Kodi log
|
||||
:type message: str
|
||||
"""
|
||||
self.log(message, xbmc.LOGWARNING)
|
||||
|
||||
def log_error(self, message):
|
||||
"""
|
||||
Add ERROR message to the Kodi log
|
||||
|
||||
:param message: message to write to the Kodi log
|
||||
:type message: str
|
||||
"""
|
||||
self.log(message, xbmc.LOGERROR)
|
||||
|
||||
def log_debug(self, message):
|
||||
"""
|
||||
Add debug message to the Kodi log
|
||||
|
||||
:param message: message to write to the Kodi log
|
||||
:type message: str
|
||||
"""
|
||||
self.log(message, xbmc.LOGDEBUG)
|
||||
|
||||
def get_storage(self, filename='storage.pcl'):
|
||||
"""
|
||||
Get a persistent :class:`Storage` instance for storing arbitrary values between addon calls.
|
||||
|
||||
A :class:`Storage` instance can be used as a context manager.
|
||||
|
||||
Example::
|
||||
|
||||
with plugin.get_storage() as storage:
|
||||
storage['param1'] = value1
|
||||
value2 = storage['param2']
|
||||
|
||||
.. note:: After exiting :keyword:`with` block a :class:`Storage` instance is invalidated.
|
||||
|
||||
:param filename: the name of a storage file (optional)
|
||||
:type filename: str
|
||||
:return: Storage object
|
||||
:rtype: Storage
|
||||
"""
|
||||
return Storage(self.config_dir, filename)
|
||||
|
||||
def cached(self, duration=10):
|
||||
"""
|
||||
Cached decorator
|
||||
|
||||
Used to cache function return data
|
||||
|
||||
Usage::
|
||||
|
||||
@plugin.cached(30)
|
||||
def my_func(*args, **kwargs):
|
||||
# Do some stuff
|
||||
return value
|
||||
|
||||
:param duration: caching duration in min (positive values only)
|
||||
:type duration: int
|
||||
:raises ValueError: if duration is zero or negative
|
||||
"""
|
||||
def outer_wrapper(func):
|
||||
@wraps(func)
|
||||
def inner_wrapper(*args, **kwargs):
|
||||
with self.get_storage('__cache__.pcl') as cache:
|
||||
current_time = datetime.now()
|
||||
key = func.__name__ + str(args) + str(kwargs)
|
||||
try:
|
||||
data, timestamp = cache[key]
|
||||
if duration > 0 and current_time - timestamp > timedelta(minutes=duration):
|
||||
raise KeyError
|
||||
elif duration <= 0:
|
||||
raise ValueError('Caching duration cannot be zero or negative!')
|
||||
self.log_debug('Cache hit: {0}'.format(key))
|
||||
except KeyError:
|
||||
self.log_debug('Cache miss: {0}'.format(key))
|
||||
data = func(*args, **kwargs)
|
||||
cache[key] = (data, current_time)
|
||||
return data
|
||||
return inner_wrapper
|
||||
return outer_wrapper
|
||||
|
||||
def gettext(self, ui_string):
|
||||
"""
|
||||
Get a translated UI string from addon localization files.
|
||||
|
||||
This function emulates GNU Gettext for more convenient access
|
||||
to localized addon UI strings. To reduce typing this method object
|
||||
can be assigned to a ``_`` (single underscore) variable.
|
||||
|
||||
For using gettext emulation :meth:`Addon.initialize_gettext` method
|
||||
needs to be called first. See documentation for that method for more info
|
||||
about Gettext emulation.
|
||||
|
||||
:param ui_string: a UI string from English :file:`strings.po`.
|
||||
:type ui_string: str
|
||||
:return: a UI string from translated :file:`strings.po`.
|
||||
:rtype: unicode
|
||||
:raises simpleplugin.SimplePluginError: if :meth:`Addon.initialize_gettext` wasn't called first
|
||||
or if a string is not found in English :file:`strings.po`.
|
||||
"""
|
||||
if self._ui_strings_map is not None:
|
||||
try:
|
||||
return self.get_localized_string(self._ui_strings_map['strings'][ui_string])
|
||||
except KeyError:
|
||||
raise SimplePluginError('UI string "{0}" is not found in strings.po!'.format(ui_string))
|
||||
else:
|
||||
raise SimplePluginError('Addon localization is not initialized!')
|
||||
|
||||
def initialize_gettext(self):
|
||||
"""
|
||||
Initialize GNU gettext emulation in addon
|
||||
|
||||
Kodi localization system for addons is not very convenient
|
||||
because you need to operate with numeric string codes instead
|
||||
of UI strings themselves which reduces code readability and
|
||||
may lead to errors. The :class:`Addon` class provides facilities
|
||||
for emulating GNU Gettext localization system.
|
||||
|
||||
This allows to use UI strings from addon's English :file:`strings.po`
|
||||
file instead of numeric codes to return localized strings from
|
||||
respective localized :file:`.po` files.
|
||||
|
||||
This method returns :meth:`Addon.gettext` method object that
|
||||
can be assigned to a short alias to reduce typing. Traditionally,
|
||||
``_`` (a single underscore) is used for this purpose.
|
||||
|
||||
Example::
|
||||
|
||||
addon = simpleplugin.Addon()
|
||||
_ = addon.initialize_gettext()
|
||||
|
||||
xbmcgui.Dialog().notification(_('Warning!'), _('Something happened'))
|
||||
|
||||
In the preceding example the notification strings will be replaced
|
||||
with localized versions if these strings are translated.
|
||||
|
||||
:return: :meth:`Addon.gettext` method object
|
||||
:raises simpleplugin.SimplePluginError: if the addon's English :file:`strings.po` file is missing
|
||||
"""
|
||||
strings_po = os.path.join(self.path, 'resources', 'language', 'English', 'strings.po')
|
||||
if os.path.exists(strings_po):
|
||||
with open(strings_po, 'rb') as fo:
|
||||
raw_strings = fo.read()
|
||||
raw_strings_hash = md5(raw_strings).hexdigest()
|
||||
gettext_pcl = '__gettext__.pcl'
|
||||
with self.get_storage(gettext_pcl) as ui_strings_map:
|
||||
if (not os.path.exists(os.path.join(self._configdir, gettext_pcl)) or
|
||||
raw_strings_hash != ui_strings_map['hash']):
|
||||
ui_strings = self._parse_po(raw_strings.split('\n'))
|
||||
self._ui_strings_map = {
|
||||
'hash': raw_strings_hash,
|
||||
'strings': ui_strings
|
||||
}
|
||||
ui_strings_map['hash'] = raw_strings_hash
|
||||
ui_strings_map['strings'] = ui_strings.copy()
|
||||
else:
|
||||
self._ui_strings_map = deepcopy(ui_strings_map)
|
||||
else:
|
||||
raise SimplePluginError('Unable to initialize localization because of missing English strings.po!')
|
||||
return self.gettext
|
||||
|
||||
def _parse_po(self, strings):
|
||||
"""
|
||||
Parses ``strings.po`` file into a dict of {'string': id} items.
|
||||
"""
|
||||
ui_strings = {}
|
||||
string_id = None
|
||||
for string in strings:
|
||||
if string_id is None and 'msgctxt' in string:
|
||||
string_id = int(re.search(r'"#(\d+)"', string).group(1))
|
||||
elif string_id is not None and 'msgid' in string:
|
||||
ui_strings[re.search(r'"(.*?)"', string, re.U).group(1)] = string_id
|
||||
string_id = None
|
||||
return ui_strings
|
||||
|
||||
|
||||
class Plugin(Addon):
|
||||
"""
|
||||
Plugin class
|
||||
|
||||
:param id_: plugin's id, e.g. 'plugin.video.foo' (optional)
|
||||
:type id_: str
|
||||
|
||||
This class provides a simplified API to create virtual directories of playable items
|
||||
for Kodi content plugins.
|
||||
:class:`simpleplugin.Plugin` uses a concept of callable plugin actions (functions or methods)
|
||||
that are defined using :meth:`Plugin.action` decorator.
|
||||
A Plugin instance must have at least one action that is named ``'root'``.
|
||||
|
||||
Minimal example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from simpleplugin import Plugin
|
||||
|
||||
plugin = Plugin()
|
||||
|
||||
@plugin.action()
|
||||
def root(params): # Mandatory item!
|
||||
return [{'label': 'Foo',
|
||||
'url': plugin.get_url(action='some_action', param='Foo')},
|
||||
{'label': 'Bar',
|
||||
'url': plugin.get_url(action='some_action', param='Bar')}]
|
||||
|
||||
@plugin.action()
|
||||
def some_action(params):
|
||||
return [{'label': params['param']}]
|
||||
|
||||
plugin.run()
|
||||
|
||||
An action callable receives 1 parameter -- params.
|
||||
params is a dict-like object containing plugin call parameters (including action string)
|
||||
The action callable can return
|
||||
either a list/generator of dictionaries representing Kodi virtual directory items
|
||||
or a resolved playable path (:class:`str` or :obj:`unicode`) for Kodi to play.
|
||||
|
||||
Example 1::
|
||||
|
||||
@plugin.action()
|
||||
def list_action(params):
|
||||
listing = get_listing(params) # Some external function to create listing
|
||||
return listing
|
||||
|
||||
The ``listing`` variable is a Python list/generator of dict items.
|
||||
Example 2::
|
||||
|
||||
@plugin.action()
|
||||
def play_action(params):
|
||||
path = get_path(params) # Some external function to get a playable path
|
||||
return path
|
||||
|
||||
Each dict item can contain the following properties:
|
||||
|
||||
- label -- item's label (default: ``''``).
|
||||
- label2 -- item's label2 (default: ``''``).
|
||||
- thumb -- item's thumbnail (default: ``''``).
|
||||
- icon -- item's icon (default: ``''``).
|
||||
- path -- item's path (default: ``''``).
|
||||
- fanart -- item's fanart (optional).
|
||||
- art -- a dict containing all item's graphic (see :meth:`xbmcgui.ListItem.setArt` for more info) -- optional.
|
||||
- stream_info -- a dictionary of ``{stream_type: {param: value}}`` items
|
||||
(see :meth:`xbmcgui.ListItem.addStreamInfo`) -- optional.
|
||||
- info -- a dictionary of ``{media: {param: value}}`` items
|
||||
(see :meth:`xbmcgui.ListItem.setInfo`) -- optional
|
||||
- context_menu - a list that contains 2-item tuples ``('Menu label', 'Action')``.
|
||||
The items from the tuples are added to the item's context menu.
|
||||
- url -- a callback URL for this list item.
|
||||
- is_playable -- if ``True``, then this item is playable and must return a playable path or
|
||||
be resolved via :meth:`Plugin.resolve_url` (default: ``False``).
|
||||
- is_folder -- if ``True`` then the item will open a lower-level sub-listing. if ``False``,
|
||||
the item either is a playable media or a general-purpose script
|
||||
which neither creates a virtual folder nor points to a playable media (default: C{True}).
|
||||
if ``'is_playable'`` is set to ``True``, then ``'is_folder'`` value automatically assumed to be ``False``.
|
||||
- subtitles -- the list of paths to subtitle files (optional).
|
||||
- mime -- item's mime type (optional).
|
||||
- list_item -- an 'class:`xbmcgui.ListItem` instance (optional).
|
||||
It is used when you want to set all list item properties by yourself.
|
||||
If ``'list_item'`` property is present, all other properties,
|
||||
except for ``'url'`` and ``'is_folder'``, are ignored.
|
||||
- properties -- a dictionary of list item properties
|
||||
(see :meth:`xbmcgui.ListItem.setProperty`) -- optional.
|
||||
|
||||
Example 3::
|
||||
|
||||
listing = [{ 'label': 'Label',
|
||||
'label2': 'Label 2',
|
||||
'thumb': 'thumb.png',
|
||||
'icon': 'icon.png',
|
||||
'fanart': 'fanart.jpg',
|
||||
'art': {'clearart': 'clearart.png'},
|
||||
'stream_info': {'video': {'codec': 'h264', 'duration': 1200},
|
||||
'audio': {'codec': 'ac3', 'language': 'en'}},
|
||||
'info': {'video': {'genre': 'Comedy', 'year': 2005}},
|
||||
'context_menu': [('Menu Item', 'Action')],
|
||||
'url': 'plugin:/plugin.video.test/?action=play',
|
||||
'is_playable': True,
|
||||
'is_folder': False,
|
||||
'subtitles': ['/path/to/subtitles.en.srt', '/path/to/subtitles.uk.srt'],
|
||||
'mime': 'video/mp4'
|
||||
}]
|
||||
|
||||
Alternatively, an action callable can use :meth:`Plugin.create_listing` and :meth:`Plugin.resolve_url`
|
||||
static methods to pass additional parameters to Kodi.
|
||||
|
||||
Example 4::
|
||||
|
||||
@plugin.action()
|
||||
def list_action(params):
|
||||
listing = get_listing(params) # Some external function to create listing
|
||||
return Plugin.create_listing(listing, sort_methods=(2, 10, 17), view_mode=500)
|
||||
|
||||
Example 5::
|
||||
|
||||
@plugin.action()
|
||||
def play_action(params):
|
||||
path = get_path(params) # Some external function to get a playable path
|
||||
return Plugin.resolve_url(path, succeeded=True)
|
||||
|
||||
If an action callable performs any actions other than creating a listing or
|
||||
resolving a playable URL, it must return ``None``.
|
||||
"""
|
||||
def __init__(self, id_=''):
|
||||
"""
|
||||
Class constructor
|
||||
"""
|
||||
super(Plugin, self).__init__(id_)
|
||||
self._url = 'plugin://{0}/'.format(self.id)
|
||||
self._handle = None
|
||||
self.actions = {}
|
||||
|
||||
def __str__(self):
|
||||
return '<Plugin {0}>'.format(sys.argv)
|
||||
|
||||
def __repr__(self):
|
||||
return '<simpleplugin.Plugin object {0}>'.format(sys.argv)
|
||||
|
||||
@staticmethod
|
||||
def get_params(paramstring):
|
||||
"""
|
||||
Convert a URL-encoded paramstring to a Python dict
|
||||
|
||||
:param paramstring: URL-encoded paramstring
|
||||
:type paramstring: str
|
||||
:return: parsed paramstring
|
||||
:rtype: Params
|
||||
"""
|
||||
raw_params = parse_qs(paramstring)
|
||||
params = Params()
|
||||
for key, value in raw_params.iteritems():
|
||||
params[key] = value[0] if len(value) == 1 else value
|
||||
return params
|
||||
|
||||
def get_url(self, plugin_url='', **kwargs):
|
||||
"""
|
||||
Construct a callable URL for a virtual directory item
|
||||
|
||||
If plugin_url is empty, a current plugin URL is used.
|
||||
kwargs are converted to a URL-encoded string of plugin call parameters
|
||||
To call a plugin action, 'action' parameter must be used,
|
||||
if 'action' parameter is missing, then the plugin root action is called
|
||||
If the action is not added to :class:`Plugin` actions, :class:`PluginError` will be raised.
|
||||
|
||||
:param plugin_url: plugin URL with trailing / (optional)
|
||||
:type plugin_url: str
|
||||
:param kwargs: pairs of key=value items
|
||||
:return: a full plugin callback URL
|
||||
:rtype: str
|
||||
"""
|
||||
url = plugin_url or self._url
|
||||
if kwargs:
|
||||
return '{0}?{1}'.format(url, urlencode(kwargs, doseq=True))
|
||||
return url
|
||||
|
||||
def action(self, name=None):
|
||||
"""
|
||||
Action decorator
|
||||
|
||||
Defines plugin callback action. If action's name is not defined explicitly,
|
||||
then the action is named after the decorated function.
|
||||
|
||||
.. warning:: Action's name must be unique.
|
||||
|
||||
A plugin must have at least one action named ``'root'`` implicitly or explicitly.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@plugin.action() # The action is implicitly named 'root' after the decorated function
|
||||
def root(params):
|
||||
pass
|
||||
|
||||
@plugin.action('foo') # The action name is set explicitly
|
||||
def foo_action(params):
|
||||
pass
|
||||
|
||||
:param name: action's name (optional).
|
||||
:type name: str
|
||||
:raises simpleplugin.SimplePluginError: if the action with such name is already defined.
|
||||
"""
|
||||
def wrap(func, name=name):
|
||||
if name is None:
|
||||
name = func.__name__
|
||||
if name in self.actions:
|
||||
raise SimplePluginError('Action "{0}" already defined!'.format(name))
|
||||
self.actions[name] = func
|
||||
return func
|
||||
return wrap
|
||||
|
||||
def run(self, category=''):
|
||||
"""
|
||||
Run plugin
|
||||
|
||||
:param category: str - plugin sub-category, e.g. 'Comedy'.
|
||||
See :func:`xbmcplugin.setPluginCategory` for more info.
|
||||
:type category: str
|
||||
:raises simpleplugin.SimplePluginError: if unknown action string is provided.
|
||||
"""
|
||||
self._handle = int(sys.argv[1])
|
||||
if category:
|
||||
xbmcplugin.setPluginCategory(self._handle, category)
|
||||
params = self.get_params(sys.argv[2][1:])
|
||||
action = params.get('action', 'root')
|
||||
self.log_debug(str(self))
|
||||
self.log_debug('Actions: {0}'.format(str(self.actions.keys())))
|
||||
self.log_debug('Called action "{0}" with params "{1}"'.format(action, str(params)))
|
||||
try:
|
||||
action_callable = self.actions[action]
|
||||
except KeyError:
|
||||
raise SimplePluginError('Invalid action: "{0}"!'.format(action))
|
||||
else:
|
||||
result = action_callable(params)
|
||||
self.log_debug('Action return value: {0}'.format(str(result)))
|
||||
if isinstance(result, (list, GeneratorType)):
|
||||
self._add_directory_items(self.create_listing(result))
|
||||
elif isinstance(result, basestring):
|
||||
self._set_resolved_url(self.resolve_url(result))
|
||||
elif isinstance(result, tuple) and hasattr(result, 'listing'):
|
||||
self._add_directory_items(result)
|
||||
elif isinstance(result, tuple) and hasattr(result, 'path'):
|
||||
self._set_resolved_url(result)
|
||||
else:
|
||||
self.log_debug('The action "{0}" has not returned any valid data to process.'.format(action))
|
||||
|
||||
@staticmethod
|
||||
def create_listing(listing, succeeded=True, update_listing=False, cache_to_disk=False, sort_methods=None,
|
||||
view_mode=None, content=None):
|
||||
"""
|
||||
Create and return a context dict for a virtual folder listing
|
||||
|
||||
:param listing: the list of the plugin virtual folder items
|
||||
:type listing: :class:`list` or :class:`types.GeneratorType`
|
||||
:param succeeded: if ``False`` Kodi won't open a new listing and stays on the current level.
|
||||
:type succeeded: bool
|
||||
:param update_listing: if ``True``, Kodi won't open a sub-listing but refresh the current one.
|
||||
:type update_listing: bool
|
||||
:param cache_to_disk: cache this view to disk.
|
||||
:type cache_to_disk: bool
|
||||
:param sort_methods: the list of integer constants representing virtual folder sort methods.
|
||||
:type sort_methods: tuple
|
||||
:param view_mode: a numeric code for a skin view mode.
|
||||
View mode codes are different in different skins except for ``50`` (basic listing).
|
||||
:type view_mode: int
|
||||
:param content: string - current plugin content, e.g. 'movies' or 'episodes'.
|
||||
See :func:`xbmcplugin.setContent` for more info.
|
||||
:type content: str
|
||||
:return: context object containing necessary parameters
|
||||
to create virtual folder listing in Kodi UI.
|
||||
:rtype: ListContext
|
||||
"""
|
||||
return ListContext(listing, succeeded, update_listing, cache_to_disk, sort_methods, view_mode, content)
|
||||
|
||||
@staticmethod
|
||||
def resolve_url(path='', play_item=None, succeeded=True):
|
||||
"""
|
||||
Create and return a context dict to resolve a playable URL
|
||||
|
||||
:param path: the path to a playable media.
|
||||
:type path: str or unicode
|
||||
:param play_item: a dict of item properties as described in the class docstring.
|
||||
It allows to set additional properties for the item being played, like graphics, metadata etc.
|
||||
if ``play_item`` parameter is present, then ``path`` value is ignored, and the path must be set via
|
||||
``'path'`` property of a ``play_item`` dict.
|
||||
:type play_item: dict
|
||||
:param succeeded: if ``False``, Kodi won't play anything
|
||||
:type succeeded: bool
|
||||
:return: context object containing necessary parameters
|
||||
for Kodi to play the selected media.
|
||||
:rtype: PlayContext
|
||||
"""
|
||||
return PlayContext(path, play_item, succeeded)
|
||||
|
||||
@staticmethod
|
||||
def create_list_item(item):
|
||||
"""
|
||||
Create an :class:`xbmcgui.ListItem` instance from an item dict
|
||||
|
||||
:param item: a dict of ListItem properties
|
||||
:type item: dict
|
||||
:return: ListItem instance
|
||||
:rtype: xbmcgui.ListItem
|
||||
"""
|
||||
list_item = xbmcgui.ListItem(label=item.get('label', ''),
|
||||
label2=item.get('label2', ''),
|
||||
path=item.get('path', ''))
|
||||
if int(xbmc.getInfoLabel('System.BuildVersion')[:2]) >= 16:
|
||||
art = item.get('art', {})
|
||||
art['thumb'] = item.get('thumb', '')
|
||||
art['icon'] = item.get('icon', '')
|
||||
art['fanart'] = item.get('fanart', '')
|
||||
item['art'] = art
|
||||
else:
|
||||
list_item.setThumbnailImage(item.get('thumb', ''))
|
||||
list_item.setIconImage(item.get('icon', ''))
|
||||
list_item.setProperty('fanart_image', item.get('fanart', ''))
|
||||
if item.get('art'):
|
||||
list_item.setArt(item['art'])
|
||||
if item.get('stream_info'):
|
||||
for stream, stream_info in item['stream_info'].iteritems():
|
||||
list_item.addStreamInfo(stream, stream_info)
|
||||
if item.get('info'):
|
||||
for media, info in item['info'].iteritems():
|
||||
list_item.setInfo(media, info)
|
||||
if item.get('context_menu') is not None:
|
||||
list_item.addContextMenuItems(item['context_menu'])
|
||||
if item.get('subtitles'):
|
||||
list_item.setSubtitles(item['subtitles'])
|
||||
if item.get('mime'):
|
||||
list_item.setMimeType(item['mime'])
|
||||
if item.get('properties'):
|
||||
for key, value in item['properties'].iteritems():
|
||||
list_item.setProperty(key, value)
|
||||
return list_item
|
||||
|
||||
def _add_directory_items(self, context):
|
||||
"""
|
||||
Create a virtual folder listing
|
||||
|
||||
:param context: context object
|
||||
:type context: ListContext
|
||||
"""
|
||||
self.log_debug('Creating listing from {0}'.format(str(context)))
|
||||
if context.content is not None:
|
||||
xbmcplugin.setContent(self._handle, context.content) # This must be at the beginning
|
||||
listing = []
|
||||
for item in context.listing:
|
||||
is_folder = item.get('is_folder', True)
|
||||
if item.get('list_item') is not None:
|
||||
list_item = item['list_item']
|
||||
else:
|
||||
list_item = self.create_list_item(item)
|
||||
if item.get('is_playable'):
|
||||
list_item.setProperty('IsPlayable', 'true')
|
||||
is_folder = False
|
||||
listing.append((item['url'], list_item, is_folder))
|
||||
xbmcplugin.addDirectoryItems(self._handle, listing, len(listing))
|
||||
if context.sort_methods is not None:
|
||||
[xbmcplugin.addSortMethod(self._handle, method) for method in context.sort_methods]
|
||||
xbmcplugin.endOfDirectory(self._handle,
|
||||
context.succeeded,
|
||||
context.update_listing,
|
||||
context.cache_to_disk)
|
||||
if context.view_mode is not None:
|
||||
xbmc.executebuiltin('Container.SetViewMode({0})'.format(context.view_mode))
|
||||
|
||||
def _set_resolved_url(self, context):
|
||||
"""
|
||||
Resolve a playable URL
|
||||
|
||||
:param context: context object
|
||||
:type context: PlayContext
|
||||
"""
|
||||
self.log_debug('Resolving URL from {0}'.format(str(context)))
|
||||
if context.play_item is None:
|
||||
list_item = xbmcgui.ListItem(path=context.path)
|
||||
else:
|
||||
list_item = self.create_list_item(context.play_item)
|
||||
xbmcplugin.setResolvedUrl(self._handle, context.succeeded, list_item)
|
||||
@ -1,9 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<settings>
|
||||
<setting id="subsonic_url" type="text" label="URL" default="http://demo.subsonic.org"/>
|
||||
<!-- GENERAL -->
|
||||
<category label="General">
|
||||
<setting label="Server" type="lsep" />
|
||||
<setting id="subsonic_url" type="text" label="Server URL" default="http://demo.subsonic.org"/>
|
||||
<setting id="username" type="text" label="Username" default="guest3"/>
|
||||
<setting id="password" type="text" option="hidden" label="Password" default="guest"/>
|
||||
<setting id="random_count" type="labelenum" label="Random songs" values="10|15|20|25"/>
|
||||
<setting id="transcode_format" type="labelenum" label="Transcode format" values="mp3|raw|flv|ogg"/>
|
||||
<setting id="bitrate" type="labelenum" label="Bitrate" values="320|256|224|192|160|128|112|96|80|64|56|48|40|32"/>
|
||||
<setting label="Display" type="lsep" />
|
||||
<setting id="albums_per_page" type="labelenum" label="Albums per page" default="50" values="10|25|50|100|250|500"/>
|
||||
<setting id="tracks_per_page" type="labelenum" label="Tracks per page (ignored in albums & playlists)" default="100" values="10|25|50|100|250|500"/>
|
||||
<setting label="Download" type="lsep" />
|
||||
<setting id="download_folder" type="folder" label="Download folder" source="auto" option="writeable"/>
|
||||
<setting label="Streaming" type="lsep" />
|
||||
<setting id="transcode_format_streaming" type="labelenum" label="Transcode format" values="mp3|raw|flv|ogg"/>
|
||||
<setting id="bitrate_streaming" type="labelenum" label="Bitrate" values="320|256|224|192|160|128|112|96|80|64|56|48|40|32"/>
|
||||
</category>
|
||||
|
||||
<!-- ADVANCED -->
|
||||
<category label="Advanced Settings">
|
||||
<setting label="Server" type="lsep" />
|
||||
<setting id="apiversion" type="labelenum" label="API version" values="1.11.0|1.12.0|1.13.0|1.14.0" default="1.13.0"/>
|
||||
<setting id="insecure" type="bool" label="Allow self signed certificates" default="false" />
|
||||
<setting label="Cache (in minutes) - not yet implemented" type="lsep" />
|
||||
<setting id="cachetime" type="labelenum" label="Cache datas time" default="5" values="1|5|15|30|60|120|180|720|1440"/>
|
||||
</category>
|
||||
</settings>
|
||||
|
||||
Reference in New Issue
Block a user