diff --git a/Readme.md b/Readme.md
index c54373f..93e00d7 100644
--- a/Readme.md
+++ b/Readme.md
@@ -8,4 +8,13 @@ Do **not** try to install a .zip generated by GitHub.
Compatible with Kodi 19.0 ("Matrix") and above that uses Python 3.
+Settings: the server URL (e.g. `https://media.example.com`; a value ending with `/api`, as older
+versions had it, works too) and an optional auth key, sent as the `Authorization` header.
+
+Watched state and resume points live on the server, shared with other apps (e.g. SKLK TV): listings
+come with them, and a background service (`service.py`) reports playback (every 30 s, on pause, stop
+and end). On its first start the service also sends Kodi's existing watched history of this add-on to
+the server. Mark watched/unwatched with the add-on's own context menu entries ("Mark watched",
+"Mark all watched", ...); Kodi's built-in "Mark as watched" only changes Kodi's local database.
+
License: no licence
diff --git a/addon.xml b/addon.xml
index c64dbcc..5eca0da 100644
--- a/addon.xml
+++ b/addon.xml
@@ -1,6 +1,6 @@
@@ -10,6 +10,8 @@
video
+
+
sklk pluginas
nera jokio aprasymo
@@ -18,6 +20,6 @@
icon.png
fanart.jpg
- jokiu naujienu..
+ 0.0.24: watched state and resume points kept on the server, shared with other apps
diff --git a/main.py b/main.py
index 12c0d6d..322a24f 100644
--- a/main.py
+++ b/main.py
@@ -10,6 +10,7 @@
#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
@@ -27,15 +28,14 @@ _url = sys.argv[0]
# Get the plugin handle as an integer number.
_handle = int(sys.argv[1])
-# todo - get from settings
-#API_URL = xbmcplugin.getSetting(_handle, 'api_url') #if xbmcplugin.getSetting(_handle, 'api_url') else xbmc.getSetting("api_url")
my_addon = xbmcaddon.Addon()
-API_URL = my_addon.getSetting('api_url')
-VIDEO_URL = xbmcplugin.getSetting(_handle, 'video_url')
+# the server, e.g. https://media.example.com: menus come from /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')
-xbmcplugin.getSetting
-
def getHttpURL(url):
request = urllib.request.Request(url)
@@ -183,6 +183,9 @@ def list_APIresponse(objAPIresponse):
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)
@@ -195,7 +198,47 @@ def list_APIresponse(objAPIresponse):
xbmcplugin.addSortMethod(_handle, xbmcplugin.SORT_METHOD_DATE)
# Finish creating a virtual folder.
- xbmcplugin.endOfDirectory(_handle)
+ # 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):
@@ -234,7 +277,8 @@ def play_video(path):
#xbmcgui.Dialog().ok("debug", "playvideo", path)
if 'http' not in path:
- path = VIDEO_URL + 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)
@@ -303,6 +347,11 @@ def router(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
# {: } elements
params = dict(parse_qsl(paramstring))
diff --git a/resources/settings.xml b/resources/settings.xml
index 9570b63..b39f089 100644
--- a/resources/settings.xml
+++ b/resources/settings.xml
@@ -1,6 +1,6 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/service.py b/service.py
new file mode 100644
index 0000000..d2b2bb7
--- /dev/null
+++ b/service.py
@@ -0,0 +1,237 @@
+# -*- coding: utf-8 -*-
+# Background service: reports playback of this add-on's files to the server
+# (POST /api/state/), which keeps watched state and resume points
+# for every app (e.g. the SKLK TV app); listings come back with that state.
+#
+# On its first start it also hands Kodi's own watched history of this add-on
+# to the server, once (whatever the server knows that's newer wins).
+
+import json
+import os
+import re
+import sqlite3
+import time
+import urllib.request
+from datetime import datetime
+from urllib.parse import unquote
+
+import xbmc
+import xbmcaddon
+import xbmcvfs
+
+ADDON_ID = xbmcaddon.Addon().getAddonInfo('id')
+FILE_ID = re.compile(r'all/play/(\d+)')
+REPORT_EVERY = 30 # seconds, while playing
+CLIENT = 'kodi'
+
+
+def log(message, level=xbmc.LOGINFO):
+ xbmc.log('[{0} service] {1}'.format(ADDON_ID, message), level)
+
+
+def server_url():
+ """The server setting (see main.py), read each time since it may change."""
+ return re.sub(r'/api$', '', xbmcaddon.Addon().getSetting('api_url').strip().rstrip('/'))
+
+
+def report(file_id, position, duration, event, at=None):
+ """
+ Positions in seconds; at (ms since epoch) only for late reports.
+ True if the server got it (an answer that it doesn't know the file counts too).
+ """
+ if not server_url():
+ return False
+ payload = {'position': position, 'event': event, 'client': CLIENT}
+ if duration:
+ payload['duration'] = duration
+ if at:
+ payload['at'] = at
+ request = urllib.request.Request(
+ server_url() + '/api/state/' + str(file_id),
+ data=json.dumps(payload).encode('utf-8'),
+ headers={'Content-Type': 'application/json', 'Authorization': xbmcaddon.Addon().getSetting('auth_key')},
+ method='POST',
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=10) as response:
+ response.read()
+ return True
+ except Exception as e:
+ log('report failed: {0}'.format(e), xbmc.LOGWARNING)
+ return False
+
+
+def is_ours(url):
+ """Streams of this add-on's server (as opposed to other add-ons, or LRT streams)."""
+ server = server_url()
+ return bool(server) and url.startswith(server + '/')
+
+
+def refresh_if_ours():
+ # e.g. Continue watching moves on to the next episode
+ if xbmc.getInfoLabel('Container.FolderPath').startswith('plugin://' + ADDON_ID):
+ xbmc.executebuiltin('Container.Refresh')
+
+
+class Player(xbmc.Player):
+ def __init__(self):
+ super().__init__()
+ self.file_id = None
+ self.position = 0
+ self.duration = 0
+ self.last_report = 0
+ # (file_id, position, duration, event, at): retried until it goes through
+ self.unsent = None
+
+ def onAVStarted(self):
+ self.finish('stop') # in case another file was playing
+ try:
+ url = self.getPlayingFile()
+ except RuntimeError:
+ return
+ match = FILE_ID.search(url) if is_ours(url) else None
+ if match is None:
+ return
+ self.file_id = int(match.group(1))
+ self.position = 0
+ self.duration = 0
+ self.update()
+ self.send('start')
+
+ def onPlayBackPaused(self):
+ if self.file_id is not None:
+ self.update()
+ self.send('pause')
+
+ def onPlayBackStopped(self):
+ self.finish('stop')
+
+ def onPlayBackEnded(self):
+ self.finish('end')
+
+ def onPlayBackError(self):
+ self.finish('stop')
+
+ def tick(self):
+ """Every second, from the service loop."""
+ if self.unsent and time.time() - self.last_report >= REPORT_EVERY:
+ self.last_report = time.time()
+ if report(*self.unsent):
+ self.unsent = None
+
+ if self.file_id is None:
+ return
+ self.update()
+ if time.time() - self.last_report >= REPORT_EVERY and not xbmc.getCondVisibility('Player.Paused'):
+ self.send('tick')
+
+ def update(self):
+ # after a stop there's no time to ask for, the last one known is reported
+ try:
+ if self.isPlayingVideo():
+ self.position = self.getTime()
+ self.duration = self.getTotalTime() or self.duration
+ except RuntimeError:
+ pass
+
+ def finish(self, event):
+ if self.file_id is None:
+ return
+ if event == 'end':
+ self.position = self.duration
+ self.send(event)
+ self.file_id = None
+ refresh_if_ours()
+
+ def send(self, event):
+ self.last_report = time.time()
+ args = (self.file_id, self.position, self.duration, event)
+ if report(*args):
+ if self.unsent and self.unsent[0] == self.file_id:
+ self.unsent = None
+ else:
+ self.unsent = args + (int(time.time() * 1000),)
+
+
+def newest_video_database():
+ folder = xbmcvfs.translatePath('special://database')
+ versions = [(int(m.group(1)), name) for name in os.listdir(folder)
+ for m in [re.match(r'MyVideos(\d+)\.db$', name)] if m]
+ return os.path.join(folder, max(versions)[1]) if versions else None
+
+
+def to_ms(last_played):
+ try:
+ return int(time.mktime(datetime.strptime(last_played, '%Y-%m-%d %H:%M:%S').timetuple()) * 1000)
+ except (TypeError, ValueError):
+ return 1 # unknown: older than anything the server has
+
+
+def import_kodi_history():
+ """
+ Before this service, Kodi kept this add-on's watched state and resume points
+ in its own database only. Send them to the server once, as late reports.
+ """
+ profile = xbmcvfs.translatePath(xbmcaddon.Addon().getAddonInfo('profile'))
+ marker = os.path.join(profile, 'history_imported')
+ if os.path.exists(marker):
+ return
+
+ database = newest_video_database()
+ rows = []
+ if database:
+ connection = sqlite3.connect('file:{0}?mode=ro'.format(database), uri=True)
+ try:
+ rows = connection.execute(
+ 'SELECT path.strPath, files.strFilename, files.playCount, files.lastPlayed,'
+ ' bookmark.timeInSeconds, bookmark.totalTimeInSeconds'
+ ' FROM files JOIN path ON path.idPath = files.idPath'
+ ' LEFT JOIN bookmark ON bookmark.idFile = files.idFile AND bookmark.type = 1'
+ ).fetchall()
+ finally:
+ connection.close()
+
+ # by file id, the latest wins (a file can be there by plugin url and by stream url)
+ history = {}
+ for folder, filename, play_count, last_played, position, total in rows:
+ url = unquote(unquote((folder or '') + (filename or '')))
+ match = FILE_ID.search(url)
+ if match is None or not (url.startswith('plugin://' + ADDON_ID) or is_ours(url)):
+ continue
+ if not play_count and not position:
+ continue
+ file_id = int(match.group(1))
+ at = to_ms(last_played)
+ if file_id not in history or history[file_id][3] < at:
+ history[file_id] = (play_count or 0, position or 0, total or 0, at)
+
+ for file_id, (play_count, position, total, at) in history.items():
+ sent = True
+ if play_count > 0:
+ sent = report(file_id, total, total, 'end', at)
+ if sent and position > 0:
+ # (also) a resume point, e.g. rewatching
+ sent = report(file_id, position, total, 'stop', at)
+ if not sent:
+ return # server unreachable: try again next time
+
+ if not os.path.isdir(profile):
+ os.makedirs(profile)
+ open(marker, 'w').close()
+ log('imported watch history of {0} files'.format(len(history)))
+
+
+def run():
+ monitor = xbmc.Monitor()
+ player = Player()
+ try:
+ import_kodi_history()
+ except Exception as e:
+ log('history import failed: {0}'.format(e), xbmc.LOGWARNING)
+
+ while not monitor.waitForAbort(1):
+ player.tick()
+
+
+if __name__ == '__main__':
+ run()