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]: