Files
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

158 lines
4.7 KiB
Python

# -*- 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()