From 227a95abcd80e20d08b1bbaca0a1912c2893e807 Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Mon, 12 Oct 2026 10:27:37 +0300 Subject: [PATCH] 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 /api/state/: 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 --- Readme.md | 9 ++ addon.xml | 6 +- main.py | 65 +++++++++-- resources/settings.xml | 12 +-- service.py | 237 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 16 deletions(-) create mode 100644 service.py 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()