Skip to content

Commit

Permalink
feat: Upnext addon integration
Browse files Browse the repository at this point in the history
  • Loading branch information
lumiru authored and lumiru committed Feb 11, 2024
1 parent 19def90 commit c6c23f9
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 14 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

***

Expand Down
1 change: 1 addition & 0 deletions resources/lib/addons/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Dummy file to make this directory a package.
68 changes: 68 additions & 0 deletions resources/lib/addons/upnext.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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)
49 changes: 49 additions & 0 deletions resources/lib/addons/utils.py
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

"""
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
1 change: 1 addition & 0 deletions resources/lib/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,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"

Expand Down
5 changes: 5 additions & 0 deletions resources/lib/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions resources/lib/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
56 changes: 51 additions & 5 deletions resources/lib/videoplayer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -59,6 +60,7 @@ def start_playback(self):

self._handle_resume()
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. """
Expand Down Expand Up @@ -173,6 +175,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 """

Expand Down
27 changes: 18 additions & 9 deletions resources/lib/videostream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -44,6 +44,7 @@ def __init__(self):
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.next_playable_item: PlayableItem | None = None


class VideoStream(Object):
Expand Down Expand Up @@ -88,32 +89,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())
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]:
Expand Down

0 comments on commit c6c23f9

Please sign in to comment.