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