From c83f655e6dafe11baf9c755fef24155a2d22271d Mon Sep 17 00:00:00 2001 From: lumiru Date: Sat, 30 Dec 2023 21:10:41 +0100 Subject: [PATCH] feat: Upnext addon integration --- README.md | 1 + 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/model.py | 5 +++ resources/lib/utils.py | 19 +++++++++ resources/lib/videoplayer.py | 56 +++++++++++++++++++++++--- resources/lib/videostream.py | 30 ++++++++------ 9 files changed, 213 insertions(+), 17 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 b2fc33c..f991cd6 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,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/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..999afd1 --- /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.name + 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 769f9ca..66e4e59 100644 --- a/resources/lib/api.py +++ b/resources/lib/api.py @@ -67,6 +67,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/model.py b/resources/lib/model.py index f4b5c63..1eae9b6 100644 --- a/resources/lib/model.py +++ b/resources/lib/model.py @@ -189,6 +189,7 @@ def __init__(self): self.series_id: str | None = None # @todo: this is not present in all subclasses, move that self.season_id: str | None = None # @todo: this is not present in all subclasses, move that self.title: str | None = None + self.name: str | None = None self.thumb: str | None = None self.fanart: str | None = None self.poster: str | None = None @@ -281,6 +282,7 @@ def __init__(self, data: dict): self.id = panel.get("id") self.title: str = panel.get("title") + self.name: str = panel.get("title") self.tvshowtitle: str = panel.get("title") self.series_id: str | None = panel.get("id") self.season_id: str | None = None @@ -338,6 +340,7 @@ def __init__(self, data: dict): self.id = data.get("id") self.title: str = data.get("title") + self.name: str = data.get("title") self.tvshowtitle: str = data.get("title") self.series_id: str | None = data.get("series_id") self.season_id: str | None = data.get("id") @@ -401,6 +404,7 @@ def __init__(self, data: dict): self.id = panel.get("id") self.title: str = utils.format_long_episode_title(meta.get("season_title"), meta.get("episode_number"), panel.get("title")) + self.name: str = panel.get("title", "") self.tvshowtitle: str = meta.get("series_title", "") self.duration: int = int(meta.get("duration_ms", 0) / 1000) self.playhead: int = data.get("playhead", 0) @@ -469,6 +473,7 @@ def __init__(self, data: dict): self.id = panel.get("id") self.title: str = meta.get("movie_listing_title", "") + self.name: str = data.get("movie_listing_title", "") self.tvshowtitle: str = meta.get("movie_listing_title", "") self.duration: int = int(meta.get("duration_ms", 0) / 1000) self.playhead: int = data.get("playhead", 0) diff --git a/resources/lib/utils.py b/resources/lib/utils.py index c394157..062c4ba 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -196,6 +196,25 @@ def get_img_from_struct(item: Dict, image_type: str, depth: int = 2) -> Union[st return None +async def get_upnext_episode(args, id: str, api) -> dict: + try: + req = api.make_request( + method="GET", + url=api.UPNEXT_ENDPOINT.format(id), + params={ + "locale": args.subtitle, + # "preferred_audio_language": "" + } + ) + except (CrunchyrollError, requests.exceptions.RequestException) as e: + crunchy_log(args, "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] + + def dump(data) -> None: xbmc.log(dumps(data, indent=4), xbmc.LOGINFO) diff --git a/resources/lib/videoplayer.py b/resources/lib/videoplayer.py index 7e53d68..7369707 100644 --- a/resources/lib/videoplayer.py +++ b/resources/lib/videoplayer.py @@ -24,11 +24,12 @@ import xbmcgui import xbmcplugin -from resources.lib import utils -from resources.lib.api import API -from resources.lib.gui import SkipModalDialog, _show_modal_dialog -from resources.lib.model import Object, Args, CrunchyrollError -from resources.lib.videostream import VideoPlayerStreamData, VideoStream +from . import utils, view +from .addons import upnext +from .api import API +from .gui import SkipModalDialog, _show_modal_dialog +from .model import Object, Args, CrunchyrollError, EpisodeData, SeriesData +from .videostream import VideoPlayerStreamData, VideoStream class VideoPlayer(Object): @@ -59,6 +60,7 @@ def start_playback(self): self._handle_update_playhead() self._handle_skipping() + self._handle_upnext() def is_playing(self) -> bool: """ Returns true if playback is running. Note that it also returns true when paused. """ @@ -202,6 +204,50 @@ def _handle_skipping(self): utils.crunchy_log(self._args, "_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: + return + next_url = view.build_url( + self._args, + { + "series_id": self._args.get_arg("series_id"), + "episode_id": next_episode.episode_id, + "stream_id": next_episode.stream_id + }, + "video_episode_play" + ) + utils.log("Next URL: %s" % next_url) + show_next_at_seconds = self._compute_when_episode_ends() + upnext.send_next_info(self._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(self._args, "Cannot send upnext notification", xbmc.LOGERROR) + + def _compute_when_episode_ends(self) -> int: + if not self._stream_data.skip_events_data: + return None + result = None + skip_events_data = self._stream_data.skip_events_data + if skip_events_data.get("credits") or skip_events_data.get("preview"): + video_end = self._stream_data.playable_item.duration + 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 credits_start and credits_end and preview_start and credits_end == 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 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 """ diff --git a/resources/lib/videostream.py b/resources/lib/videostream.py index e4b70c6..aa26273 100644 --- a/resources/lib/videostream.py +++ b/resources/lib/videostream.py @@ -29,7 +29,7 @@ from resources.lib.api import API from resources.lib.model import Object, Args, CrunchyrollError, PlayableItem from resources.lib.utils import log_error_with_trace, crunchy_log, \ - get_playheads_from_api, get_cms_object_data_by_ids, get_listables_from_response + get_playheads_from_api, get_cms_object_data_by_ids, get_listables_from_response, get_upnext_episode class VideoPlayerStreamData(Object): @@ -45,6 +45,7 @@ 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 class VideoStream(Object): @@ -91,35 +92,40 @@ 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') 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 = self.args.get_arg('episode_id') + series_id = self.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_v2()) - t_skip_events_data = asyncio.create_task(self._get_skip_events(self.args.get_arg('episode_id'))) - t_playheads = asyncio.create_task(get_playheads_from_api(self.args, self.api, self.args.get_arg('episode_id'))) - t_item_data = asyncio.create_task( - get_cms_object_data_by_ids(self.args, self.api, [self.args.get_arg('episode_id')])) - # t_item_parent_data = asyncio.create_task(get_cms_object_data_by_ids(self.args, self.api, self.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(self.args, self.api, episode_id)) + t_item_data = asyncio.create_task(get_cms_object_data_by_ids(self.args, self.api, [episode_id, series_id])) + t_upnext_data = asyncio.create_task(get_upnext_episode(self.args, episode_id, self.api)) # 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(self.args, [results[3].get(self.args.get_arg('episode_id'))]) if \ - results[3] else None + listable_items = get_listables_from_response(self.args, [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(self.args, [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(self.args, [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, } async def _get_stream_data_from_api(self) -> Union[Dict, bool]: