mirror of
https://github.com/sakaljurgis/plugin.video.sklk.git
synced 2026-11-06 14:38:03 +00:00
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>
This commit is contained in:
1 parent
99b8f071fe
commit
227a95abcd
5 files changed
+313
-16
No files matched your search
+237
@@ -0,0 +1,237 @@
|
||||
# -*- 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.
|
||||
#
|
||||
# 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()
|
||||
Reference in new issue
Block a user