Compare commits

...
2 Commits
Author SHA1 Message Date
Jurgis SakalauskasandClaude Opus 5.5 5e20067d6d send Kodi's watched history from a settings button
Not automatically on the service's first start any more: with several Kodi
installs, only the one whose history counts should send it. The button
("Send this Kodi's watched history to the server") asks first, shows progress
and can be cancelled; sending again is harmless, the server keeps newer state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:53:07 +03:00
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
6 changed files with 357 additions and 16 deletions

No files matched your search

+10
View File
@@ -8,4 +8,14 @@ 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). Kodi's own earlier watched history of this add-on can be sent to the server with the button
in the add-on settings ("Send this Kodi's watched history to the server"); do it on the Kodi whose
history counts, the server keeps whatever it has that's newer. 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
+4 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon id="plugin.video.sklk"
version="0.0.23"
version="0.0.25"
name="sklk"
provider-name="krk">
<requires>
@@ -10,6 +10,8 @@
<extension point="xbmc.python.pluginsource" library="main.py">
<provides>video</provides>
</extension>
<!-- reports playback to the server, which keeps watched state for all apps -->
<extension point="xbmc.service" library="service.py" start="login"/>
<extension point="xbmc.addon.metadata">
<summary lang="en">sklk pluginas</summary>
<description lang="en_GB">nera jokio aprasymo</description>
@@ -18,6 +20,6 @@
<icon>icon.png</icon>
<fanart>fanart.jpg</fanart>
</assets>
<news>jokiu naujienu..</news>
<news>0.0.25: sending Kodi's watched history to the server is a button in the settings. 0.0.24: watched state and resume points kept on the server, shared with other apps</news>
</extension>
</addon>
+83
View File
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Kodi's own watched history of this add-on (its video database) to the server,
# as late reports: whatever the server knows that's newer wins. Started from the
# add-on settings, on the Kodi whose history counts (e.g. the main one).
import os
import re
import sqlite3
import time
from datetime import datetime
from urllib.parse import unquote
import xbmcvfs
from service import ADDON_ID, FILE_ID, is_ours, log, report
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 read_history():
"""{file id: (play count, resume position, total time, last played in ms)} of this add-on's files."""
database = newest_video_database()
if database is None:
return {}
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()
# 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)
return history
def send_history(history, on_progress=None):
"""
How many files went through: stops at the first one that doesn't (server
unreachable), or when on_progress(files done) returns False.
"""
done = 0
for file_id, (play_count, position, total, at) in history.items():
if on_progress is not None and on_progress(done) is False:
break
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:
break
done += 1
log('sent watched history of {0} of {1} files'.format(done, len(history)))
return done
+94 -8
View File
@@ -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 <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')
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)
@@ -294,6 +338,40 @@ def queryAPI(paramstring):
#dialog.textviewer('Plot', 'Some movie plot.')
def send_watched_history():
"""
This Kodi's own watched history of the add-on to the server, which keeps
whatever it has that's newer. Meant for the Kodi whose history counts.
"""
import history
dialog = xbmcgui.Dialog()
files = history.read_history()
if not files:
dialog.ok("sklk", "This Kodi has no watched history of the add-on.")
return
if not dialog.yesno("sklk", "Send this Kodi's watched history ({0} files) to the server?\n"
"Newer state on the server stays.".format(len(files))):
return
progress = xbmcgui.DialogProgress()
progress.create("sklk", "Sending watched history...")
def on_progress(done):
progress.update(done * 100 // len(files))
return not progress.iscanceled()
try:
sent = history.send_history(files, on_progress)
finally:
progress.close()
if sent == len(files):
dialog.ok("sklk", "Sent the watched history of {0} files.".format(sent))
else:
dialog.ok("sklk", "Sent {0} of {1} files (stopped, or the server didn't answer).".format(sent, len(files)))
def router(paramstring):
"""
Router function that calls other functions
@@ -303,6 +381,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
# {<parameter>: <value>} elements
params = dict(parse_qsl(paramstring))
@@ -315,6 +398,9 @@ def router(paramstring):
elif params['action'] == 'play':
# Play a video from a provided URL.
play_video(params['path'])
elif params['action'] == 'send_history':
# button in the add-on settings
send_watched_history()
elif params['action'] == 'ignore':
# do nothing, this is info item
strVariable = "nothing"
+9 -6
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<settings>
<setting id="api_url" type="text" default="http://sklk.lt:8181/api" label="API URL"/>
<setting id="video_url" type="text" default="http://sklk.lt:8181" label="VIDEO URL"/>
<setting id="auth_key" type="text" default="" label="Auth key"/>
</settings>
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<settings>
<!-- the server, e.g. https://media.example.com ("api_url" for older versions' value, which ended with /api) -->
<setting id="api_url" type="text" default="" label="Server URL"/>
<setting id="auth_key" type="text" default="" label="Auth key"/>
<!-- on the Kodi whose watched history counts (e.g. the main one); the server keeps what's newer -->
<setting id="send_history" type="action" label="Send this Kodi's watched history to the server"
action="RunPlugin(plugin://plugin.video.sklk/?action=send_history)"/>
</settings>
+157
View File
@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# Background service: reports playback of this add-on's files to the server
# (POST <server>/api/state/<file id>), which keeps watched state and resume points
# for every app (e.g. the SKLK TV app); listings come back with that state.
# (Kodi's own earlier history: see history.py.)
import json
import re
import time
import urllib.request
import xbmc
import xbmcaddon
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 run():
monitor = xbmc.Monitor()
player = Player()
while not monitor.waitForAbort(1):
player.tick()
if __name__ == '__main__':
run()