diff --git a/plugin.video.cbc/addon.xml b/plugin.video.cbc/addon.xml index 881da7adec..ec462b08fe 100644 --- a/plugin.video.cbc/addon.xml +++ b/plugin.video.cbc/addon.xml @@ -1,7 +1,7 @@ @@ -28,6 +28,6 @@ https://forum.kodi.tv/showthread.php?tid=328421 https://watch.cbc.ca/ https://github.com/micahg/plugin.video.cbc - - Fix live channels and IPTV + - Updates for new CBC Gem API diff --git a/plugin.video.cbc/default.py b/plugin.video.cbc/default.py index 030580c920..b33af51ea7 100644 --- a/plugin.video.cbc/default.py +++ b/plugin.video.cbc/default.py @@ -19,12 +19,6 @@ getString = xbmcaddon.Addon().getLocalizedString LIVE_CHANNELS = getString(30004) -GEMS = { - 'featured': getString(30005), - 'shows': getString(30006), - 'documentaries': getString(30024), - 'kids': getString(30025) -} SEARCH = getString(30026) @@ -56,38 +50,64 @@ def authorize(): return True -def play(labels, image, url): +def play(labels, image, data): """Play the stream using the configured player.""" - item = xbmcgui.ListItem(labels['title'], path=url) - if image: - item.setArt({'thumb': image, 'poster': image}) - item.setInfo(type="Video", infoLabels=labels) - helper = inputstreamhelper.Helper('hls') - if not xbmcaddon.Addon().getSettingBool("ffmpeg") and helper.check_inputstream(): - item.setProperty('inputstream', 'inputstream.adaptive') - item.setProperty('inputstream.adaptive.manifest_type', 'hls') - xbmcplugin.setResolvedUrl(plugin.handle, True, item) - if url is None: + if not 'url' in data: xbmcgui.Dialog().ok(getString(30010), getString(30011)) - + return + + (lic, tok) = GemV2.get_stream_drm(data) + is_helper = None + mime = None + drm = None + if data['type'] == 'hls': + is_helper = inputstreamhelper.Helper('hls') + elif data['type'] == 'dash': + drm = 'com.widevine.alpha' + is_helper = inputstreamhelper.Helper('mpd', drm=drm) + mime = 'application/dash+xml' + + if is_helper is None: + xbmcgui.Dialog().ok(getString(30027), getString(30027)) + return + + if is_helper.check_inputstream(): + url = data['url'] + play_item = xbmcgui.ListItem(path=url) + play_item.setInfo(type="Video", infoLabels=labels) + if mime: + play_item.setMimeType(mime) + play_item.setContentLookup(False) + + if int(xbmc.getInfoLabel('System.BuildVersion').split('.')[0]) >= 19: + play_item.setProperty('inputstream', is_helper.inputstream_addon) + else: + play_item.setProperty('inputstreamaddon', is_helper.inputstream_addon) + + if drm: + play_item.setProperty('inputstream.adaptive.license_type', drm) + license_headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0', + 'Content-Type': 'application/octet-stream', + 'Origin': 'https://gem.cbc.ca', + 'x-dt-auth-token': tok, # string containing "Bearer eyJ...." + } + license_config = [ lic, urlencode(license_headers), 'R{SSM}', 'R'] + license_key = '|'.join(license_config) + play_item.setProperty('inputstream.adaptive.license_key', license_key) + xbmcplugin.setResolvedUrl(plugin.handle, True, play_item) def add_items(handle, items): for item in items: list_item = xbmcgui.ListItem(item['title']) list_item.setInfo(type="Video", infoLabels=CBC.get_labels(item)) - image = item['image'].replace('(Size)', '224') + image = item['image']['url'].replace('(Size)', '224') list_item.setArt({'thumb': image, 'poster': image}) item_type = item['type'] is_folder = True - if item_type == 'SHOW': - url = plugin.url_for(gem_show_menu, item['id']) - elif item_type == 'ASSET': - url = plugin.url_for(gem_asset, item['id']) - list_item.setProperty('IsPlayable', 'true') - is_folder = False - elif item_type == 'SEASON': - # ignore the season and go to the show (its what the web UI does) - url = plugin.url_for(gem_show_menu, item['id'].split('/')[0]) + if item_type.lower() == 'show' or item_type.lower() == 'media': + p = GemV2.normalized_format_path(item) + url = plugin.url_for(layout_menu, p) else: log(f'Unable to handle shelf item type "{item_type}".', True) url = None @@ -143,9 +163,12 @@ def live_channels_add_only(station): @plugin.route('/channels/play') def play_live_channel(): labels = dict(parse_qsl(plugin.args['labels'][0])) if 'labels' in plugin.args else None - chans = LiveChannels() - url = chans.get_channel_stream(plugin.args['id'][0]) - return play(labels, plugin.args['image'][0], url) + data = GemV2.get_stream(plugin.args['id'][0], plugin.args['app_code'][0]) + if not 'url' in data: + log('Failed to get stream URL, attempting to authorize.') + if authorize(): + data = GemV2.get_stream(plugin.args['id'][0], plugin.args['app_code'][0]) + return play(labels, plugin.args['image'][0] if 'image' in plugin.args else None, data) @plugin.route('/channels') def live_channels_menu(): @@ -169,131 +192,11 @@ def live_channels_menu(): (getString(30017), 'RunPlugin({})'.format(plugin.url_for(live_channels_add_only, callsign))), ]) xbmcplugin.addDirectoryItem(plugin.handle, - plugin.url_for(play_live_channel, id=channel['idMedia'], + plugin.url_for(play_live_channel, id=channel['idMedia'], app_code='medianetlive', labels=urlencode(labels), image=image), item, False) xbmcplugin.endOfDirectory(plugin.handle) -@plugin.route('/gem/show/episode') -def gem_episode(): - """Play an episode.""" - json_str = plugin.args['query'][0] - episode = json.loads(json_str) - - # get the url, and failing that, attempt authorization, then retry - resp = GemV2().get_episode(episode['url']) - url = None if not resp else resp['url'] if 'url' in resp else None - if not url: - log('Failed to get stream URL, attempting to authorize.') - if authorize(): - resp = GemV2().get_episode(episode['url']) - url = resp['url'] if 'url' in resp else None - - labels = episode['labels'] - play(labels, None, url) - - -@plugin.route('/gem/show/season') -def gem_show_season(): - """Create a menu for a show season.""" - xbmcplugin.setContent(plugin.handle, 'videos') - json_str = plugin.args['query'][0] - # remember show['season'] is season details but there is general show info in show as well - show = json.loads(json_str) - for episode in show['season']['assets']: - item = xbmcgui.ListItem(episode['title']) - image = episode['image'].replace('(Size)', '224') - item.setArt({'thumb': image, 'poster': image}) - item.setProperty('IsPlayable', 'true') - labels = GemV2.get_labels(show, episode) - item.setInfo(type="Video", infoLabels=labels) - episode_info = {'url': episode['playSession']['url'], 'labels': labels} - url = plugin.url_for(gem_episode, query=json.dumps(episode_info)) - xbmcplugin.addDirectoryItem(plugin.handle, url, item, False) - xbmcplugin.endOfDirectory(plugin.handle) - - -@plugin.route('/gem/asset/') -def gem_asset(asset): - asset_layout = GemV2.get_asset_by_id(asset) - resp = GemV2.get_episode(asset_layout['playSession']['url']) - url = None if not resp else resp['url'] if 'url' in resp else None - if not url: - log('Failed to get stream URL, attempting to authorize.') - if authorize(): - resp = GemV2().get_episode(asset_layout['playSession']['url']) - url = resp['url'] if 'url' in resp else None - labels = GemV2.get_labels({'title': asset_layout['series']}, asset_layout) - image = asset_layout['image'] - play(labels, image, url) - - -def gem_add_film_assets(assets): - for asset in assets: - labels = GemV2.get_labels({'title': asset['series']}, asset) - image = asset['image'] - item = xbmcgui.ListItem(labels['title']) - item.setInfo(type="Video", infoLabels=labels) - item.setArt({'thumb': image, 'poster': image}) - item.setProperty('IsPlayable', 'true') - episode_info = {'url': asset['playSession']['url'], 'labels': labels} - url = plugin.url_for(gem_episode, query=json.dumps(episode_info)) - xbmcplugin.addDirectoryItem(plugin.handle, url, item, False) - - -@plugin.route('/gem/show/') -def gem_show_menu(show_id): - """Create a menu for a shelfs items.""" - xbmcplugin.setContent(plugin.handle, 'videos') - 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']: - - # films seem to have been shoe-horned (with teeth) into the structure oddly -- compensate - if season['title'] == 'Film': - gem_add_film_assets(season['assets']) - else: - labels = GemV2.get_labels(season, season) - item = xbmcgui.ListItem(season['title']) - item.setInfo(type="Video", infoLabels=labels) - image = season['image'].replace('(Size)', '224') - item.setArt({'thumb': image, 'poster': image}) - show['season'] = season - url = plugin.url_for(gem_show_season, query=json.dumps(show)) - xbmcplugin.addDirectoryItem(plugin.handle, url, item, True) - - xbmcplugin.endOfDirectory(plugin.handle) - - -@plugin.route('/gem/shelf') -def gem_shelf_menu(): - """Create a menu item for each shelf.""" - handle = plugin.handle - xbmcplugin.setContent(handle, 'videos') - json_str = plugin.args['query'][0] - shelf_items = json.loads(json_str) - add_items(handle, shelf_items) - xbmcplugin.addSortMethod(plugin.handle, xbmcplugin.SORT_METHOD_TITLE_IGNORE_THE) - xbmcplugin.endOfDirectory(handle) - - -@plugin.route('/gem/categories/') -def gem_category_menu(category_id): - """Populate a menu with categorical content.""" - handle = plugin.handle - xbmcplugin.setContent(handle, 'videos') - 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) - - @plugin.route('/gem/search') def search(): handle = plugin.handle @@ -303,23 +206,24 @@ def search(): xbmcplugin.endOfDirectory(handle) -@plugin.route('/gem/layout/') +@plugin.route('/gem/layout/') def layout_menu(layout): """Populate the menu with featured items.""" handle = plugin.handle xbmcplugin.setContent(handle, 'videos') - layout = GemV2.get_layout(layout) - if 'categories' in layout: - for category in layout['categories']: - item = xbmcgui.ListItem(category['title']) - url = plugin.url_for(gem_category_menu, category['id']) - xbmcplugin.addDirectoryItem(handle, url, item, True) - if 'shelves' in layout: - for shelf in layout['shelves']: - item = xbmcgui.ListItem(shelf['title']) - shelf_items = json.dumps(shelf['items']) - url = plugin.url_for(gem_shelf_menu, query=shelf_items) - xbmcplugin.addDirectoryItem(handle, url, item, True) + for f in GemV2.get_format(layout): + n = GemV2.normalized_format_item(f) + p = GemV2.normalized_format_path(f) + item = xbmcgui.ListItem(n['label']) + if 'art' in n: + item.setArt(n['art']) + item.setInfo(type="Video", infoLabels=n['info_labels']) + if n['playable']: + item.setProperty('IsPlayable', 'true') + url = plugin.url_for(play_live_channel, id=p, app_code=n['app_code']) + else: + url = plugin.url_for(layout_menu, p) + xbmcplugin.addDirectoryItem(handle, url, item, not n['playable']) xbmcplugin.endOfDirectory(handle) @@ -334,8 +238,8 @@ def main_menu(): handle = plugin.handle xbmcplugin.setContent(handle, 'videos') - for key, value in GEMS.items(): - xbmcplugin.addDirectoryItem(handle, plugin.url_for(layout_menu, key), xbmcgui.ListItem(value), True) + for c in GemV2.get_browse(): + xbmcplugin.addDirectoryItem(handle, plugin.url_for(layout_menu, c['url']), xbmcgui.ListItem(c['title']), True) xbmcplugin.addDirectoryItem(handle, plugin.url_for(live_channels_menu), xbmcgui.ListItem(LIVE_CHANNELS), True) xbmcplugin.addDirectoryItem(handle, plugin.url_for(search), xbmcgui.ListItem(SEARCH), True) xbmcplugin.endOfDirectory(handle) diff --git a/plugin.video.cbc/resources/language/resource.language.en_gb/strings.po b/plugin.video.cbc/resources/language/resource.language.en_gb/strings.po index 91ab86b40b..ecc6711043 100644 --- a/plugin.video.cbc/resources/language/resource.language.en_gb/strings.po +++ b/plugin.video.cbc/resources/language/resource.language.en_gb/strings.po @@ -119,3 +119,7 @@ msgstr "" msgctxt "#30026" msgid "Search" msgstr "" + +msgctxt "#30027" +msgid "Unsupported DRM" +msgstr "" diff --git a/plugin.video.cbc/resources/lib/cbc.py b/plugin.video.cbc/resources/lib/cbc.py index a142001459..64b5529be9 100644 --- a/plugin.video.cbc/resources/lib/cbc.py +++ b/plugin.video.cbc/resources/lib/cbc.py @@ -325,21 +325,6 @@ def get_labels(item): return labels - def parse_smil(self, smil): - """Parse a SMIL file for the video.""" - resp = self.session.get(smil) - - if not resp.status_code == 200: - log(f'ERROR: {smil} returns status of {resp.status_code}', True) - return None - save_cookies(self.session.cookies) - - dom = parseString(resp.content) - seq = dom.getElementsByTagName('seq')[0] - video = seq.getElementsByTagName('video')[0] - src = video.attributes['src'].value - return src - @staticmethod def get_session(): """Get a requests session object with CBC cookies.""" diff --git a/plugin.video.cbc/resources/lib/gemv2.py b/plugin.video.cbc/resources/lib/gemv2.py index 57b4faeb91..35d563c454 100644 --- a/plugin.video.cbc/resources/lib/gemv2.py +++ b/plugin.video.cbc/resources/lib/gemv2.py @@ -4,41 +4,33 @@ 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', - 'shows': 'https://services.radio-canada.ca/ott/cbc-api/v2/hubs/shows', - 'documentaries': 'https://services.radio-canada.ca/ott/cbc-api/v2/hubs/documentaries', - 'kids': 'https://services.radio-canada.ca/ott/cbc-api/v2/hubs/kids' -} -SHOW_BY_ID = 'https://services.radio-canada.ca/ott/cbc-api/v2/shows/{}' -CATEGORY_BY_ID = 'https://services.radio-canada.ca/ott/cbc-api/v2/categories/{}' -ASSET_BY_ID = 'https://services.radio-canada.ca/ott/cbc-api/v2/assets/{}' -SEARCH_BY_NAME = 'https://services.radio-canada.ca/ott/cbc-api/v2/search' +from resources.lib.utils import loadAuthorization, log + + +# api CONFIG IS AT https://services.radio-canada.ca/ott/catalog/v1/gem/settings?device=web +BROWSE_URI = 'https://services.radio-canada.ca/ott/catalog/v2/gem/browse?device=web' +FORMAT_BY_ID = 'https://services.radio-canada.ca/ott/catalog/v2/gem/{}?device=web' +SEARCH_BY_NAME = 'https://services.radio-canada.ca/ott/catalog/v1/gem/search' + class GemV2: """V2 Gem API class.""" @staticmethod - def get_layout(name): - """Get a Gem V2 layout by name.""" - url = LAYOUT_MAP[name] - resp = CBC.get_session().get(url) - return json.loads(resp.content) + def scrape_json(uri, headers=None, params=None): + resp = CBC.get_session().get(uri, headers=headers, params=params) - @staticmethod - def get_show_layout_by_id(show_id): - """Get a Gem V2 show layout by ID.""" - url = SHOW_BY_ID.format(show_id) - resp = CBC.get_session().get(url) - return json.loads(resp.content) + if resp.status_code != 200: + log(f'HTTP {resp.status_code} from {uri}', True) + return None + + try: + jsObj = json.loads(resp.content) + except: + log(f'Unable to parse JSON from {uri} (status {resp.status_code})', True) + return None + return jsObj - @staticmethod - def get_asset_by_id(asset_id): - url = ASSET_BY_ID.format(asset_id) - resp = CBC.get_session().get(url) - return json.loads(resp.content) @staticmethod def get_episode(url): @@ -56,15 +48,126 @@ def get_episode(url): if 'claims' in auth: headers['x-claims-token'] = auth['claims'] - resp = requests.get(url, headers=headers) - return json.loads(resp.content) + return GemV2.scrape_json(url, headers) + + + @staticmethod + def get_browse(type='formats'): + """Get a Gem V2 API V2 browse format""" + jsObj = GemV2.scrape_json(BROWSE_URI) + if jsObj is not None and type in jsObj: + return jsObj[type] + log(f'Unable to find key "{type}" in response from {BROWSE_URI}') + return None + + @staticmethod + def get_format(path): + """Get a Gem V2 API V2 browse format""" + url = FORMAT_BY_ID.format(path) + jsObj = GemV2.scrape_json(url) + if jsObj is None or 'content' not in jsObj: + log(f'Unable to find key content in response from {url}') + return None + content = jsObj['content'][0] + if 'items' in content and 'results' in content['items']: + return content['items']['results'] + + if 'requestedType' in jsObj and jsObj['requestedType'].lower() == 'season': + # Search for the right season: + # - lineup url will be something like 'rosemary-barton-live/s02' + # - path will be something like 'show/rosemary-barton-live/s02' + for lineup in content['lineups']: + if 'url' in lineup and lineup['url'] in path: + return lineup['items'] + return content['lineups'][0]['items'] + if 'lineups' in content: + return content['lineups'] + + log(f'Unable to find key content/[0]/items/results in response from {url}') + return None + + @staticmethod + def get_stream(id, app_code): + url = f'https://services.radio-canada.ca/media/meta/v1/index.ashx?appCode={app_code}&idMedia={id}&output=jsonObject' + jsObj = GemV2.scrape_json(url) + if jsObj['errorMessage'] is not None: + log(f'Error fetching {url}: {jsObj["errorMessage"]}') + return None + + drm = None + for at in jsObj['availableTechs']: + if at['name'] == 'dash': + drm = at + elif at['name'] == 'hls' and drm == None: + # only use HLS if dash isn't available -- the new HLS cannot be played + drm = at + log(drm) + tech = drm['name'] + manifest_versions = drm['manifestVersions'] + mv = manifest_versions[-1] if manifest_versions is not None else 1 + url = f'https://services.radio-canada.ca/media/validation/v2/?appCode={app_code}&connectionType=hd&deviceType=multiams&idMedia={id}&multibitrate=true&output=json&tech={tech}&manifestType=desktop&manifestVersion={mv}' + retval = GemV2.get_episode(url) + retval['type'] = drm['name'] + return retval + + @staticmethod + def get_stream_drm(stream): + wv_url = None + wv_tok = None + for x in stream['params']: + if x['name'] == 'widevineLicenseUrl': + wv_url = x['value'] + if x['name'] == 'widevineAuthToken': + wv_tok = x['value'] + return (wv_url, wv_tok) + + @staticmethod + def normalized_format_item(item): + """ + Given an object in the list returned by get_format, turn its useful + bits into stuff we can display + """ + images = item['images'] if 'images' in item else None + retval = { + 'label': item['title'], + 'playable': 'idMedia' in item, + 'info_labels': { + 'tvshowtitle': item['title'], + 'title': item['title'], + } + } + if 'description' in item: + retval['info_labels']['plot'] = item['description'] + retval['info_labels']['plotoutline'] = item['description'] + if images: + retval['art'] = { + 'thumb': images['background']['url'] if 'background' in images else None, + 'poster': images['card']['url'], + 'clearlogo': images['logo']['url'] if 'logo' in images else None, + } + if 'metadata' in item: + meta = item['metadata'] + if 'country' in meta: + retval['info_labels']['country'] = meta['country'] + if 'duration' in meta: + retval['info_labels']['duration'] = meta['duration'] + if 'airDate' in meta: + retval['info_labels']['aired'] = meta['airDate'] + if 'credits' in meta: + retval['info_labels']['cast'] = meta['credits'][0]['peoples'].split(',') + if 'idMedia' in item: + retval['app_code'] = 'medianet' if item['mediaType'] == 'LiveToVod' else 'gem' + None + return retval @staticmethod - def get_category(category_id): - """Get a Gem V2 category by ID.""" - url = CATEGORY_BY_ID.format(category_id) - resp = CBC.get_session().get(url) - return json.loads(resp.content) + def normalized_format_path(item): + if 'idMedia' in item: + return item['idMedia'] + if 'type' in item and item['type'].lower() == 'show': + return f'{item["type"]}/{item["url"]}' + return f'show/{item["url"]}' + @staticmethod def get_labels(show, episode): @@ -84,8 +187,15 @@ def get_labels(show, episode): labels['duration'] = episode['duration'] return labels + @staticmethod def search_by_term(term): - params = {'term': term} - resp = CBC.get_session().get(SEARCH_BY_NAME, params=params) - return json.loads(resp.content) \ No newline at end of file + params = { + 'term': term, + 'device': 'web', + } + jsObj = GemV2.scrape_json(SEARCH_BY_NAME, params=params) + if jsObj is None or 'result' not in jsObj: + log(f'Search by term yields no result: {term}') + return None + return jsObj['result'] diff --git a/plugin.video.cbc/resources/lib/livechannels.py b/plugin.video.cbc/resources/lib/livechannels.py index ed2da14f84..68e2403bc6 100644 --- a/plugin.video.cbc/resources/lib/livechannels.py +++ b/plugin.video.cbc/resources/lib/livechannels.py @@ -7,6 +7,7 @@ from resources.lib.utils import save_cookies, loadCookies, log, get_iptv_channels_file from resources.lib.cbc import CBC +from resources.lib.gemv2 import GemV2 LIST_URL = 'https://services.radio-canada.ca/ott/catalog/v2/gem/home?device=web' LIST_ELEMENT = '2415871718' @@ -71,6 +72,7 @@ def get_iptv_channels(self): # - channel_dict is used by the IPTVManager for the guide and stream is how the IPTV manager calls us back to play something values = { 'id': callsign, + 'app_code': 'medianetlive', 'image': image, 'labels': urlencode(labels) } @@ -89,13 +91,7 @@ def get_iptv_channels(self): return result def get_channel_stream(self, id): - url = f'https://services.radio-canada.ca/media/validation/v2/?appCode=medianetlive&connectionType=hd&deviceType=ipad&idMedia={id}&multibitrate=true&output=json&tech=hls&manifestType=desktop' - resp = self.session.get(url) - if not resp.status_code == 200: - log('ERROR: {} returns status of {}'.format(LIST_URL, resp.status_code), True) - return None - save_cookies(self.session.cookies) - return json.loads(resp.content)['url'] + return GemV2.get_stream(id=id,app_code='medianetlive') def get_channel_metadata(self, id): url = f'https://services.radio-canada.ca/media/meta/v1/index.ashx?appCode=medianetlive&idMedia={id}&output=jsonObject'