Skip to content

Commit

Permalink
[plugin.video.cbc] 4.0.13+matrix.1 (#3750)
Browse files Browse the repository at this point in the history
* Fix authentication with GEM API
* Fix moive playback
* Fix sort order on shelf and category menus
  • Loading branch information
micahg authored Oct 10, 2021
1 parent a5dcf52 commit 6a29ad0
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 124 deletions.
6 changes: 4 additions & 2 deletions plugin.video.cbc/addon.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.cbc"
name="Canadian Broadcasting Corp (CBC)"
version="4.0.11+matrix.1"
version="4.0.13+matrix.1"
provider-name="micahg,t1m,smf007,oshanrube">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
Expand All @@ -28,6 +28,8 @@
<forum>https://forum.kodi.tv/showthread.php?tid=328421</forum>
<website>https://watch.cbc.ca/</website>
<source>https://github.com/micahg/plugin.video.cbc</source>
<news>- CBC Gem V2 API Support</news>
<news>- Fix authentication
- Fix movies
- Fix sort order on shelf and category menus</news>
</extension>
</addon>
29 changes: 23 additions & 6 deletions plugin.video.cbc/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import xbmcplugin
import xbmcgui
import xbmcaddon
from xbmcvfs import translatePath
import inputstreamhelper
import routing

Expand Down Expand Up @@ -205,12 +206,26 @@ def gem_show_menu(show_id):
show_layout = GemV2.get_show_layout_by_id(show_id)
show = {k: v for (k, v) in show_layout.items() if k not in ['sponsors', 'seasons']}
for season in show_layout['seasons']:
item = xbmcgui.ListItem(season['title'])
item.setInfo(type="Video", infoLabels=CBC.get_labels(season))
film = season['title'] == 'Film'
title = season['assets'][0]['title'] if film else season['title']
labels = GemV2.get_labels(season, season)

# films seem to have been shoe-horned (with teeth) into the structure oddly -- compensate
if film:
labels['title'] = title

item = xbmcgui.ListItem(title)
item.setInfo(type="Video", infoLabels=labels)
image = season['image'].replace('(Size)', '224')
item.setArt({'thumb': image, 'poster': image})
show['season'] = season
xbmcplugin.addDirectoryItem(plugin.handle, plugin.url_for(gem_show_season, query=json.dumps(show)), item, True)
if film:
item.setProperty('IsPlayable', 'true')
episode_info = {'url': season['assets'][0]['playSession']['url'], 'labels': labels}
url = plugin.url_for(gem_episode, query=json.dumps(episode_info))
else:
show['season'] = season
url = plugin.url_for(gem_show_season, query=json.dumps(show))
xbmcplugin.addDirectoryItem(plugin.handle, url, item, not film)
xbmcplugin.endOfDirectory(plugin.handle)


Expand All @@ -223,12 +238,12 @@ def gem_shelf_menu():
shelf_items = json.loads(json_str)
for shelf_item in shelf_items:
item = xbmcgui.ListItem(shelf_item['title'])
item.setInfo(type="Video", infoLabels=CBC.get_labels(shelf_item))
image = shelf_item['image'].replace('(Size)', '224')
item.setArt({'thumb': image, 'poster': image})
url = plugin.url_for(gem_show_menu, shelf_item['id'])
xbmcplugin.addDirectoryItem(handle, url, item, True)
xbmcplugin.addSortMethod(plugin.handle, xbmcplugin.SORT_METHOD_TITLE_IGNORE_THE)
xbmcplugin.addSortMethod(plugin.handle, xbmcplugin.SORT_METHOD_TITLE)
xbmcplugin.endOfDirectory(handle)


Expand All @@ -240,10 +255,12 @@ def gem_category_menu(category_id):
category = GemV2.get_category(category_id)
for show in category['items']:
item = xbmcgui.ListItem(show['title'])
item.setInfo(type="Video", infoLabels=CBC.get_labels(show))
image = show['image'].replace('(Size)', '224')
item.setArt({'thumb': image, 'poster': image})
url = plugin.url_for(gem_show_menu, show['id'])
xbmcplugin.addDirectoryItem(handle, url, item, True)
xbmcplugin.addSortMethod(handle, xbmcplugin.SORT_METHOD_TITLE_IGNORE_THE)
xbmcplugin.endOfDirectory(handle)


Expand All @@ -270,7 +287,7 @@ def layout_menu(layout):
@plugin.route('/')
def main_menu():
"""Populate the menu with the main menu items."""
data_path = xbmc.translatePath(xbmcaddon.Addon().getAddonInfo('profile'))
data_path = translatePath('special://userdata/addon_data/plugin.video.cbc')
if not os.path.exists(data_path):
os.makedirs(data_path)
if not os.path.exists(getAuthorizationFile()):
Expand Down
191 changes: 80 additions & 111 deletions plugin.video.cbc/resources/lib/cbc.py
Original file line number Diff line number Diff line change
@@ -1,161 +1,130 @@
import requests, uuid, urllib.request, urllib.parse, urllib.error, json
"""Module for general CBC stuff"""
import urllib.request
import urllib.parse
import urllib.error
import json
from xml.dom.minidom import *
import xml.etree.ElementTree as ET

import requests

from .utils import save_cookies, loadCookies, saveAuthorization, log

CALLSIGN = 'cbc$callSign'
API_KEY = '3f4beddd-2061-49b0-ae80-6f1f2ed65b37'
RADIUS_LOGIN_FMT = 'https://api.loginradius.com/identity/v2/auth/login?{}'
RADIUS_JWT_FMT = 'https://cloud-api.loginradius.com/sso/jwt/api/token?{}'
TOKEN_URL = 'https://services.radio-canada.ca/ott/cbc-api/v2/token'
PROFILE_URL = 'https://services.radio-canada.ca/ott/cbc-api/v2/profile'


class CBC:
"""Class for CBC stuff."""

def __init__(self):
self.ENV_JS = 'https://watch.cbc.ca/public/js/env.js'
self.API_KEY = '3f4beddd-2061-49b0-ae80-6f1f2ed65b37'
self.RADIUS_LOGIN_FMT = 'https://api.loginradius.com/identity/v2/auth/login?{}'
self.RADIUS_JWT_FMT = 'https://cloud-api.loginradius.com/sso/jwt/api/token?{}'
self.IDENTITIES_URL='https://api-cbc.cloud.clearleap.com/cloffice/client/identities'
self.DEVICE_XML_FMT = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<device>
<type>web</type>
</device>"""
self.LOGIN_XML_FMT = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<login>
<token>{}</token>
<device>
<deviceId>{}</deviceId>
<type>web</type>
</device>
</login>"""
"""Initialize the CBC class."""
# Create requests session object
self.session = requests.Session()
session_cookies = loadCookies()
if not session_cookies == None:
if session_cookies is not None:
self.session.cookies = session_cookies

def authorize(self, username = None, password = None, callback = None):
full_auth = not username == None and not password == None
r = self.session.get(self.IDENTITIES_URL)
if not callback == None:
callback(20 if full_auth else 50)
if not r.status_code == 200:
log('ERROR: {} returns status of {}'.format(self.IDENTITIES_URL, r.status_code), True)
return None
def authorize(self, username=None, password=None, callback=None):
"""Authorize for video playback."""
token = self.radius_login(username, password)
if callback is not None:
callback(25)
if token is None:
log('Radius Login failed', True)
return False

dom = parseString(r.content)
reg_url = dom.getElementsByTagName('registerDeviceUrl')[0].firstChild.nodeValue
login_url = dom.getElementsByTagName('loginUrl')[0].firstChild.nodeValue

auth = self.registerDevice(reg_url)
if not callback == None:
callback(40 if full_auth else 100)
if auth == None:
log('Device registration failed', True)
jwt = self.radius_jwt(token)
if callback is not None:
callback(50)
if jwt is None:
log('Radius JWT retrieval failed', True)
return False

# token = self.login(login_url, auth['devid'], jwt)
auth = {}
token = self.get_access_token(jwt)
if callback is not None:
callback(75)
if token is None:
log('Access token retrieval failed', True)
return False
auth['token'] = token

if full_auth:
token = self.radiusLogin(username, password)
if not callback == None:
callback(60)
if token == None:
log('Radius Login failed', True)
return False

jwt = self.radiusJWT(token)
if not callback == None:
callback(80)
if jwt == None:
log('Radius JWT retrieval failed', True)
return False

token = self.login(login_url, auth['devid'], jwt)
if not callback == None:
callback(100)
if token == None:
log('Final login failed', True)
return False
auth['token'] = token
claims = self.get_claims_token(token)
if callback is not None:
callback(100)
if token is None:
log('Claims token retrieval failed', True)
return False
auth['claims'] = claims

saveAuthorization(auth)
save_cookies(self.session.cookies)

return True


def registerDevice(self, url):
r = self.session.post(url, data=self.DEVICE_XML_FMT)
if not r.status_code == 200:
log('ERROR: {} returns status of {}'.format(url, r.status_code), True)
return None
save_cookies(self.session.cookies)
# Parse the authorization response
dom = parseString(r.content)
status = dom.getElementsByTagName('status')[0].firstChild.nodeValue
if status != "Success":
log('Error: Unable to authorize device', True)
return None
auth = {
'devid': dom.getElementsByTagName('deviceId')[0].firstChild.nodeValue,
'token': dom.getElementsByTagName('deviceToken')[0].firstChild.nodeValue
}

return auth


def radiusLogin(self, username, password):
query = urllib.parse.urlencode({'apikey': self.API_KEY})
def radius_login(self, username, password):
"""Login with Radius using user credentials."""
query = urllib.parse.urlencode({'apikey': API_KEY})

data = {
'email': username,
'password': password
}
url = self.RADIUS_LOGIN_FMT.format(query)
r = self.session.post(url, json = data)
if not r.status_code == 200:
log('{} returns status {}'.format(r.url, r.status_code))
url = RADIUS_LOGIN_FMT.format(query)
req = self.session.post(url, json=data)
if not req.status_code == 200:
log('{} returns status {}'.format(req.url, req.status_code), True)
return None

token = json.loads(r.content)['access_token']
token = json.loads(req.content)['access_token']

return token


def radiusJWT(self, token):
def radius_jwt(self, token):
"""Exchange a radius token for a JWT."""
query = urllib.parse.urlencode({
'access_token': token,
'apikey': self.API_KEY,
'apikey': API_KEY,
'jwtapp': 'jwt'
})
url = self.RADIUS_JWT_FMT.format(query)
r = self.session.get(url)
if not r.status_code == 200:
log('{} returns status {}'.format(r.url, r.status_code))
url = RADIUS_JWT_FMT.format(query)
req = self.session.get(url)
if not req.status_code == 200:
log('{} returns status {}'.format(req.url, req.status_code))
return None
return json.loads(r.content)['signature']


def login(self, url, devid, jwt):
data = self.LOGIN_XML_FMT.format(jwt, devid)
headers = {'Content-type': 'content_type_value'}
r = self.session.post(url, data = data, headers = headers)
if not r.status_code == 200:
log('{} returns status {}'.format(r.url, r.status_code))
return json.loads(req.content)['signature']

def get_access_token(self, jwt):
"""Exchange a JWT for another JWT."""
data = {'jwt': jwt}
req = self.session.post(TOKEN_URL, json=data)
if not req.status_code == 200:
log('{} returns status {}'.format(req.url, req.status_code), True)
return None

dom = parseString(r.content)
res = dom.getElementsByTagName('result')[0]
token = res.getElementsByTagName('token')[0].firstChild.nodeValue

return token

return json.loads(req.content)['accessToken']

def get_claims_token(self, access_token):
"""Get the claims token for tied to the access token."""
headers = {'ott-access-token': access_token}
req = self.session.get(PROFILE_URL, headers=headers)
if not req.status_code == 200:
log('{} returns status {}'.format(req.url, req.status_code), True)
return None
return json.loads(req.content)['claimsToken']

def getImage(self, item):
# ignore 'cbc$liveImage' - the pix don't make sense after the first load
if 'defaultThumbnailUrl' in item:
return item['defaultThumbnailUrl']
elif 'cbc$staticImage' in item:
if 'cbc$staticImage' in item:
return item['cbc$staticImage']
elif 'cbc$featureImage' in item:
if 'cbc$featureImage' in item:
return item['cbc$featureImage']
return None

Expand Down
19 changes: 14 additions & 5 deletions plugin.video.cbc/resources/lib/gemv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import requests

from resources.lib.cbc import CBC
from resources.lib.utils import loadAuthorization

LAYOUT_MAP = {
'featured': 'https://services.radio-canada.ca/ott/cbc-api/v2/home',
Expand Down Expand Up @@ -35,8 +36,15 @@ def get_show_layout_by_id(show_id):
@staticmethod
def get_episode(url):
"""Get a Gem V2 episode by URL."""
# resp = CBC.get_session().get(url)
resp = requests.get(url)
auth = loadAuthorization()
headers = {}
if 'token' in auth:
headers['Authorization'] = 'Bearer {}'.format(auth['token'])

if 'claims' in auth:
headers['x-claims-token'] = auth['claims']

resp = requests.get(url, headers=headers)
return json.loads(resp.content)

@staticmethod
Expand All @@ -52,11 +60,12 @@ def get_labels(show, episode):
labels = {
'studio': 'Canadian Broadcasting Corporation',
'country': 'Canada',
'tvshowtitle': episode['title'],
'title': show['title'],
'tvshowtitle': show['title'],
'title': episode['title'],
'plot': episode['description'],
'plotoutline': episode['description'],
'season': episode['season'],
'episode': episode['episode']
}
if 'episode' in episode:
labels['episode'] = episode['episode']
return labels

0 comments on commit 6a29ad0

Please sign in to comment.