Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 5 additions & 3 deletions 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.22+matrix.1"
version="4.0.23+matrix.1"
provider-name="micahg,t1m,smf007,oshanrube,jgaudet">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
Expand Down Expand Up @@ -29,8 +29,10 @@
<website>https://watch.cbc.ca/</website>
<source>https://github.com/micahg/plugin.video.cbc</source>
<news>
- Updates for new Auth API
- Fixes for live events
- Add back missing live channels
- Better live event handling
- Better EPG error handling
- Remove dead code and fix match bug
</news>
</extension>
</addon>
17 changes: 10 additions & 7 deletions default.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import routing

from resources.lib.cbc import CBC
from resources.lib.utils import log, getAuthorizationFile, get_cookie_file, get_iptv_channels_file
from resources.lib.utils import log, getAuthorizationFile, get_iptv_channels_file, is_pending, iso8601_to_local
from resources.lib.livechannels import LiveChannels
from resources.lib.gemv2 import GemV2
from resources.lib.iptvmanager import IPTVManager
Expand Down Expand Up @@ -119,7 +119,6 @@ def logout():
"""Remove authorization stuff."""
log('Logging out...', True)
os.remove(getAuthorizationFile())
os.remove(get_cookie_file())


@plugin.route('/iptv/channels')
Expand Down Expand Up @@ -174,6 +173,7 @@ def play_live_channel():
def live_channels_menu():
"""Populate the menu with live channels."""
xbmcplugin.setContent(plugin.handle, 'videos')
xbmcplugin.addSortMethod(plugin.handle, xbmcplugin.SORT_METHOD_LABEL)
chans = LiveChannels()
chan_list = chans.get_live_channels()
cbc = CBC()
Expand All @@ -184,7 +184,10 @@ def live_channels_menu():
item = xbmcgui.ListItem(labels['title'])
item.setArt({'thumb': image, 'poster': image})
item.setInfo(type="Video", infoLabels=labels)
item.setProperty('IsPlayable', 'true')
air_date = channel.get('airDate')
local_dt = iso8601_to_local(air_date) if air_date else None
if local_dt is None or not is_pending(local_dt):
item.setProperty('IsPlayable', 'true')
item.addContextMenuItems([
(getString(30014), 'RunPlugin({})'.format(plugin.url_for(live_channels_add_all))),
(getString(30015), 'RunPlugin({})'.format(plugin.url_for(live_channels_add, callsign))),
Expand Down Expand Up @@ -222,16 +225,16 @@ def layout_menu(path):
for f in items:
n = GemV2.normalized_format_item(f)
p = GemV2.normalized_format_path(f, path)
item = xbmcgui.ListItem(n['label'])
item = xbmcgui.ListItem(n['title'])
if 'art' in n:
item.setArt(n['art'])
item.setInfo(type="Video", infoLabels=n['info_labels'])
if n['playable']:
item.setProperty('IsPlayable', 'true')
if 'app_code' in n and n['app_code']:
item.setProperty('IsPlayable', 'true' if 'playable' in n and n['playable'] else 'false')
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.addDirectoryItem(handle, url, item, not 'app_code' in n)
xbmcplugin.endOfDirectory(handle)


Expand Down
21 changes: 9 additions & 12 deletions resources/lib/cbc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
import json
# import http.client as http_client
from urllib.parse import urlparse, parse_qs, quote
from xml.dom.minidom import parseString

import requests

from .utils import save_cookies, loadCookies, saveAuthorization, log
from .utils import saveAuthorization, log, iso8601_to_local, is_pending

# http_client.HTTPConnection.debuglevel = 1

Expand Down Expand Up @@ -65,9 +64,6 @@ def __init__(self):
"""Initialize the CBC class."""
# Create requests session object
self.session = requests.Session()
session_cookies = loadCookies()
if session_cookies is not None:
self.session.cookies = session_cookies

@staticmethod
def azure_authorize_authorize(sess: requests.Session):
Expand Down Expand Up @@ -336,8 +332,8 @@ def get_labels(item):
'studio': 'Canadian Broadcasting Corporation',
'country': 'Canada'
}
if 'cbc$callSign' in item:
labels['title'] = '{} {}'.format(item['cbc$callSign'], item['title'])
if 'streamTitle' in item:
labels['title'] = item['streamTitle'].encode('utf-8')
else:
labels['title'] = item['title'].encode('utf-8')

Expand Down Expand Up @@ -373,14 +369,15 @@ def get_labels(item):
if item['cbc$audioVideo'].lower() == 'video':
labels['mediatype'] = 'video'

if 'airDate' in item:
local_dt = iso8601_to_local(item['airDate'])
if local_dt is not None:
is_pending(local_dt, item=labels)

return labels


@staticmethod
def get_session():
"""Get a requests session object with CBC cookies."""
sess = requests.Session()
cookies = loadCookies()
if cookies is not None:
sess.cookies = cookies
return sess
return requests.Session()
35 changes: 32 additions & 3 deletions resources/lib/epg.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

# Y/M/D
GUIDE_URL_FMT = 'https://www.cbc.ca/programguide/daily/{}/cbc_television'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
REQUEST_TIMEOUT = (10, 20)

# This is a combination of actual callsigns (NN for newsnet) and guid values
# for over-the-top-only services. No doubt, someone will come back here some
Expand Down Expand Up @@ -63,8 +65,16 @@ def map_channel_ids(unblocked):
"""Map channel IDs to guide names."""
url = get_guide_url(datetime.now())
data = call_guide_url(url)
if data is None:
log('Unable to map channel IDs: no data returned from {}'.format(url), True)
return { sign: None for sign in SPECIAL_GUIDES.keys()}

soup = BeautifulSoup(data, features="html.parser")
select = soup.find('select', id="selectlocation-tv")
if select is None:
log('Unable to map channel IDs: missing location selector at {}'.format(url), True)
return { sign: None for sign in SPECIAL_GUIDES.keys()}

options = select.find_all('option')
channel_map = { sign: None for sign in SPECIAL_GUIDES.keys()}
for option in options:
Expand All @@ -87,9 +97,14 @@ def get_guide_url(dttm, callsign=None):
def call_guide_url(url, location=None):
"""Call the guide URL and return the response body."""
cookies = {}
headers = {'User-Agent': USER_AGENT}
if location is not None:
cookies['pgTvLocation'] = location
resp = requests.get(url, cookies=cookies)
try:
resp = requests.get(url, cookies=cookies, headers=headers, timeout=REQUEST_TIMEOUT)
except requests.RequestException as ex:
log(f'HTTP request failed for {url}: {ex}', True)
return None
if resp.status_code != 200:
log('{} returns status of {}'.format(url, resp.status_code), True)
return None
Expand All @@ -101,9 +116,21 @@ def get_channel_data(dttm, channel, callsign):
epg_data = []
url = get_guide_url(dttm, callsign)
data = call_guide_url(url, channel)
if data is None:
return epg_data

soup = BeautifulSoup(data, features="html.parser")

select = soup.find('table', id="sched-table").find('tbody')
table = soup.find('table', id="sched-table")
if table is None:
log('Missing schedule table at "{}"'.format(url), True)
return epg_data

select = table.find('tbody')
if select is None:
log('Missing schedule body at "{}"'.format(url), True)
return epg_data

progs = select.find_all('tr')
for prog in progs:
prog_js = {}
Expand All @@ -122,7 +149,9 @@ def get_channel_data(dttm, channel, callsign):
prog_js = {}
break
prog_js['title'] = title_cell.get_text()
prog_js['description'] = cell.find('dd').get_text()
description_cell = cell.find('dd')
if description_cell is not None:
prog_js['description'] = description_cell.get_text()

# skip the header row
if len(prog_js.items()) == 0:
Expand Down
41 changes: 16 additions & 25 deletions resources/lib/gemv2.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
"""Module for the V2 Gem API."""
import json
from datetime import datetime

import requests

from resources.lib.cbc import CBC
from resources.lib.utils import loadAuthorization, log
from resources.lib.utils import loadAuthorization, log, iso8601_to_local, is_pending


# api CONFIG IS AT https://services.radio-canada.ca/ott/catalog/v1/gem/settings?device=web
Expand All @@ -18,15 +17,6 @@
class GemV2:
"""V2 Gem API class."""

@staticmethod
def iso8601_to_local(dttm):
"""Convert an ISO 8601 timestamp (UTC or offset-aware) to local time string."""
try:
local_dt = datetime.fromisoformat(dttm.replace('Z', '+00:00')).astimezone()
return local_dt.strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError, AttributeError):
return dttm

@staticmethod
def scrape_json(uri, headers=None, params=None):
if headers is None:
Expand All @@ -40,7 +30,7 @@ def scrape_json(uri, headers=None, params=None):

try:
jsObj = json.loads(resp.content)
except:
except (json.JSONDecodeError, ValueError, TypeError):
log(f'Unable to parse JSON from {uri} (status {resp.status_code})', True)
return None
return jsObj
Expand Down Expand Up @@ -198,7 +188,7 @@ def normalized_format_item(item):
images = item['images'] if 'images' in item else None
title = item['label'] if 'label' in item else item['title']
retval = {
'label': title,
'title': title,
'playable': 'idMedia' in item,
'info_labels': {
'tvshowtitle': title,
Expand All @@ -225,22 +215,23 @@ def normalized_format_item(item):
if 'credits' in meta:
retval['info_labels']['cast'] = meta['credits'][0]['peoples'].split(',')
if 'live' in meta and 'startDate' in meta['live']:
dttm = GemV2.iso8601_to_local(meta['live']['startDate'])
retval['label'] += f' [Live: {dttm}]'
local_dt = iso8601_to_local(meta['live']['startDate'])
if local_dt is not None and is_pending(local_dt, item=retval):
retval['playable'] = False

if 'idMedia' in item:
# logic in https://services.radio-canada.ca/ott/catalog/v1/gem/settings?device=web
if 'type' in item:
match item['type'].lower():
case 'media':
retval['app_code'] = 'gem'
case 'quickturn':
retval['app_code'] = 'medianet'
case 'liveevent' | 'replay':
retval['app_code'] = 'medianetlive'
case _:
log(f'Unknown type {item["type"]} for item with idMedia {item["idMedia"]}, defaulting to app_code "gem"')
retval['app_code'] = 'gem'
item_type = item['type'].lower()
if item_type == 'media':
retval['app_code'] = 'gem'
elif item_type == 'quickturn':
retval['app_code'] = 'medianet'
elif item_type == 'liveevent' or item_type == 'replay':
retval['app_code'] = 'medianetlive'
else:
log(f'Unknown type {item["type"]} for item with idMedia {item["idMedia"]}, defaulting to app_code "gem"')
retval['app_code'] = 'gem'


return retval
Expand Down
Loading