Files
plugin.video.sklk/main.py
T
Jurgis SakalauskasandClaude Opus 5.5 227a95abcd sync watched state with the server, single server url setting
- list items take playcount, resume point and last played from the server
  (folders: watched/total episodes), listings aren't cached
- background service (service.py) reports playback to <server>/api/state/<id>:
  every 30 s, on pause, stop and end; on first start it sends Kodi's existing
  watched history of this add-on to the server once
- one "Server URL" setting instead of api + video url (the old api url value,
  ending with /api, still works); no default address

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:27:37 +03:00

399 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Module: default
# Based on Roman V. M. example plugin https://github.com/romanvm/plugin.video.example
# still draft, but it works!
#xbmc.log("router objAPIresponse " + json.dumps(objAPIresponse))
#xbmcgui.Dialog().ok("name", json.dumps(objAPIresponse))
#my_addon = xbmcaddon.Addon()
#my_setting = my_addon.getSetting('my_setting') # returns the string 'true' or 'false'
#my_addon.setSetting('my_setting', 'false')
import re
import sys
from urllib.parse import urlencode, parse_qsl
import xbmcgui
import xbmcplugin
import xbmcaddon
import xbmc
import urllib.request
import simplejson as json
from io import StringIO
import gzip
# Get the plugin url in plugin:// notation.
_url = sys.argv[0]
# Get the plugin handle as an integer number.
_handle = int(sys.argv[1])
my_addon = xbmcaddon.Addon()
# the server, e.g. https://media.example.com: menus come from <server>/api, videos from the
# server-relative paths it lists. Stored as "api_url", which used to end with /api (next to a
# separate video url of the same server); either way works.
SERVER_URL = re.sub(r'/api$', '', my_addon.getSetting('api_url').strip().rstrip('/'))
API_URL = SERVER_URL + '/api'
AUTH_KEY = my_addon.getSetting('auth_key')
def getHttpURL(url):
request = urllib.request.Request(url)
request.add_header('Accept-encoding', 'gzip')
request.add_header('Authorization', AUTH_KEY)
response = urllib.request.urlopen(request)
if response.info().get('Content-Encoding') == 'gzip':
buf = StringIO(response.read())
f = gzip.GzipFile(fileobj=buf)
return f.read()
return response.read()
def get_url(**kwargs):
"""
Create a URL for calling the plugin recursively from the given set of keyword arguments.
:param kwargs: "argument=value" pairs
:type kwargs: dict
:return: plugin call URL
:rtype: str
"""
return '{0}?{1}'.format(_url, urlencode(kwargs))
def list_APIresponse(objAPIresponse):
#xbmcgui.Dialog().ok("ok", "listing api response")
# Set plugin category. It is displayed in some skins as the name
# of the current section.
if 'category' in objAPIresponse:
xbmcplugin.setPluginCategory(_handle, objAPIresponse['category'])
# Set plugin content. It allows Kodi to select appropriate views
# for this type of content.
if 'content' in objAPIresponse:
xbmcplugin.setContent(_handle, objAPIresponse['content'])
# Get items
if 'items' in objAPIresponse:
items = objAPIresponse['items']
# Iterate through items
for item in items:
# Create a list item with a text label and a thumbnail image.
list_item = xbmcgui.ListItem(label=item['label'])
# Set graphics (thumbnail, fanart, banner, poster, landscape etc.) for the list item.
if 'thumb' in item:
list_item.setArt({'thumb': item['thumb']})
if 'icon' in item:
list_item.setArt({'icon': item['icon']})
if 'fanart' in item:
list_item.setArt({'fanart': item['fanart']})
# Set additional info for the list item.
# https://codedocs.xyz/xbmc/xbmc/group__python__xbmcgui__listitem.html#ga0b71166869bda87ad744942888fb5f14
# 'mediatype' is needed for a skin to display info for this ListItem correctly.
if 'title' in item:
list_item.setInfo('video', {'title': item['title']})
if 'mediatype' in item:
list_item.setInfo('video', {'mediatype': item['mediatype']})
if 'size' in item: #long (1024) - size in bytes
list_item.setInfo('video', {'size': item['size']})
if 'duration' in item: # integer (245) - duration in seconds
list_item.setInfo('video', {'duration': item['duration']})
if 'aired' in item: # string (2008-12-07)
list_item.setInfo('video', {'aired': item['aired']})
if 'date' in item: # string (d.m.Y / 01.01.2009) - file date
list_item.setInfo('video', {'date': item['date']})
if 'plot' in item: # string (Long Description)
list_item.setInfo('video', {'plot': item['plot']})
if 'plotoutline' in item: # string (Short Description)
list_item.setInfo('video', {'plotoutline': item['plotoutline']})
# add properties - why it is not working?
if 'IsPlayable' in item:
if item['IsPlayable']:
list_item.setProperty('IsPlayable', 'true') # must be unicode or str, not bool
# list_item.setProperty('IsPlayable', item['IsPlayable']) - this doesn't work
else:
list_item.setProperty('IsPlayable', 'false')
# is_folder = True means that this item opens a sub-list of lower level items.
if 'isFolder' in item:
is_folder = item['isFolder']
# Create a URL for a plugin recursive call.
# Example: plugin://plugin.video.example/?action=query&path=epg
# if path is available ->
# default action for folder is query,
# for file default is play, unless other action is provided
#if path is not provided
# action is ignore
if 'path' in item:
if is_folder:
url = get_url(action='query', path=item['path'])
else:
if 'action' in item:
if 'searchFor' in item:
url = get_url(action=item['action'], path=item['path'], searchFor=item['searchFor'])
else:
url = get_url(action=item['action'], path=item['path'])
else:
url = get_url(action='play', path=item['path'])
list_item.setProperty('IsPlayable', 'true')
else:
url = get_url(action='ignore')
# add context menus (if there are any)
if 'contextMenus' in item:
contextMenus = item['contextMenus']
listContextMenus = []
# Iterate through contextMenus
for contextMenu in contextMenus:
#should have a name (String) and urlParams (object)
params = contextMenu['urlParams']
cmUrl = '{0}?{1}'.format(_url, urlencode(params)) #context menu url
listContextMenus.append((contextMenu['name'], 'RunPlugin("' + cmUrl + '")'))
if len(contextMenus) > 0:
list_item.addContextMenuItems(listContextMenus)
# watched / resume point as the server has it (shared with other apps)
set_watch_state(list_item, item, is_folder)
# Add our item to the Kodi virtual folder listing.
xbmcplugin.addDirectoryItem(_handle, url, list_item, is_folder)
# Add a sort method for the virtual folder items
if 'nosort' in objAPIresponse:
xbmcplugin.addSortMethod(_handle, xbmcplugin.SORT_METHOD_UNSORTED)
else:
#(alphabetically, ignore articles)
xbmcplugin.addSortMethod(_handle, xbmcplugin.SORT_METHOD_LABEL_IGNORE_THE)
xbmcplugin.addSortMethod(_handle, xbmcplugin.SORT_METHOD_DATE)
# Finish creating a virtual folder.
# not cached: watch state may have changed (on another device too) when coming back to it
xbmcplugin.endOfDirectory(_handle, cacheToDisc=False)
def set_watch_state(list_item, item, is_folder):
"""
Watched state and resume point from the server, which keeps them for all apps
(the service reports Kodi's playback there). Set explicitly, they take
precedence over Kodi's own database.
"""
if is_folder:
# shows and seasons: watched episodes out of the total
total = item.get('totalCount', 0)
if total > 0:
watched = item.get('watchedCount', 0)
list_item.setProperty('TotalEpisodes', str(total))
list_item.setProperty('WatchedEpisodes', str(watched))
list_item.setProperty('UnWatchedEpisodes', str(total - watched))
list_item.setInfo('video', {'playcount': 1 if watched >= total else 0})
return
if 'playcount' not in item:
return
info = {'playcount': item['playcount']}
if 'lastplayed' in item:
info['lastplayed'] = item['lastplayed']
list_item.setInfo('video', info)
# no resume point = 0, which also clears one Kodi may have kept;
# Kodi only takes it with a total time
resume = item.get('resume', {})
position = resume.get('position', 0)
total = resume.get('total') or item.get('duration', 0)
tag = list_item.getVideoInfoTag()
if hasattr(tag, 'setResumePoint'):
# Kodi 20+
tag.setResumePoint(position, total)
else:
list_item.setProperty('ResumeTime', str(position))
list_item.setProperty('TotalTime', str(total))
def select_APIresponse(objAPIresponse):
if 'items' in objAPIresponse:
items = objAPIresponse['items']
arrItems = [] #for now this is arr of strings
# Iterate through items, collect all labels
for item in items:
arrItems.append(item['label'])
#show the list
dialog = xbmcgui.Dialog() #todo - let to choose own title
selected = dialog.select(objAPIresponse['category'], arrItems)
#dialog.select('Choose a playlist', ['Playlist #1', 'Playlist #2', 'Playlist #3']) #list of strings / xbmcgui.ListItems - list of items shown in dialog.
if selected == -1:
pass
else:
#invoke self run on selected item
strPath = items[selected]['path']
params = {'action': 'query', 'path': strPath}
url = urlencode(params)
#xbmcgui.Dialog().ok("running queryApi", url)
queryAPI(url)
def play_video(path):
"""
Play a video by the provided path.
:param path: relative path or full path
:type path: str
"""
#xbmcgui.Dialog().ok("debug", "playvideo", path)
if 'http' not in path:
# server-relative, e.g. /api?path=all/play/103
path = SERVER_URL + ('' if path.startswith('/') else '/') + path
# Create a playable item with a path to play.
play_item = xbmcgui.ListItem(path=path)
# Pass the item to the Kodi player.
xbmcplugin.setResolvedUrl(_handle, True, listitem=play_item)
def queryAPI(paramstring):
strAPIresponse = getHttpURL(API_URL + "?" + paramstring)
#parsed response from server
objAPIresponse = json.loads(strAPIresponse)
#show message if message is received
if 'msgBoxOK' in objAPIresponse:
xbmcgui.Dialog().ok("message from server", objAPIresponse['msgBoxOK'])
if 'notification' in objAPIresponse:
xbmc.executebuiltin('Notification("Notification from server!", "' + objAPIresponse['notification'] + '")')
# to force the update the container (e.g. after file is deleted)
# can have a replace (string) to reset the path history and must have urlParams (object)
if 'forceUpdate' in objAPIresponse:
forceUpdate = objAPIresponse['forceUpdate']
if 'replace' in forceUpdate and 'urlParams' in forceUpdate:
fuUrl = '{0}?{1}'.format(_url, urlencode(forceUpdate['urlParams'])) #forceUpdate url
xbmc.executebuiltin("Container.Update(" + fuUrl + ", " + forceUpdate['replace'] + ")")
return
elif 'urlParams' in forceUpdate:
fuUrl = '{0}?{1}'.format(_url, urlencode(forceUpdate['urlParams'])) #forceUpdate url
xbmc.executebuiltin("Container.Update(" + fuUrl + ")")
return
#play video if play is received
if 'play' in objAPIresponse:
play_video(objAPIresponse['play'])
#always update list, unless instructed otherwise
if 'updateList' in objAPIresponse:
if objAPIresponse['updateList']:
list_APIresponse(objAPIresponse)
else:
list_APIresponse(objAPIresponse)
#refresh container
if 'refreshContainer' in objAPIresponse:
if objAPIresponse['refreshContainer']:
xbmc.executebuiltin("Container.Refresh")
#show list to select
if 'selectList' in objAPIresponse:
if objAPIresponse['selectList']:
#show a list of items to select
select_APIresponse(objAPIresponse)
#dialog.textviewer('Plot', 'Some movie plot.')
def router(paramstring):
"""
Router function that calls other functions
depending on the provided paramstring
:param paramstring: URL encoded plugin paramstring
:type paramstring: str
"""
if not SERVER_URL:
xbmcgui.Dialog().ok("sklk", "Set the server URL in the add-on settings first.")
my_addon.openSettings()
return
# Parse a URL-encoded paramstring to the dictionary of
# {<parameter>: <value>} elements
params = dict(parse_qsl(paramstring))
# Check the parameters passed to the plugin
if params:
if params['action'] == 'query':
# Query API server and generate list (or play video).
queryAPI(paramstring)
elif params['action'] == 'play':
# Play a video from a provided URL.
play_video(params['path'])
elif params['action'] == 'ignore':
# do nothing, this is info item
strVariable = "nothing"
elif params['action'] == 'search':
# show search dialog for now, later will query the server
dialog = xbmcgui.Dialog()
if 'searchFor' in params:
strSearchString = dialog.input("Search...", defaultt=params['searchFor'], type=xbmcgui.INPUT_ALPHANUM)
else:
strSearchString = dialog.input("Search...", type=xbmcgui.INPUT_ALPHANUM)
if strSearchString:
params['action'] = "query"
params['search'] = strSearchString
url = '{0}?{1}'.format(_url, urlencode(params))
#xbmc.executebuiltin("Container.Update(" + url + ")")
xbmc.executebuiltin('Container.Update(%s)' % url)
else:
# If the provided paramstring does not contain a supported action
# we raise an exception. This helps to catch coding errors,
# e.g. typos in action names.
xbmcgui.Dialog().ok("Invalid paramstring:", paramstring)
#raise ValueError('Invalid paramstring: {0}!'.format(paramstring))
else:
# If the plugin is called from Kodi UI without any parameters,
# display the list of items
queryAPI(paramstring)
if __name__ == '__main__':
# Call the router function and pass the plugin call parameters to it.
# We use string slicing to trim the leading '?' from the plugin call paramstring
router(sys.argv[2][1:])