diff --git a/Readme.md b/Readme.md
index 93e00d7..9a72e42 100644
--- a/Readme.md
+++ b/Readme.md
@@ -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
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
-the server. Mark watched/unwatched with the add-on's own context menu entries ("Mark watched",
+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
diff --git a/addon.xml b/addon.xml
index 5eca0da..cf98a65 100644
--- a/addon.xml
+++ b/addon.xml
@@ -1,6 +1,6 @@
@@ -20,6 +20,6 @@
icon.png
fanart.jpg
- 0.0.24: watched state and resume points kept on the server, shared with other apps
+ 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
diff --git a/history.py b/history.py
new file mode 100644
index 0000000..dca360d
--- /dev/null
+++ b/history.py
@@ -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
diff --git a/main.py b/main.py
index 322a24f..a14890b 100644
--- a/main.py
+++ b/main.py
@@ -338,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
@@ -364,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"
diff --git a/resources/settings.xml b/resources/settings.xml
index b39f089..925bcbb 100644
--- a/resources/settings.xml
+++ b/resources/settings.xml
@@ -3,4 +3,7 @@
+
+
diff --git a/service.py b/service.py
index d2b2bb7..62be346 100644
--- a/service.py
+++ b/service.py
@@ -2,22 +2,15 @@
# Background service: reports playback of this add-on's files to the server
# (POST /api/state/), 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).
+# (Kodi's own earlier history: see history.py.)
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+)')
@@ -153,82 +146,9 @@ class Player(xbmc.Player):
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()