mirror of
https://github.com/sakaljurgis/plugin.video.sklk.git
synced 2026-11-06 14:38:03 +00:00
- 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>
238 lines
7.7 KiB
Python
238 lines
7.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.
|
|
#
|
|
# 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()
|