mirror of
https://github.com/sakaljurgis/plugin.video.sklk.git
synced 2026-11-06 14:38:03 +00:00
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>
This commit is contained in:
1 parent
227a95abcd
commit
5e20067d6d
6 files changed
+129
-85
No files matched your search
@@ -13,8 +13,9 @@ versions had it, works too) and an optional auth key, sent as the `Authorization
|
|||||||
|
|
||||||
Watched state and resume points live on the server, shared with other apps (e.g. SKLK TV): listings
|
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
|
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
|
and end). Kodi's own earlier watched history of this add-on can be sent to the server with the button
|
||||||
the server. Mark watched/unwatched with the add-on's own context menu entries ("Mark watched",
|
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.
|
"Mark all watched", ...); Kodi's built-in "Mark as watched" only changes Kodi's local database.
|
||||||
|
|
||||||
License: no licence
|
License: no licence
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<addon id="plugin.video.sklk"
|
<addon id="plugin.video.sklk"
|
||||||
version="0.0.24"
|
version="0.0.25"
|
||||||
name="sklk"
|
name="sklk"
|
||||||
provider-name="krk">
|
provider-name="krk">
|
||||||
<requires>
|
<requires>
|
||||||
@@ -20,6 +20,6 @@
|
|||||||
<icon>icon.png</icon>
|
<icon>icon.png</icon>
|
||||||
<fanart>fanart.jpg</fanart>
|
<fanart>fanart.jpg</fanart>
|
||||||
</assets>
|
</assets>
|
||||||
<news>0.0.24: watched state and resume points kept on the server, shared with other apps</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>
|
</extension>
|
||||||
</addon>
|
</addon>
|
||||||
+83
@@ -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
|
||||||
@@ -338,6 +338,40 @@ def queryAPI(paramstring):
|
|||||||
|
|
||||||
#dialog.textviewer('Plot', 'Some movie plot.')
|
#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):
|
def router(paramstring):
|
||||||
"""
|
"""
|
||||||
Router function that calls other functions
|
Router function that calls other functions
|
||||||
@@ -364,6 +398,9 @@ def router(paramstring):
|
|||||||
elif params['action'] == 'play':
|
elif params['action'] == 'play':
|
||||||
# Play a video from a provided URL.
|
# Play a video from a provided URL.
|
||||||
play_video(params['path'])
|
play_video(params['path'])
|
||||||
|
elif params['action'] == 'send_history':
|
||||||
|
# button in the add-on settings
|
||||||
|
send_watched_history()
|
||||||
elif params['action'] == 'ignore':
|
elif params['action'] == 'ignore':
|
||||||
# do nothing, this is info item
|
# do nothing, this is info item
|
||||||
strVariable = "nothing"
|
strVariable = "nothing"
|
||||||
|
|||||||
@@ -3,4 +3,7 @@
|
|||||||
<!-- the server, e.g. https://media.example.com ("api_url" for older versions' value, which ended with /api) -->
|
<!-- 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="api_url" type="text" default="" label="Server URL"/>
|
||||||
<setting id="auth_key" type="text" default="" label="Auth key"/>
|
<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>
|
</settings>
|
||||||
+1
-81
@@ -2,22 +2,15 @@
|
|||||||
# Background service: reports playback of this add-on's files to the server
|
# 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
|
# (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.
|
# for every app (e.g. the SKLK TV app); listings come back with that state.
|
||||||
#
|
# (Kodi's own earlier history: see history.py.)
|
||||||
# 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 json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcaddon
|
import xbmcaddon
|
||||||
import xbmcvfs
|
|
||||||
|
|
||||||
ADDON_ID = xbmcaddon.Addon().getAddonInfo('id')
|
ADDON_ID = xbmcaddon.Addon().getAddonInfo('id')
|
||||||
FILE_ID = re.compile(r'all/play/(\d+)')
|
FILE_ID = re.compile(r'all/play/(\d+)')
|
||||||
@@ -153,82 +146,9 @@ class Player(xbmc.Player):
|
|||||||
self.unsent = args + (int(time.time() * 1000),)
|
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():
|
def run():
|
||||||
monitor = xbmc.Monitor()
|
monitor = xbmc.Monitor()
|
||||||
player = Player()
|
player = Player()
|
||||||
try:
|
|
||||||
import_kodi_history()
|
|
||||||
except Exception as e:
|
|
||||||
log('history import failed: {0}'.format(e), xbmc.LOGWARNING)
|
|
||||||
|
|
||||||
while not monitor.waitForAbort(1):
|
while not monitor.waitForAbort(1):
|
||||||
player.tick()
|
player.tick()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user