diff --git a/README.md b/README.md
index 1188862..284ec74 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ Git repo: https://github.com/MrKrabat/plugin.video.crunchyroll
- [x] Optionally soft-subs only
- [x] Configure up to two languages for subtitles / dubs
- [x] Crunchylists support
+- [x] UpNext addon integration
***
diff --git a/resources/language/resource.language.de_de/strings.po b/resources/language/resource.language.de_de/strings.po
index f6ee1f7..d000844 100644
--- a/resources/language/resource.language.de_de/strings.po
+++ b/resources/language/resource.language.de_de/strings.po
@@ -32,6 +32,10 @@ msgctxt "#30230"
msgid "Other"
msgstr "Sonstiges"
+msgctxt "#30240"
+msgid "Addons"
+msgstr "Erweiterungen"
+
msgctxt "#30101"
msgid "Email"
msgstr "E-Mail"
@@ -296,3 +300,27 @@ msgstr "Es sind zu viele Streams aktiv. Bitte probiere es später noch einmal."
msgctxt "#30090"
msgid "No search results"
msgstr "Keine Suchergebnisse"
+
+msgctxt "#30319"
+msgid "UpNext fixed or unavailable end detection duration"
+msgstr "Vorlaufzeit für UpNext wenn \"Statisch\" oder keine Credits/Vorschau"
+
+msgctxt "#30300"
+msgid "Show UpNext dialog at"
+msgstr "UpNext Integration"
+
+msgctxt "#30301"
+msgid "credits start"
+msgstr "Zu Beginn der Credits, wenn danach keine Vorschau kommt"
+
+msgctxt "#30302"
+msgid "preview start"
+msgstr "Zu Beginn der Vorschau"
+
+msgctxt "#30303"
+msgid "fixed time before the end"
+msgstr "Statisch"
+
+msgctxt "#30304"
+msgid "never (disabled)"
+msgstr "Deaktiviert"
diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po
index 933652e..5334d9c 100644
--- a/resources/language/resource.language.en_gb/strings.po
+++ b/resources/language/resource.language.en_gb/strings.po
@@ -31,6 +31,10 @@ msgctxt "#30230"
msgid "Other"
msgstr ""
+msgctxt "#30240"
+msgid "Addons"
+msgstr ""
+
msgctxt "#30001"
msgid "Email"
msgstr ""
@@ -297,4 +301,28 @@ msgstr ""
msgctxt "#30090"
msgid "No search results"
-msgstr "No search results"
\ No newline at end of file
+msgstr "No search results"
+
+msgctxt "#30319"
+msgid "UpNext fixed or unavailable end detection duration"
+msgstr ""
+
+msgctxt "#30300"
+msgid "Show UpNext dialog at"
+msgstr ""
+
+msgctxt "#30301"
+msgid "credits start"
+msgstr ""
+
+msgctxt "#30302"
+msgid "preview start"
+msgstr ""
+
+msgctxt "#30303"
+msgid "fixed time before the end"
+msgstr ""
+
+msgctxt "#30304"
+msgid "never (disabled)"
+msgstr ""
diff --git a/resources/language/resource.language.es_es/strings.po b/resources/language/resource.language.es_es/strings.po
index 6367f29..bcee097 100644
--- a/resources/language/resource.language.es_es/strings.po
+++ b/resources/language/resource.language.es_es/strings.po
@@ -31,6 +31,10 @@ msgctxt "#30230"
msgid "Other"
msgstr "Otro"
+msgctxt "#30240"
+msgid "Addons"
+msgstr ""
+
msgctxt "#30001"
msgid "Email"
msgstr "Email"
@@ -296,4 +300,28 @@ msgstr ""
msgctxt "#30090"
msgid "No search results"
-msgstr ""
\ No newline at end of file
+msgstr ""
+
+msgctxt "#30319"
+msgid "UpNext fixed or unavailable end detection duration"
+msgstr ""
+
+msgctxt "#30300"
+msgid "Show UpNext dialog at"
+msgstr ""
+
+msgctxt "#30301"
+msgid "credits start"
+msgstr ""
+
+msgctxt "#30302"
+msgid "preview start"
+msgstr ""
+
+msgctxt "#30303"
+msgid "fixed time before the end"
+msgstr ""
+
+msgctxt "#30304"
+msgid "never (disabled)"
+msgstr ""
diff --git a/resources/language/resource.language.fr_fr/strings.po b/resources/language/resource.language.fr_fr/strings.po
index fd379e5..e07c4ba 100644
--- a/resources/language/resource.language.fr_fr/strings.po
+++ b/resources/language/resource.language.fr_fr/strings.po
@@ -21,7 +21,7 @@ msgstr "Se connecter"
msgctxt "#30210"
msgid "Languages"
-msgstr "Lanagages"
+msgstr "Langages"
msgctxt "#30220"
msgid "Playback"
@@ -31,6 +31,10 @@ msgctxt "#30230"
msgid "Other"
msgstr "Autre"
+msgctxt "#30240"
+msgid "Addons"
+msgstr "Extensions"
+
msgctxt "#30001"
msgid "Email"
msgstr "Email"
@@ -241,11 +245,11 @@ msgstr "Une erreur est survenue"
msgctxt "#30062"
msgid "You need to be logged in"
-msgstr "Vus devez être connecté"
+msgstr "Vous devez être connecté"
msgctxt "#30063"
msgid "You need to be a premium member"
-msgstr "Voius devez être un membre premium"
+msgstr "Vous devez être un membre premium"
msgctxt "#30064"
msgid "Failed to play video"
@@ -253,7 +257,7 @@ msgstr "Erreur de lecture vidéo"
msgctxt "#30065"
msgid "Do you want to continue watching at %s%%?"
-msgstr "Voulez vous continuer à regarder depuis %s%% ?"
+msgstr "Voulez-vous continuer à regarder depuis %s%% ?"
msgctxt "#30066"
msgid "If the video does not start wait 30 seconds for the fallback to start."
@@ -269,7 +273,7 @@ msgstr "Supprimer de la file d'attente"
msgctxt "#30069"
msgid "Alternative Subtitle Language"
-msgstr "Langage de sous-titres alternatif"
+msgstr "Langue de sous-titres alternatif"
msgctxt "#30070"
msgid "None"
@@ -289,8 +293,32 @@ msgstr "Choisissez votre profil"
msgctxt "#30080"
msgid "There are too many active streams. Please try again later."
-msgstr ""
+msgstr "Il y a trop de streams actifs. Ré-essayez plus tard."
msgctxt "#30090"
msgid "No search results"
-msgstr ""
\ No newline at end of file
+msgstr "Aucun résultat"
+
+msgctxt "#30319"
+msgid "UpNext fixed or unavailable end detection duration"
+msgstr "Durée fixe ou de détection indisponible pour UpNext"
+
+msgctxt "#30300"
+msgid "Show UpNext dialog at"
+msgstr "Afficher la fenêtre UpNext quand"
+
+msgctxt "#30301"
+msgid "credits start"
+msgstr "le générique commence"
+
+msgctxt "#30302"
+msgid "preview start"
+msgstr "la preview commence"
+
+msgctxt "#30303"
+msgid "fixed time before the end"
+msgstr "une durée fixe avant la fin"
+
+msgctxt "#30304"
+msgid "never (disabled)"
+msgstr "jamais (désactivé)"
diff --git a/resources/language/resource.language.pt_br/strings.po b/resources/language/resource.language.pt_br/strings.po
index b579680..0852cf5 100644
--- a/resources/language/resource.language.pt_br/strings.po
+++ b/resources/language/resource.language.pt_br/strings.po
@@ -31,6 +31,10 @@ msgctxt "#30230"
msgid "Other"
msgstr "Outros"
+msgctxt "#30240"
+msgid "Addons"
+msgstr ""
+
msgctxt "#30001"
msgid "Username"
msgstr "E-mail"
@@ -293,4 +297,28 @@ msgstr "Muitas streams ativas. Por favor, tente mais tarde"
msgctxt "#30090"
msgid "No search results"
-msgstr ""
\ No newline at end of file
+msgstr ""
+
+msgctxt "#30319"
+msgid "UpNext fixed or unavailable end detection duration"
+msgstr ""
+
+msgctxt "#30300"
+msgid "Show UpNext dialog at"
+msgstr ""
+
+msgctxt "#30301"
+msgid "credits start"
+msgstr ""
+
+msgctxt "#30302"
+msgid "preview start"
+msgstr ""
+
+msgctxt "#30303"
+msgid "fixed time before the end"
+msgstr ""
+
+msgctxt "#30304"
+msgid "never (disabled)"
+msgstr ""
diff --git a/resources/lib/addons/__init__.py b/resources/lib/addons/__init__.py
new file mode 100644
index 0000000..b93054b
--- /dev/null
+++ b/resources/lib/addons/__init__.py
@@ -0,0 +1 @@
+# Dummy file to make this directory a package.
diff --git a/resources/lib/addons/upnext.py b/resources/lib/addons/upnext.py
new file mode 100644
index 0000000..c7e2cb9
--- /dev/null
+++ b/resources/lib/addons/upnext.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+# Crunchyroll
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from base64 import b64encode
+from json import dumps
+from typing import Optional
+
+from resources.lib.model import Args, PlayableItem, SeriesData
+
+from . import utils
+
+
+def send_next_info(
+ args: Args,
+ current_episode: PlayableItem,
+ next_episode: PlayableItem,
+ play_url: str,
+ notification_offset: Optional[int] = None,
+ series: Optional[SeriesData] = None
+):
+ """
+ Notify next episode info to upnext.
+ See https://github.com/im85288/service.upnext/wiki/Integration#sending-data-to-up-next for implementation details.
+ """
+ current_upnext_episode = UpnextEpisode(current_episode, series)
+ next_upnext_episode = UpnextEpisode(next_episode, series)
+ next_info = {
+ "current_episode": current_upnext_episode.__dict__,
+ "next_episode": next_upnext_episode.__dict__,
+ "play_url": play_url,
+ }
+ if notification_offset is not None:
+ next_info["notification_offset"] = notification_offset
+ upnext_signal(args.addon_id, next_info)
+
+
+class UpnextEpisode:
+ def __init__(self, dto: PlayableItem, series_dto: Optional[SeriesData]):
+ self.episodeid: str | None = dto.episode_id
+ self.tvshowid: str | None = dto.series_id
+ self.title: str = dto.title_unformatted
+ self.art: dict = {
+ "thumb": dto.thumb,
+ }
+ if series_dto:
+ self.art.update({
+ # "tvshow.clearart": series_dto.clearart,
+ # "tvshow.clearlogo": series_dto.clearlogo,
+ "tvshow.fanart": series_dto.fanart,
+ "tvshow.landscape": series_dto.fanart,
+ "tvshow.poster": series_dto.poster,
+ })
+ self.season: int = dto.season
+ self.episode: str = dto.episode
+ self.showtitle: str = dto.tvshowtitle
+ self.plot: str = dto.plot
+ self.playcount: int = dto.playcount
+ # self.rating: str = dto.rating
+ self.firstaired: str = dto.year
+ self.runtime: int = dto.duration
+
+
+def upnext_signal(sender, next_info):
+ """Send upnext_data to Kodi using JSON RPC"""
+ data = [utils.to_unicode(b64encode(dumps(next_info).encode()))]
+ utils.notify(sender=sender + '.SIGNAL', message='upnext_data', data=data)
diff --git a/resources/lib/addons/utils.py b/resources/lib/addons/utils.py
new file mode 100644
index 0000000..cc3adb4
--- /dev/null
+++ b/resources/lib/addons/utils.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Crunchyroll
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+"""
+This file exposes functions to send notifications to other XBMC addons.
+Its original version was taken from service.upnext wiki at
+https://github.com/im85288/service.upnext/wiki/Example-source-code
+"""
+import xbmc
+
+def notify(sender, message, data):
+ """Send a notification to Kodi using JSON RPC"""
+ result = jsonrpc(method='JSONRPC.NotifyAll', params=dict(
+ sender=sender,
+ message=message,
+ data=data,
+ ))
+ if result.get('result') != 'OK':
+ xbmc.log('Failed to send notification: ' + result.get('error').get('message'), xbmc.LOGERROR)
+ return False
+ return True
+
+def jsonrpc(**kwargs):
+ """Perform JSONRPC calls"""
+ from json import dumps, loads
+ if kwargs.get('id') is None:
+ kwargs.update(id=0)
+ if kwargs.get('jsonrpc') is None:
+ kwargs.update(jsonrpc='2.0')
+ return loads(xbmc.executeJSONRPC(dumps(kwargs)))
+
+def to_unicode(text, encoding='utf-8', errors='strict'):
+ """Force text to unicode"""
+ if isinstance(text, bytes):
+ return text.decode(encoding, errors=errors)
+ return text
diff --git a/resources/lib/api.py b/resources/lib/api.py
index b4311c0..1b1693d 100644
--- a/resources/lib/api.py
+++ b/resources/lib/api.py
@@ -68,6 +68,7 @@ class API:
RESUME_ENDPOINT = "https://beta-api.crunchyroll.com/content/v2/discover/{}/history"
SEASONAL_TAGS_ENDPOINT = "https://beta-api.crunchyroll.com/content/v2/discover/seasonal_tags"
CATEGORIES_ENDPOINT = "https://beta-api.crunchyroll.com/content/v1/tenant_categories"
+ UPNEXT_ENDPOINT = "https://beta-api.crunchyroll.com/content/v2/discover/up_next/{}"
SKIP_EVENTS_ENDPOINT = "https://static.crunchyroll.com/skip-events/production/{}.json" # request w/o auth req.
INTRO_V2_ENDPOINT = "https://static.crunchyroll.com/datalab-intro-v2/{}.json"
diff --git a/resources/lib/videoplayer.py b/resources/lib/videoplayer.py
index 5d66b95..70d38a7 100644
--- a/resources/lib/videoplayer.py
+++ b/resources/lib/videoplayer.py
@@ -24,11 +24,13 @@
import xbmcgui
import xbmcplugin
-from resources.lib import utils
-from resources.lib.globals import G
-from resources.lib.gui import SkipModalDialog, _show_modal_dialog
-from resources.lib.model import Object, CrunchyrollError, LoginError
-from resources.lib.videostream import VideoPlayerStreamData, VideoStream
+from . import utils, view
+from .addons import upnext
+from .api import API
+from .globals import G
+from .gui import SkipModalDialog, _show_modal_dialog
+from .model import Object, CrunchyrollError, LoginError
+from .videostream import VideoPlayerStreamData, VideoStream
class VideoPlayer(Object):
@@ -59,6 +61,8 @@ def start_playback(self):
self._handle_skipping()
self._handle_update_playhead()
+ self._handle_upnext()
+
def is_playing(self) -> bool:
""" Returns true if playback is running. Note that it also returns true when paused. """
@@ -198,6 +202,41 @@ def _handle_skipping(self):
utils.crunchy_log("_handle_skipping: starting thread", xbmc.LOGINFO)
threading.Thread(target=self.thread_check_skipping).start()
+ def _handle_upnext(self):
+ try:
+ next_episode = self._stream_data.next_playable_item
+ if not next_episode:
+ utils.crunchy_log("_handle_upnext: No episode or disabled upnext integration")
+ return
+ next_url = view.build_url(
+ {
+ "series_id": G.args.get_arg("series_id"),
+ "episode_id": next_episode.episode_id,
+ "stream_id": next_episode.stream_id
+ },
+ "video_episode_play"
+ )
+ show_next_at_seconds = self._stream_data.end_timecode
+ if show_next_at_seconds is not None:
+ # Needs to wait 10s, otherwise, upnext will show next dialog at episode start...
+ xbmc.sleep(10000)
+ utils.crunchy_log("_handle_upnext: Next URL (shown at %ds / %ds): %s" % (
+ show_next_at_seconds,
+ self._stream_data.playable_item.duration,
+ next_url
+ ))
+
+ upnext.send_next_info(
+ G.args,
+ self._stream_data.playable_item,
+ next_episode,
+ next_url,
+ show_next_at_seconds,
+ self._stream_data.playable_item_parent
+ )
+ except Exception:
+ utils.crunchy_log("_handle_upnext: Cannot send upnext notification", xbmc.LOGERROR)
+
def thread_update_playhead(self):
""" background thread to update playback with crunchyroll in intervals """
@@ -259,13 +298,17 @@ def _check_and_filter_skip_data(self) -> bool:
if not self._stream_data.skip_events_data:
return False
+ # never skip preview (fetched for upnext)
+ if self._stream_data.skip_events_data.get('preview'):
+ self._stream_data.skip_events_data.pop('preview', None)
+
# if not enabled in config, remove from our list
- if G.args.addon.getSetting("enable_skip_intro") != "true" and self._stream_data.skip_events_data.get(
+ if G.args.addon.getSetting('enable_skip_intro') != 'true' and self._stream_data.skip_events_data.get(
'intro'):
self._stream_data.skip_events_data.pop('intro', None)
- if G.args.addon.getSetting("enable_skip_credits") != "true" and self._stream_data.skip_events_data.get(
- 'credits'):
+ if (G.args.addon.getSetting('enable_skip_credits') != 'true' or self._stream_data.end_marker == 'credits') and (
+ self._stream_data.skip_events_data.get('credits') ):
self._stream_data.skip_events_data.pop('credits', None)
return len(self._stream_data.skip_events_data) > 0
@@ -301,7 +344,7 @@ def clear_active_stream(self):
Crunchyroll keeps track of started streams. If they are not released, CR will block starting a new one.
"""
- if not G.args.get_arg('episode_id') or not self._stream_data.token:
+ if not G.args.get_arg('episode_id') or not self._stream_data or not self._stream_data.token:
return
try:
diff --git a/resources/lib/videostream.py b/resources/lib/videostream.py
index 03ddb1f..684bc9a 100644
--- a/resources/lib/videostream.py
+++ b/resources/lib/videostream.py
@@ -45,6 +45,9 @@ def __init__(self):
# PlayableItem which contains cms obj data of playable_item's parent, if exists (Episodes, not Movies). currently not used.
self.playable_item_parent: PlayableItem | None = None
self.token: str | None = None
+ self.next_playable_item: PlayableItem | None = None
+ self.end_marker: str = "off"
+ self.end_timecode: int | None = None
class VideoStream(Object):
@@ -89,35 +92,45 @@ def get_player_stream_data(self) -> Optional[VideoPlayerStreamData]:
video_player_stream_data.playheads_data = async_data.get('playheads_data')
video_player_stream_data.playable_item = async_data.get('playable_item')
video_player_stream_data.playable_item_parent = async_data.get('playable_item_parent')
+ video_player_stream_data.next_playable_item = async_data.get('next_playable_item')
+
+ video_end = self._compute_when_episode_ends(video_player_stream_data)
+
+ video_player_stream_data.end_marker = video_end.get('marker')
+ video_player_stream_data.end_timecode = video_end.get('timecode')
return video_player_stream_data
async def _gather_async_data(self) -> Dict[str, Any]:
""" gather data asynchronously and return them as a dictionary """
+ episode_id = G.args.get_arg('episode_id')
+ series_id = G.args.get_arg('series_id')
+
# create threads
# actually not sure if this works, as the requests lib is not async
# also not sure if this is thread safe in any way, what if session is timed-out when starting this?
t_stream_data = asyncio.create_task(self._get_stream_data_from_api())
- t_skip_events_data = asyncio.create_task(self._get_skip_events(G.args.get_arg('episode_id')))
- t_playheads = asyncio.create_task(get_playheads_from_api(G.args.get_arg('episode_id')))
- t_item_data = asyncio.create_task(
- get_cms_object_data_by_ids([G.args.get_arg('episode_id')]))
- # t_item_parent_data = asyncio.create_task(get_cms_object_data_by_ids(G.args, G.api, G.args.get_arg('series_id')))
+ t_skip_events_data = asyncio.create_task(self._get_skip_events(episode_id))
+ t_playheads = asyncio.create_task(get_playheads_from_api(episode_id))
+ t_item_data = asyncio.create_task(get_cms_object_data_by_ids([episode_id, series_id]))
+ t_upnext_data = asyncio.create_task(self._get_upnext_episode(episode_id))
# start async requests and fetch results
- results = await asyncio.gather(t_stream_data, t_skip_events_data, t_playheads, t_item_data)
+ results = await asyncio.gather(t_stream_data, t_skip_events_data, t_playheads, t_item_data, t_upnext_data)
- playable_item = get_listables_from_response([results[3].get(G.args.get_arg('episode_id'))]) if \
- results[3] else None
+ listable_items = get_listables_from_response([value for key, value in results[3].items()]) if results[3] else []
+ playable_items = [item for item in listable_items if item.id == episode_id]
+ parent_listables = [item for item in listable_items if item.id == series_id]
+ upnext_items = get_listables_from_response([results[4]]) if results[4] else None
return {
'stream_data': results[0] or {},
'skip_events_data': results[1] or {},
'playheads_data': results[2] or {},
- 'playable_item': playable_item[0] if playable_item else None,
- 'playable_item_parent': None
- # get_listables_from_response([results[4]])[0] if results[4] else None
+ 'playable_item': playable_items[0] if playable_items else None,
+ 'playable_item_parent': parent_listables[0] if parent_listables else None,
+ 'next_playable_item': upnext_items[0] if upnext_items else None,
}
@staticmethod
@@ -308,7 +321,8 @@ async def _get_skip_events(episode_id) -> Optional[Dict]:
# if none of the skip options are enabled in setting, don't fetch that data
if (G.args.addon.getSetting("enable_skip_intro") != "true" and
- G.args.addon.getSetting("enable_skip_credits") != "true"):
+ G.args.addon.getSetting("enable_skip_credits") != "true" and
+ G.args.addon.getSetting("upnext_mode") == "disabled"):
return None
try:
@@ -343,7 +357,7 @@ async def _get_skip_events(episode_id) -> Optional[Dict]:
return None
# prepare the data a bit
- supported_skips = ['intro', 'credits']
+ supported_skips = ['intro', 'credits', 'preview']
prepared = dict()
for skip_type in supported_skips:
if req.get(skip_type) and req.get(skip_type).get('start') is not None and req.get(skip_type).get(
@@ -356,3 +370,91 @@ async def _get_skip_events(episode_id) -> Optional[Dict]:
crunchy_log("_get_skip_events: check for %s FAILED" % skip_type, xbmc.LOGINFO)
return prepared if len(prepared) > 0 else None
+
+ @staticmethod
+ async def _get_upnext_episode(id: str) -> Optional[Dict]:
+ """ fetch upnext episode data from api """
+
+ # if upnext integration is disabled, don't fetch data
+ if G.args.addon.getSetting("upnext_mode") == "disabled":
+ return None
+
+ try:
+ req = G.api.make_request(
+ method="GET",
+ url=G.api.UPNEXT_ENDPOINT.format(id),
+ params={
+ "locale": G.args.subtitle
+ }
+ )
+ except (CrunchyrollError, requests.exceptions.RequestException) as e:
+ crunchy_log("_get_upnext_episode: failed to load for: %s" % id)
+ return None
+ if not req or "error" in req or len(req.get("data", [])) == 0:
+ return None
+
+ return req.get("data")[0]
+
+ @staticmethod
+ def _compute_when_episode_ends(partial_stream_data: VideoPlayerStreamData) -> Dict[str, Any]:
+ """ Extract timecode for video end from skip_events_data.
+
+ Extracted timecode depends on *upnext_mode* user setting and available skip events data.
+ upnext_mode can hold 4 different behaviour.
+ - "disabled", so no need to compute anything.
+ - "fixed", so we should send the timecode for the last 15s (user can change this duration by *upnext_fixed_duration* settings).
+ - "preview", which means we have to retrieve preview timecode from skip event API.
+ If preview timecode is not available, go back to the same behaviour as "fixed" mode.
+ - "credits", which means we have to retrieve credits and preview timecode from skip event API.
+ If credits timecode is not available, go back to the same behaviour as "preview" mode.
+ Additionaly, we have to check there is no additional scenes after credits,
+ so we check if preview starts at credits end. Otherwise, video end timecode will be the preview start timecode.
+ """
+
+ result = {
+ 'marker': 'off',
+ 'timecode': None
+ }
+ upnext_mode = G.args.addon.getSetting('upnext_mode')
+ if upnext_mode == 'disabled' or not partial_stream_data.next_playable_item:
+ return result
+
+ video_end = partial_stream_data.playable_item.duration
+ fixed_duration = int(G.args.addon.getSetting('upnext_fixed_duration'), 10)
+ # Standard behaviour is to show upnext 15s before the end of the video
+ result = {
+ 'marker': 'fixed',
+ 'timecode': video_end - fixed_duration
+ }
+
+ skip_events_data = partial_stream_data.skip_events_data
+ # If upnext selected mode is fixed or there is no available skip data
+ if upnext_mode == 'fixed' or not skip_events_data or (not skip_events_data.get('credits') and not skip_events_data.get('preview')):
+ return result
+
+ # Extract skip data
+ credits_start = skip_events_data.get('credits', {}).get('start')
+ credits_end = skip_events_data.get('credits', {}).get('end')
+ preview_start = skip_events_data.get('preview', {}).get('start')
+ preview_end = skip_events_data.get('preview', {}).get('end')
+
+ # If there is no data about preview but credits ends less than 20s before the end, consider time after credits_end is the preview
+ if not preview_start and credits_end and credits_end >= video_end - 20:
+ preview_start = credits_end
+ preview_end = video_end
+
+ # If there are outro and preview
+ # and if the outro ends when the preview start
+ if upnext_mode == 'credits' and credits_start and credits_end and preview_start and credits_end + 3 > preview_start:
+ result = {
+ 'marker': 'credits',
+ 'timecode': credits_start
+ }
+ # If there is a preview
+ elif preview_start:
+ result = {
+ 'marker': 'preview',
+ 'timecode': preview_start
+ }
+
+ return result
diff --git a/resources/settings.xml b/resources/settings.xml
index c40b5fd..360ff03 100644
--- a/resources/settings.xml
+++ b/resources/settings.xml
@@ -149,5 +149,31 @@
+
+
+
+ 0
+ disabled
+
+
+
+
+
+
+
+
+
+ 30300
+
+
+
+ 0
+ 15
+
+ 30319
+
+
+
+