# -*- coding: utf-8 -*- # Copyright: (c) 2019, Dag Wieers (@dagwieers) <dag@wieers.com> # GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) """Implementation of ResumePoints class""" from __future__ import absolute_import, division, unicode_literals try: # Python 3 from urllib.error import HTTPError from urllib.request import build_opener, install_opener, ProxyHandler, Request, urlopen except ImportError: # Python 2 from urllib2 import build_opener, HTTPError, install_opener, ProxyHandler, Request, urlopen from data import SECONDS_MARGIN from kodiutils import (container_refresh, get_cache, get_proxies, get_setting_bool, get_url_json, has_credentials, input_down, invalidate_caches, localize, log, log_error, notification, update_cache) class ResumePoints: """Track, cache and manage VRT resume points and watch list""" def __init__(self): """Initialize resumepoints, relies on XBMC vfs and a special VRT token""" self._data = dict() # Our internal representation install_opener(build_opener(ProxyHandler(get_proxies()))) @staticmethod def is_activated(): """Is resumepoints activated in the menu and do we have credentials ?""" return get_setting_bool('useresumepoints', default=True) and has_credentials() @staticmethod def resumepoint_headers(url=None): """Generate http headers for VRT NU Resumepoints API""" from tokenresolver import TokenResolver xvrttoken = TokenResolver().get_token('X-VRT-Token', variant='user') headers = {} if xvrttoken: url = 'https://www.vrt.be' + url if url else 'https://www.vrt.be/vrtnu' headers = { 'authorization': 'Bearer ' + xvrttoken, 'content-type': 'application/json', 'Referer': url, } else: log_error('Failed to get usertoken from VRT NU') notification(message=localize(30975)) return headers def refresh(self, ttl=None): """Get a cached copy or a newer resumepoints from VRT, or fall back to a cached file""" if not self.is_activated(): return resumepoints_json = get_cache('resume_points.json', ttl) if not resumepoints_json: resumepoints_url = 'https://video-user-data.vrt.be/resume_points' headers = self.resumepoint_headers() if not headers: return resumepoints_json = get_url_json(url=resumepoints_url, cache='resume_points.json', headers=headers) if resumepoints_json is not None: self._data = resumepoints_json def update(self, asset_id, title, url, watch_later=None, position=None, total=None, whatson_id=None, path=None): """Set program resumepoint or watchLater status and update local copy""" menu_caches = [] self.refresh(ttl=5) # Add existing position and total if None if asset_id in self._data and position is None and total is None: position = self.get_position(asset_id) total = self.get_total(asset_id) # Update if (self.still_watching(position, total) or watch_later is True or (path and path.startswith('plugin://plugin.video.vrt.nu/play/upnext'))): # Normally, VRT NU resumepoints are deleted when an episode is (un)watched and Kodi GUI automatically sets # the (un)watched status when Kodi Player exits. This mechanism doesn't work with "Up Next" episodes because # these episodes are not initiated from a ListItem in Kodi GUI. # For "Up Next" episodes, we should never delete the VRT NU resumepoints to make sure the watched status # can be forced in Kodi GUI using the playcount infolabel. log(3, "[Resumepoints] Update resumepoint '{asset_id}' {position}/{total}", asset_id=asset_id, position=position, total=total) if asset_id is None: return True if watch_later is not None and position is None and total is None and watch_later is self.is_watchlater(asset_id): # watchLater status is not changed, nothing to do return True if watch_later is None and position == self.get_position(asset_id) and total == self.get_total(asset_id): # Resumepoint is not changed, nothing to do return True menu_caches.append('continue-*.json') if asset_id in self._data: # Update existing resumepoint values payload = self._data[asset_id]['value'] payload['url'] = url else: # Create new resumepoint values payload = dict(position=0, total=100, url=url) if watch_later is not None: payload['watchLater'] = watch_later menu_caches.append('watchlater-*.json') if position is not None: payload['position'] = position if total is not None: payload['total'] = total if whatson_id is not None: payload['whatsonId'] = whatson_id # First update resumepoints to a fast local cache because online resumpoints take a longer time to take effect self.update_local(asset_id, dict(value=payload), menu_caches) # Asynchronously update online from threading import Thread Thread(target=self.update_online, name='ResumePointsUpdate', args=(asset_id, title, url, payload)).start() else: # Delete log(3, "[Resumepoints] Delete resumepoint '{asset_id}' {position}/{total}", asset_id=asset_id, position=position, total=total) # Do nothing if there is no resumepoint for this asset_id if not self._data.get(asset_id): log(3, "[Resumepoints] '{asset_id}' not present, nothing to delete", asset_id=asset_id) return True # Add menu caches menu_caches.append('continue-*.json') if self.is_watchlater(asset_id): menu_caches.append('watchlater-*.json') # Delete local representation and cache self.delete_local(asset_id, menu_caches) # Asynchronously delete online from threading import Thread Thread(target=self.delete_online, name='ResumePointsDelete', args=(asset_id,)).start() return True def update_online(self, asset_id, title, url, payload): """Update resumepoint online""" from json import dumps try: get_url_json('https://video-user-data.vrt.be/resume_points/{asset_id}'.format(asset_id=asset_id), headers=self.resumepoint_headers(url), data=dumps(payload).encode()) except HTTPError as exc: log_error('Failed to (un)watch episode {title} at VRT NU ({error})', title=title, error=exc) notification(message=localize(30977, title=title)) return False return True def update_local(self, asset_id, resumepoint_json, menu_caches=None): """Update resumepoint locally and update cache""" self._data.update({asset_id: resumepoint_json}) from json import dumps update_cache('resume_points.json', dumps(self._data)) if menu_caches: invalidate_caches(*menu_caches) def delete_local(self, asset_id, menu_caches=None): """Delete resumepoint locally and update cache""" if asset_id in self._data: del self._data[asset_id] from json import dumps update_cache('resume_points.json', dumps(self._data)) if menu_caches: invalidate_caches(*menu_caches) def delete_online(self, asset_id): """Delete resumepoint online""" req = Request('https://video-user-data.vrt.be/resume_points/{asset_id}'.format(asset_id=asset_id), headers=self.resumepoint_headers()) req.get_method = lambda: 'DELETE' try: result = urlopen(req) log(3, "[Resumepoints] '{asset_id}' online deleted: {code}", asset_id=asset_id, code=result.getcode()) except HTTPError as exc: log_error("Failed to remove '{asset_id}' from resumepoints: {error}", asset_id=asset_id, error=exc) return False return True def is_watchlater(self, asset_id): """Is a program set to watch later ?""" return self._data.get(asset_id, {}).get('value', {}).get('watchLater') is True def watchlater(self, asset_id, title, url): """Watch an episode later""" succeeded = self.update(asset_id=asset_id, title=title, url=url, watch_later=True) if succeeded: notification(message=localize(30403, title=title)) container_refresh() def unwatchlater(self, asset_id, title, url, move_down=False): """Unwatch an episode later""" succeeded = self.update(asset_id=asset_id, title=title, url=url, watch_later=False) if succeeded: notification(message=localize(30404, title=title)) # If the current item is selected and we need to move down before removing if move_down: input_down() container_refresh() def get_position(self, asset_id): """Return the stored position of a video""" return self._data.get(asset_id, {}).get('value', {}).get('position', 0) def get_total(self, asset_id): """Return the stored total length of a video""" return self._data.get(asset_id, {}).get('value', {}).get('total', 100) def get_url(self, asset_id, url_type='medium'): """Return the stored url a video""" from utils import reformat_url return reformat_url(self._data.get(asset_id, {}).get('value', {}).get('url'), url_type) def watchlater_urls(self): """Return all watchlater urls""" return [self.get_url(asset_id) for asset_id in self._data if self.is_watchlater(asset_id)] def resumepoints_urls(self): """Return all urls that have not been finished watching""" return [self.get_url(asset_id) for asset_id in self._data if self.still_watching(self.get_position(asset_id), self.get_total(asset_id))] @staticmethod def still_watching(position, total): """Determine if the video is still being watched""" if None not in (position, total) and SECONDS_MARGIN < position < (total - SECONDS_MARGIN): return True return False