From fd0dd73d8aec9cf35564609986064ab8655a108f Mon Sep 17 00:00:00 2001 From: lumiru Date: Sat, 30 Dec 2023 21:10:41 +0100 Subject: [PATCH 1/4] feat: UpNext addon integration --- README.md | 1 + .../resource.language.fr_fr/strings.po | 42 +++++++++-- resources/lib/addons/__init__.py | 1 + resources/lib/addons/upnext.py | 68 ++++++++++++++++++ resources/lib/addons/utils.py | 49 +++++++++++++ resources/lib/api.py | 1 + resources/lib/videoplayer.py | 70 +++++++++++++++++-- resources/lib/videostream.py | 57 +++++++++++---- resources/settings.xml | 26 +++++++ 9 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 resources/lib/addons/__init__.py create mode 100644 resources/lib/addons/upnext.py create mode 100644 resources/lib/addons/utils.py 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.fr_fr/strings.po b/resources/language/resource.language.fr_fr/strings.po index ea18b42..be8b094 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" @@ -69,7 +73,7 @@ msgstr "Passer" msgctxt "#30020" msgid "Subtitle Language" -msgstr "Language des sous titres" +msgstr "Langue des sous titres" msgctxt "#30021" msgid "English" @@ -216,11 +220,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" @@ -228,7 +232,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." @@ -244,7 +248,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" @@ -264,4 +268,28 @@ msgstr "Choisissez votre profil" msgctxt "#30080" msgid "There are too many active streams. Please try again later." -msgstr "" \ No newline at end of file +msgstr "Il y a trop de streams actifs. Ré-essayez plus tard." + +msgctxt "#30089" +msgid "UpNext fixed or unavailable end detection duration" +msgstr "Durée fixe ou de détection indisponible pour UpNext" + +msgctxt "#30090" +msgid "UpNext integration" +msgstr "Intégration à UpNext" + +msgctxt "#30091" +msgid "At credits start, if nothing after" +msgstr "Dès le générique, s'il n'y a rien après" + +msgctxt "#30092" +msgid "At preview start" +msgstr "Au début de la bande-annonce du suivant" + +msgctxt "#30093" +msgid "Fixed" +msgstr "Fixé" + +msgctxt "#30094" +msgid "Disabled" +msgstr "Désactivé" 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..de278e6 --- /dev/null +++ b/resources/lib/addons/upnext.py @@ -0,0 +1,68 @@ +# -*- 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 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: int | None = None, series: SeriesData | None = 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 = UpnextEpisode(current_episode, series) + next = UpnextEpisode(next_episode, series) + next_info = { + "current_episode": current.__dict__, + "next_episode": next.__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: SeriesData | None): + 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 d5b2d17..1447d88 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 4b7d165..ab415ea 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): @@ -56,9 +58,12 @@ def start_playback(self): self._handle_update_playhead() self._handle_skipping() + if not self._wait_for_playback_started(10): utils.crunchy_log('Timeout reached, video did not start playback in 10 seconds', xbmc.LOGERROR) + self._handle_upnext() + def is_playing(self) -> bool: """ Returns true if playback is running. Note that it also returns true when paused. """ @@ -207,6 +212,59 @@ 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._compute_when_episode_ends() + # Needs to wait 1s, otherwise, upnext will show next dialog at episode start... + xbmc.sleep(1000) + utils.crunchy_log("_handle_upnext: Next URL (shown at %ds): %s" % (show_next_at_seconds, 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 _compute_when_episode_ends(self) -> int: + upnext_mode = G.args.addon.getSetting("upnext_mode") + if upnext_mode == "disabled": + return None + + video_end = self._stream_data.playable_item.duration + fixed_duration = int(G.args.addon.getSetting("upnext_fixed_duration"), 10) + result = video_end - fixed_duration + + skip_events_data = self._stream_data.unmodified_skip_events_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 + + 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 are outro and preview + # and if the outro ends when the preview start + if upnext_mode == "best" and credits_start and credits_end and preview_start and credits_end + 3 > preview_start: + result = credits_start + # If there is a preview + elif preview_start: + result = preview_start + # If there is outro without preview + # and if the outro ends in the last 20 seconds video + elif upnext_mode == "best" and credits_start and credits_end and video_end <= credits_end + 20: + result = credits_start + + return result + def thread_update_playhead(self): """ background thread to update playback with crunchyroll in intervals """ @@ -306,7 +364,7 @@ def _ask_to_skip(self, section): ).start() def clear_active_stream(self): - 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 c07a8c4..38ec4b2 100644 --- a/resources/lib/videostream.py +++ b/resources/lib/videostream.py @@ -39,12 +39,14 @@ def __init__(self): self.stream_url: str | None = None self.subtitle_urls: list[str] | None = None self.skip_events_data: Dict = {} + self.unmodified_skip_events_data: Dict = {} self.playheads_data: Dict = {} # PlayableItem which is about to be played, that contains cms object data self.playable_item: PlayableItem | None = None # 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 class VideoStream(Object): @@ -86,38 +88,44 @@ def get_player_stream_data(self) -> Optional[VideoPlayerStreamData]: video_player_stream_data.token = async_data.get('stream_data').get('token') video_player_stream_data.skip_events_data = async_data.get('skip_events_data') + video_player_stream_data.unmodified_skip_events_data = dict(async_data.get('skip_events_data')) 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') 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 @@ -311,7 +319,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: @@ -359,3 +368,27 @@ 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] diff --git a/resources/settings.xml b/resources/settings.xml index 0c1e831..49b70ec 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -125,5 +125,31 @@ + + + + 0 + disabled + + + + + + + + + + 30090 + + + + 0 + 15 + + 30089 + + + + From c3aabb3aac0eabe2c931f9469f3944602e876a75 Mon Sep 17 00:00:00 2001 From: smirgol <14124899+smirgol@users.noreply.github.com> Date: Tue, 14 May 2024 21:25:31 +0200 Subject: [PATCH 2/4] upnext changes * add missing translations * fix typing errors in upnext.py * send call to UpNext only if enabled --- .../resource.language.de_de/strings.po | 28 +++++++++++++++++ .../resource.language.en_gb/strings.po | 28 +++++++++++++++++ .../resource.language.es_es/strings.po | 28 +++++++++++++++++ .../resource.language.pt_br/strings.po | 30 ++++++++++++++++++- resources/lib/addons/upnext.py | 23 ++++++++++---- resources/lib/videoplayer.py | 21 ++++++++++--- 6 files changed, 147 insertions(+), 11 deletions(-) diff --git a/resources/language/resource.language.de_de/strings.po b/resources/language/resource.language.de_de/strings.po index 882078a..2e05c82 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" @@ -267,3 +271,27 @@ msgstr "Profil auswählen" msgctxt "#30080" msgid "There are too many active streams. Please try again later." msgstr "Es sind zu viele Streams aktiv. Bitte probiere es später noch einmal." + +msgctxt "#30089" +msgid "UpNext fixed or unavailable end detection duration" +msgstr "Vorlaufzeit für UpNext wenn \"Statisch\" oder keine Credits/Vorschau" + +msgctxt "#30090" +msgid "UpNext integration" +msgstr "UpNext Integration" + +msgctxt "#30091" +msgid "At credits start, if nothing after" +msgstr "Zu Beginn der Credits, wenn danach keine Vorschau kommt" + +msgctxt "#30092" +msgid "At preview start" +msgstr "Zu Beginn der Vorschau" + +msgctxt "#30093" +msgid "Fixed" +msgstr "Statisch" + +msgctxt "#30094" +msgid "Disabled" +msgstr "Deaktiviert" \ No newline at end of file diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 0d134ed..40cde65 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 "" @@ -269,3 +273,27 @@ msgstr "" msgctxt "#30080" msgid "There are too many active streams. Please try again later." msgstr "" + +msgctxt "#30089" +msgid "UpNext fixed or unavailable end detection duration" +msgstr "" + +msgctxt "#30090" +msgid "UpNext integration" +msgstr "" + +msgctxt "#30091" +msgid "At credits start, if nothing after" +msgstr "" + +msgctxt "#30092" +msgid "At preview start" +msgstr "" + +msgctxt "#30093" +msgid "Fixed" +msgstr "" + +msgctxt "#30094" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/resources/language/resource.language.es_es/strings.po b/resources/language/resource.language.es_es/strings.po index 46ce97d..bbeae48 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" @@ -269,3 +273,27 @@ msgstr "" msgctxt "#30080" msgid "There are too many active streams. Please try again later." msgstr "" + +msgctxt "#30089" +msgid "UpNext fixed or unavailable end detection duration" +msgstr "" + +msgctxt "#30090" +msgid "UpNext integration" +msgstr "" + +msgctxt "#30091" +msgid "At credits start, if nothing after" +msgstr "" + +msgctxt "#30092" +msgid "At preview start" +msgstr "" + +msgctxt "#30093" +msgid "Fixed" +msgstr "" + +msgctxt "#30094" +msgid "Disabled" +msgstr "" diff --git a/resources/language/resource.language.pt_br/strings.po b/resources/language/resource.language.pt_br/strings.po index ce75e27..47101db 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" @@ -265,4 +269,28 @@ msgstr "" msgctxt "#30080" msgid "There are too many active streams. Please try again later." -msgstr "Muitas streams ativas. Por favor, tente mais tarde" \ No newline at end of file +msgstr "Muitas streams ativas. Por favor, tente mais tarde" + +msgctxt "#30089" +msgid "UpNext fixed or unavailable end detection duration" +msgstr "" + +msgctxt "#30090" +msgid "UpNext integration" +msgstr "" + +msgctxt "#30091" +msgid "At credits start, if nothing after" +msgstr "" + +msgctxt "#30092" +msgid "At preview start" +msgstr "" + +msgctxt "#30093" +msgid "Fixed" +msgstr "" + +msgctxt "#30094" +msgid "Disabled" +msgstr "" \ No newline at end of file diff --git a/resources/lib/addons/upnext.py b/resources/lib/addons/upnext.py index de278e6..c7e2cb9 100644 --- a/resources/lib/addons/upnext.py +++ b/resources/lib/addons/upnext.py @@ -16,29 +16,39 @@ 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: int | None = None, series: SeriesData | None = None): + +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 = UpnextEpisode(current_episode, series) - next = UpnextEpisode(next_episode, series) + current_upnext_episode = UpnextEpisode(current_episode, series) + next_upnext_episode = UpnextEpisode(next_episode, series) next_info = { - "current_episode": current.__dict__, - "next_episode": next.__dict__, + "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: SeriesData | None): + 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 @@ -62,6 +72,7 @@ def __init__(self, dto: PlayableItem, series_dto: SeriesData | None): 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()))] diff --git a/resources/lib/videoplayer.py b/resources/lib/videoplayer.py index ab415ea..80b5638 100644 --- a/resources/lib/videoplayer.py +++ b/resources/lib/videoplayer.py @@ -227,10 +227,23 @@ def _handle_upnext(self): "video_episode_play" ) show_next_at_seconds = self._compute_when_episode_ends() - # Needs to wait 1s, otherwise, upnext will show next dialog at episode start... - xbmc.sleep(1000) - utils.crunchy_log("_handle_upnext: Next URL (shown at %ds): %s" % (show_next_at_seconds, 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) + if show_next_at_seconds is not None: + # Needs to wait 1s, otherwise, upnext will show next dialog at episode start... + xbmc.sleep(1000) + 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) From c1aef09ee07aaee51133c8f4b0c62fea3939d034 Mon Sep 17 00:00:00 2001 From: smirgol <14124899+smirgol@users.noreply.github.com> Date: Tue, 14 May 2024 21:33:34 +0200 Subject: [PATCH 3/4] upnext changes * make return value of _compute_when_episode_ends optional --- resources/lib/videoplayer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/videoplayer.py b/resources/lib/videoplayer.py index 80b5638..8d2905e 100644 --- a/resources/lib/videoplayer.py +++ b/resources/lib/videoplayer.py @@ -247,7 +247,7 @@ def _handle_upnext(self): except Exception: utils.crunchy_log("_handle_upnext: Cannot send upnext notification", xbmc.LOGERROR) - def _compute_when_episode_ends(self) -> int: + def _compute_when_episode_ends(self) -> Optional[int]: upnext_mode = G.args.addon.getSetting("upnext_mode") if upnext_mode == "disabled": return None From 10b3382e54bf8d52f7d5802b2a15a59c5cb0ba52 Mon Sep 17 00:00:00 2001 From: lumiru Date: Wed, 15 May 2024 20:44:16 +0200 Subject: [PATCH 4/4] feat: UpNext integration with skip credits button _compute_when_episode_ends was moved to videostream.py since it not need the player context to work and only need to expose data about video stream. 'best' upnext_mode was renamed to 'credits' (because 'best' does not mean anything). translations was updated with easier to understand wording. --- .../resource.language.de_de/strings.po | 12 +-- .../resource.language.en_gb/strings.po | 12 +-- .../resource.language.es_es/strings.po | 10 +-- .../resource.language.fr_fr/strings.po | 20 ++--- .../resource.language.pt_br/strings.po | 12 +-- resources/lib/videoplayer.py | 47 +++--------- resources/lib/videostream.py | 75 ++++++++++++++++++- resources/settings.xml | 2 +- 8 files changed, 116 insertions(+), 74 deletions(-) diff --git a/resources/language/resource.language.de_de/strings.po b/resources/language/resource.language.de_de/strings.po index 2e05c82..bd01e67 100644 --- a/resources/language/resource.language.de_de/strings.po +++ b/resources/language/resource.language.de_de/strings.po @@ -277,21 +277,21 @@ msgid "UpNext fixed or unavailable end detection duration" msgstr "Vorlaufzeit für UpNext wenn \"Statisch\" oder keine Credits/Vorschau" msgctxt "#30090" -msgid "UpNext integration" +msgid "Show UpNext dialog at" msgstr "UpNext Integration" msgctxt "#30091" -msgid "At credits start, if nothing after" +msgid "credits start" msgstr "Zu Beginn der Credits, wenn danach keine Vorschau kommt" msgctxt "#30092" -msgid "At preview start" +msgid "preview start" msgstr "Zu Beginn der Vorschau" msgctxt "#30093" -msgid "Fixed" +msgid "fixed time before the end" msgstr "Statisch" msgctxt "#30094" -msgid "Disabled" -msgstr "Deaktiviert" \ No newline at end of file +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 40cde65..9d7d005 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -279,21 +279,21 @@ msgid "UpNext fixed or unavailable end detection duration" msgstr "" msgctxt "#30090" -msgid "UpNext integration" +msgid "Show UpNext dialog at" msgstr "" msgctxt "#30091" -msgid "At credits start, if nothing after" +msgid "credits start" msgstr "" msgctxt "#30092" -msgid "At preview start" +msgid "preview start" msgstr "" msgctxt "#30093" -msgid "Fixed" +msgid "fixed time before the end" msgstr "" msgctxt "#30094" -msgid "Disabled" -msgstr "" \ No newline at end of file +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 bbeae48..086ca6f 100644 --- a/resources/language/resource.language.es_es/strings.po +++ b/resources/language/resource.language.es_es/strings.po @@ -279,21 +279,21 @@ msgid "UpNext fixed or unavailable end detection duration" msgstr "" msgctxt "#30090" -msgid "UpNext integration" +msgid "Show UpNext dialog at" msgstr "" msgctxt "#30091" -msgid "At credits start, if nothing after" +msgid "credits start" msgstr "" msgctxt "#30092" -msgid "At preview start" +msgid "preview start" msgstr "" msgctxt "#30093" -msgid "Fixed" +msgid "fixed time before the end" msgstr "" msgctxt "#30094" -msgid "Disabled" +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 be8b094..dd82e30 100644 --- a/resources/language/resource.language.fr_fr/strings.po +++ b/resources/language/resource.language.fr_fr/strings.po @@ -275,21 +275,21 @@ msgid "UpNext fixed or unavailable end detection duration" msgstr "Durée fixe ou de détection indisponible pour UpNext" msgctxt "#30090" -msgid "UpNext integration" -msgstr "Intégration à UpNext" +msgid "Show UpNext dialog at" +msgstr "Afficher la fenêtre UpNext quand" msgctxt "#30091" -msgid "At credits start, if nothing after" -msgstr "Dès le générique, s'il n'y a rien après" +msgid "credits start" +msgstr "le générique commence" msgctxt "#30092" -msgid "At preview start" -msgstr "Au début de la bande-annonce du suivant" +msgid "preview start" +msgstr "la preview commence" msgctxt "#30093" -msgid "Fixed" -msgstr "Fixé" +msgid "fixed time before the end" +msgstr "une durée fixe avant la fin" msgctxt "#30094" -msgid "Disabled" -msgstr "Désactivé" +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 47101db..7dde13b 100644 --- a/resources/language/resource.language.pt_br/strings.po +++ b/resources/language/resource.language.pt_br/strings.po @@ -276,21 +276,21 @@ msgid "UpNext fixed or unavailable end detection duration" msgstr "" msgctxt "#30090" -msgid "UpNext integration" +msgid "Show UpNext dialog at" msgstr "" msgctxt "#30091" -msgid "At credits start, if nothing after" +msgid "credits start" msgstr "" msgctxt "#30092" -msgid "At preview start" +msgid "preview start" msgstr "" msgctxt "#30093" -msgid "Fixed" +msgid "fixed time before the end" msgstr "" msgctxt "#30094" -msgid "Disabled" -msgstr "" \ No newline at end of file +msgid "never (disabled)" +msgstr "" diff --git a/resources/lib/videoplayer.py b/resources/lib/videoplayer.py index 8d2905e..ccab104 100644 --- a/resources/lib/videoplayer.py +++ b/resources/lib/videoplayer.py @@ -226,10 +226,10 @@ def _handle_upnext(self): }, "video_episode_play" ) - show_next_at_seconds = self._compute_when_episode_ends() + show_next_at_seconds = self._stream_data.end_timecode if show_next_at_seconds is not None: - # Needs to wait 1s, otherwise, upnext will show next dialog at episode start... - xbmc.sleep(1000) + # 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, @@ -247,37 +247,6 @@ def _handle_upnext(self): except Exception: utils.crunchy_log("_handle_upnext: Cannot send upnext notification", xbmc.LOGERROR) - def _compute_when_episode_ends(self) -> Optional[int]: - upnext_mode = G.args.addon.getSetting("upnext_mode") - if upnext_mode == "disabled": - return None - - video_end = self._stream_data.playable_item.duration - fixed_duration = int(G.args.addon.getSetting("upnext_fixed_duration"), 10) - result = video_end - fixed_duration - - skip_events_data = self._stream_data.unmodified_skip_events_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 - - 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 are outro and preview - # and if the outro ends when the preview start - if upnext_mode == "best" and credits_start and credits_end and preview_start and credits_end + 3 > preview_start: - result = credits_start - # If there is a preview - elif preview_start: - result = preview_start - # If there is outro without preview - # and if the outro ends in the last 20 seconds video - elif upnext_mode == "best" and credits_start and credits_end and video_end <= credits_end + 20: - result = credits_start - - return result - def thread_update_playhead(self): """ background thread to update playback with crunchyroll in intervals """ @@ -339,13 +308,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 diff --git a/resources/lib/videostream.py b/resources/lib/videostream.py index 38ec4b2..b45dec2 100644 --- a/resources/lib/videostream.py +++ b/resources/lib/videostream.py @@ -39,7 +39,6 @@ def __init__(self): self.stream_url: str | None = None self.subtitle_urls: list[str] | None = None self.skip_events_data: Dict = {} - self.unmodified_skip_events_data: Dict = {} self.playheads_data: Dict = {} # PlayableItem which is about to be played, that contains cms object data self.playable_item: PlayableItem | None = None @@ -47,6 +46,8 @@ def __init__(self): 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): @@ -88,12 +89,16 @@ def get_player_stream_data(self) -> Optional[VideoPlayerStreamData]: video_player_stream_data.token = async_data.get('stream_data').get('token') video_player_stream_data.skip_events_data = async_data.get('skip_events_data') - video_player_stream_data.unmodified_skip_events_data = dict(async_data.get('skip_events_data')) 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]: @@ -355,7 +360,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( @@ -392,3 +397,67 @@ async def _get_upnext_episode(id: str) -> Optional[Dict]: 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 49b70ec..430c29c 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -132,7 +132,7 @@ disabled - +