11"""Module for live channels."""
2- from concurrent import futures
32import json
3+ import re
44from urllib .parse import urlencode
55
66import requests
77
8- from resources .lib .utils import save_cookies , loadCookies , log , get_iptv_channels_file
8+ from resources .lib .utils import log , get_iptv_channels_file
99from resources .lib .cbc import CBC
1010from resources .lib .gemv2 import GemV2
1111
12- LIST_URL = 'https://services.radio-canada.ca/ott/catalog/v2/gem/home?device=web'
13- LIST_ELEMENT = '2415871718'
12+ GEM_BASE_URL = 'https://gem.cbc.ca/'
13+ FALLBACK_BUILD_ID = '7ByKb_CElwT2xVJeTO43g'
14+ LIST_URL_TEMPLATE = 'https://gem.cbc.ca/_next/data/{}/live.json'
1415
1516class LiveChannels :
1617 """Class for live channels."""
@@ -19,36 +20,97 @@ def __init__(self):
1920 """Initialize the live channels class."""
2021 # Create requests session object
2122 self .session = requests .Session ()
22- session_cookies = loadCookies ()
23- if session_cookies is not None :
24- self .session .cookies = session_cookies
23+
24+ @staticmethod
25+ def extract_build_id (html ):
26+ """Extract Next.js buildId from page HTML."""
27+ script_start = '<script id="__NEXT_DATA__" type="application/json">'
28+ script_end = '</script>'
29+ start_pos = html .find (script_start )
30+ if start_pos >= 0 :
31+ start_pos += len (script_start )
32+ end_pos = html .find (script_end , start_pos )
33+ if end_pos > start_pos :
34+ try :
35+ next_data = json .loads (html [start_pos :end_pos ])
36+ build_id = next_data .get ('buildId' )
37+ if build_id :
38+ return build_id
39+ except (ValueError , TypeError ):
40+ pass
41+
42+ match = re .search (r'/_next/static/([^/]+)/_buildManifest\\.js' , html )
43+ if match :
44+ return match .group (1 )
45+
46+ return None
47+
48+ def get_live_list_url (self ):
49+ """Build the live channel JSON URL dynamically from current Next.js buildId."""
50+ try :
51+ resp = self .session .get (GEM_BASE_URL )
52+ if resp .status_code == 200 :
53+ build_id = self .extract_build_id (resp .text )
54+ if build_id :
55+ return LIST_URL_TEMPLATE .format (build_id )
56+ log ('WARNING: Unable to find buildId in {} response' .format (GEM_BASE_URL ), True )
57+ else :
58+ log ('WARNING: {} returns status of {}' .format (GEM_BASE_URL , resp .status_code ), True )
59+ except requests .RequestException as err :
60+ log ('WARNING: Error fetching {}: {}' .format (GEM_BASE_URL , err ), True )
61+
62+ return LIST_URL_TEMPLATE .format (FALLBACK_BUILD_ID )
2563
2664 def get_live_channels (self ):
2765 """Get the list of live channels."""
28- resp = self .session .get (LIST_URL )
66+ list_url = self .get_live_list_url ()
67+ resp = self .session .get (list_url )
2968
3069 if not resp .status_code == 200 :
31- log ('ERROR: {} returns status of {}' .format (LIST_URL , resp .status_code ), True )
70+ log ('ERROR: {} returns status of {}' .format (list_url , resp .status_code ), True )
3271 return None
33- save_cookies (self .session .cookies )
34-
35- ret = None
36- for result in json .loads (resp .content )['lineups' ]['results' ]:
37- if result ['key' ] == LIST_ELEMENT :
38- ret = result ['items' ]
39-
40- future_to_callsign = {}
41- with futures .ThreadPoolExecutor (max_workers = 20 ) as executor :
42- for i , channel in enumerate (ret ):
43- callsign = CBC .get_callsign (channel )
44- future = executor .submit (self .get_channel_metadata , callsign )
45- future_to_callsign [future ] = i
46-
47- for future in futures .as_completed (future_to_callsign ):
48- i = future_to_callsign [future ]
49- metadata = future .result ()
50- ret [i ]['image' ] = metadata ['Metas' ]['imageHR' ]
51- return ret
72+
73+ data = json .loads (resp .content )
74+ page_data = data .get ('pageProps' , {}).get ('data' , {})
75+ streams = page_data .get ('streams' , [])
76+ free_tv_items = page_data .get ('freeTv' , {}).get ('items' , [])
77+
78+ channels = []
79+ for stream in streams :
80+ items = stream .get ('items' , [])
81+ if len (items ) == 0 :
82+ continue
83+
84+ for item in items :
85+ channel = dict (item )
86+ if 'title' not in channel or not channel ['title' ]:
87+ channel ['title' ] = stream .get ('title' )
88+ if 'genericImage' in channel and 'image' not in channel :
89+ channel ['image' ] = channel ['genericImage' ]
90+ channels .append (channel )
91+
92+ for item in free_tv_items :
93+ channel = dict (item )
94+ if 'genericImage' in channel and 'image' not in channel :
95+ channel ['image' ] = channel ['genericImage' ]
96+ channels .append (channel )
97+
98+ unique_channels = []
99+ seen_ids = set ()
100+ for channel in channels :
101+ id_media = channel .get ('idMedia' )
102+
103+ if id_media is None :
104+ unique_channels .append (channel )
105+ continue
106+
107+ if id_media in seen_ids :
108+ continue
109+
110+ seen_ids .add (id_media )
111+ unique_channels .append (channel )
112+
113+ return unique_channels
52114
53115 def get_iptv_channels (self ):
54116 """Get the channels in a IPTV Manager compatible list."""
@@ -99,7 +161,6 @@ def get_channel_metadata(self, id):
99161 if not resp .status_code == 200 :
100162 log ('ERROR: {} returns status of {}' .format (LIST_URL , resp .status_code ), True )
101163 return None
102- save_cookies (self .session .cookies )
103164 return json .loads (resp .content )
104165
105166 @staticmethod
0 commit comments