Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Upnext addon integration #45

Open
wants to merge 7 commits into
base: nexus-staging
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

***

Expand Down
42 changes: 35 additions & 7 deletions resources/language/resource.language.fr_fr/strings.po
lumiru marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ msgstr "Se connecter"

msgctxt "#30210"
msgid "Languages"
msgstr "Lanagages"
msgstr "Langages"

msgctxt "#30220"
msgid "Playback"
Expand All @@ -31,6 +31,10 @@ msgctxt "#30230"
msgid "Other"
msgstr "Autre"

msgctxt "#30240"
msgid "Addons"
msgstr "Extensions"

msgctxt "#30001"
msgid "Email"
msgstr "Email"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -216,19 +220,19 @@ 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"
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."
Expand All @@ -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"
Expand All @@ -264,4 +268,28 @@ 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 "#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é"
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.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)
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 @@ -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"

Expand Down
70 changes: 64 additions & 6 deletions resources/lib/videoplayer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
lumiru marked this conversation as resolved.
Show resolved Hide resolved

def is_playing(self) -> bool:
""" Returns true if playback is running. Note that it also returns true when paused. """

Expand Down Expand Up @@ -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:
lumiru marked this conversation as resolved.
Show resolved Hide resolved
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")
lumiru marked this conversation as resolved.
Show resolved Hide resolved
# 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 """

Expand Down Expand Up @@ -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:
Expand Down
Loading