Repository: yt-dlp/yt-dlp
Issue category: Bug Report

Issue:
[CTVNews] Does not find video on page
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I *intentionally* remove or skip any mandatory\* field

### Checklist

- [X] I'm reporting that yt-dlp is broken on a **supported** site
- [X] I've verified that I have **updated yt-dlp to nightly or master** ([update instructions](https://github.com/yt-dlp/yt-dlp#update-channels))
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've checked that all URLs and arguments with special characters are [properly quoted or escaped](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#video-url-contains-an-ampersand--and-im-getting-some-strange-output-1-2839-or-v-is-not-recognized-as-an-internal-or-external-command)
- [X] I've searched [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766) and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [X] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

canada

### Provide a description that is worded well enough to be understood

URL: https://www.ctvnews.ca/business/respondents-to-bank-of-canada-questionnaire-largely-oppose-creating-a-digital-loonie-1.6665797

Downloads a play list but finds zero items (videos)
No actual error,  just no resulting video.


### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [X] If using API, add `'verbose': True` to `YoutubeDL` params instead
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
>yt-dlp.py -vU "https://www.ctvnews.ca/business/respondents-to-bank-of-canada-questionnaire-largely-oppose-creating-a-digital-loonie-1.6665797 "
[debug] Command-line config: ['-vU', 'https://www.ctvnews.ca/business/respondents-to-bank-of-canada-questionnaire-largely-oppose-creating-a-digital-loonie-1.6665797 ']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version nightly@2023.11.29.232714 from yt-dlp/yt-dlp-nightly-builds [6a9c7a2b5] (zip)
[debug] Python 3.10.5 (CPython AMD64 64bit) - Windows-10-10.0.14393-SP0 (OpenSSL 1.1.1n  15 Mar 2022)
[debug] exe versions: ffmpeg 6.1-full_build-www.gyan.dev (setts), ffprobe 4.3.2-2021-02-02-full_build-www.gyan.dev, rtmpdump 2.4
[debug] Optional libraries: sqlite3-3.37.2
[debug] Proxy map: {}
[debug] Request Handlers: urllib
[debug] Loaded 1792 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest
Latest version: nightly@2023.11.29.232714 from yt-dlp/yt-dlp-nightly-builds
yt-dlp is up to date (nightly@2023.11.29.232714 from yt-dlp/yt-dlp-nightly-builds)
[CTVNews] Extracting URL: https://www.ctvnews.ca/business/respondents-to-bank-of-canada-questionnaire-largely-oppose-creating-a-digital-loonie-1.6665797
[CTVNews] 1.6665797: Downloading webpage
[CTVNews] 1.6665797: Downloading webpage
[download] Downloading playlist: 1.6665797
[CTVNews] Playlist 1.6665797: Downloading 0 items
[download] Finished downloading playlist: 1.6665797
```



Hints:
This patch gets the problem video.
```diff
--- old/yt_dlp/extractor/ctvnews.py
+++ new/yt_dlp/extractor/ctvnews.py
                 if 'getAuthStates("' in webpage:
                     entries = [ninecninemedia_url_result(clip_id) for clip_id in
                                self._search_regex(r'getAuthStates\("([\d+,]+)"', webpage, 'clip ids').split(',')]
+                else:
+                    entries = [ninecninemedia_url_result(clip_id) for clip_id in orderedSet(
+                        re.findall(r'axisId&#34;:&#34;(\d+)', webpage))]
             return self.playlist_result(entries, page_id)
```
It's not clear that `orderedSet()` is necessary: it's used in the original line 63 but not in the newer l.68 shown above.

Patch:
diff --git a/yt_dlp/extractor/ctvnews.py b/yt_dlp/extractor/ctvnews.py
index 08d76d303b04..c3ddcdbee4ba 100644
--- a/yt_dlp/extractor/ctvnews.py
+++ b/yt_dlp/extractor/ctvnews.py
@@ -1,11 +1,24 @@
+import json
 import re
+import urllib.parse
 
 from .common import InfoExtractor
-from ..utils import orderedSet
+from .ninecninemedia import NineCNineMediaIE
+from ..utils import extract_attributes, orderedSet
+from ..utils.traversal import find_element, traverse_obj
 
 
 class CTVNewsIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:.+?\.)?ctvnews\.ca/(?:video\?(?:clip|playlist|bin)Id=|.*?)(?P<id>[0-9.]+)(?:$|[#?&])'
+    _BASE_REGEX = r'https?://(?:[^.]+\.)?ctvnews\.ca/'
+    _VIDEO_ID_RE = r'(?P<id>\d{5,})'
+    _PLAYLIST_ID_RE = r'(?P<id>\d\.\d{5,})'
+    _VALID_URL = [
+        rf'{_BASE_REGEX}video/c{_VIDEO_ID_RE}',
+        rf'{_BASE_REGEX}video(?:-gallery)?/?\?clipId={_VIDEO_ID_RE}',
+        rf'{_BASE_REGEX}video/?\?(?:playlist|bin)Id={_PLAYLIST_ID_RE}',
+        rf'{_BASE_REGEX}(?!video/)[^?#]*?{_PLAYLIST_ID_RE}/?(?:$|[?#])',
+        rf'{_BASE_REGEX}(?!video/)[^?#]+\?binId={_PLAYLIST_ID_RE}',
+    ]
     _TESTS = [{
         'url': 'http://www.ctvnews.ca/video?clipId=901995',
         'md5': 'b608f466c7fa24b9666c6439d766ab7e',
@@ -17,13 +30,32 @@ class CTVNewsIE(InfoExtractor):
             'timestamp': 1467286284,
             'upload_date': '20160630',
             'categories': [],
+            'season_number': 0,
+            'season': 'Season 0',
             'tags': [],
-            'season_id': 57981,
+            'series': 'CTV News National | Archive | Stories 2',
+            'season_id': '57981',
+            'thumbnail': r're:https?://.*\.jpg$',
             'duration': 764.631,
-            'series': 'CTV News National story',
-            'thumbnail': r're:^https?://.*\.jpg$',
-            'season': 'Season 0',
+        },
+    }, {
+        'url': 'https://barrie.ctvnews.ca/video/c3030933-here_s-what_s-making-news-for-nov--15?binId=1272429',
+        'md5': '8b8c2b33c5c1803e3c26bc74ff8694d5',
+        'info_dict': {
+            'id': '3030933',
+            'ext': 'flv',
+            'title': 'Here’s what’s making news for Nov. 15',
+            'description': 'Here are the top stories we’re working on for CTV News at 11 for Nov. 15',
+            'thumbnail': 'http://images2.9c9media.com/image_asset/2021_2_22_a602e68e-1514-410e-a67a-e1f7cccbacab_png_2000x1125.jpg',
+            'season_id': '58104',
             'season_number': 0,
+            'tags': [],
+            'season': 'Season 0',
+            'categories': [],
+            'series': 'CTV News Barrie',
+            'upload_date': '20241116',
+            'duration': 42.943,
+            'timestamp': 1731722452,
         },
     }, {
         'url': 'http://www.ctvnews.ca/video?playlistId=1.2966224',
@@ -46,6 +78,65 @@ class CTVNewsIE(InfoExtractor):
             'id': '1.5736957',
         },
         'playlist_mincount': 6,
+    }, {
+        'url': 'https://www.ctvnews.ca/business/respondents-to-bank-of-canada-questionnaire-largely-oppose-creating-a-digital-loonie-1.6665797',
+        'md5': '24bc4b88cdc17d8c3fc01dfc228ab72c',
+        'info_dict': {
+            'id': '2695026',
+            'ext': 'flv',
+            'season_id': '89852',
+            'series': 'From CTV News Channel',
+            'description': 'md5:796a985a23cacc7e1e2fafefd94afd0a',
+            'season': '2023',
+            'title': 'Bank of Canada asks public about digital currency',
+            'categories': [],
+            'tags': [],
+            'upload_date': '20230526',
+            'season_number': 2023,
+            'thumbnail': 'http://images2.9c9media.com/image_asset/2019_3_28_35f5afc3-10f6-4d92-b194-8b9a86f55c6a_png_1920x1080.jpg',
+            'timestamp': 1685105157,
+            'duration': 253.553,
+        },
+    }, {
+        'url': 'https://stox.ctvnews.ca/video-gallery?clipId=582589',
+        'md5': '135cc592df607d29dddc931f1b756ae2',
+        'info_dict': {
+            'id': '582589',
+            'ext': 'flv',
+            'categories': [],
+            'timestamp': 1427906183,
+            'season_number': 0,
+            'duration': 125.559,
+            'thumbnail': 'http://images2.9c9media.com/image_asset/2019_3_28_35f5afc3-10f6-4d92-b194-8b9a86f55c6a_png_1920x1080.jpg',
+            'series': 'CTV News Stox',
+            'description': 'CTV original footage of the rise and fall of the Berlin Wall.',
+            'title': 'Berlin Wall',
+            'season_id': '63817',
+            'season': 'Season 0',
+            'tags': [],
+            'upload_date': '20150401',
+        },
+    }, {
+        'url': 'https://ottawa.ctvnews.ca/features/regional-contact/regional-contact-archive?binId=1.1164587#3023759',
+        'md5': 'a14c0603557decc6531260791c23cc5e',
+        'info_dict': {
+            'id': '3023759',
+            'ext': 'flv',
+            'season_number': 2024,
+            'timestamp': 1731798000,
+            'season': '2024',
+            'episode': 'Episode 125',
+            'description': 'CTV News Ottawa at Six',
+            'duration': 2712.076,
+            'episode_number': 125,
+            'upload_date': '20241116',
+            'title': 'CTV News Ottawa at Six for Saturday, November 16, 2024',
+            'thumbnail': 'http://images2.9c9media.com/image_asset/2019_3_28_35f5afc3-10f6-4d92-b194-8b9a86f55c6a_png_1920x1080.jpg',
+            'categories': [],
+            'tags': [],
+            'series': 'CTV News Ottawa at Six',
+            'season_id': '92667',
+        },
     }, {
         'url': 'http://www.ctvnews.ca/1.810401',
         'only_matching': True,
@@ -57,29 +148,35 @@ class CTVNewsIE(InfoExtractor):
         'only_matching': True,
     }]
 
+    def _ninecninemedia_url_result(self, clip_id):
+        return self.url_result(f'9c9media:ctvnews_web:{clip_id}', NineCNineMediaIE, clip_id)
+
     def _real_extract(self, url):
         page_id = self._match_id(url)
 
-        def ninecninemedia_url_result(clip_id):
-            return {
-                '_type': 'url_transparent',
-                'id': clip_id,
-                'url': f'9c9media:ctvnews_web:{clip_id}',
-                'ie_key': 'NineCNineMedia',
-            }
+        if mobj := re.fullmatch(self._VIDEO_ID_RE, urllib.parse.urlparse(url).fragment):
+            page_id = mobj.group('id')
+
+        if re.fullmatch(self._VIDEO_ID_RE, page_id):
+            return self._ninecninemedia_url_result(page_id)
+
+        webpage = self._download_webpage(f'https://www.ctvnews.ca/{page_id}', page_id, query={
+            'ot': 'example.AjaxPageLayout.ot',
+            'maxItemsPerPage': 1000000,
+        })
+        entries = [self._ninecninemedia_url_result(clip_id)
+                   for clip_id in orderedSet(re.findall(r'clip\.id\s*=\s*(\d+);', webpage))]
+        if not entries:
+            webpage = self._download_webpage(url, page_id)
+            if 'getAuthStates("' in webpage:
+                entries = [self._ninecninemedia_url_result(clip_id) for clip_id in
+                           self._search_regex(r'getAuthStates\("([\d+,]+)"', webpage, 'clip ids').split(',')]
+            else:
+                entries = [
+                    self._ninecninemedia_url_result(clip_id) for clip_id in
+                    traverse_obj(webpage, (
+                        {find_element(tag='jasper-player-container', html=True)},
+                        {extract_attributes}, 'axis-ids', {json.loads}, ..., 'axisId'))
+                ]
 
-        if page_id.isdigit():
-            return ninecninemedia_url_result(page_id)
-        else:
-            webpage = self._download_webpage(f'http://www.ctvnews.ca/{page_id}', page_id, query={
-                'ot': 'example.AjaxPageLayout.ot',
-                'maxItemsPerPage': 1000000,
-            })
-            entries = [ninecninemedia_url_result(clip_id) for clip_id in orderedSet(
-                re.findall(r'clip\.id\s*=\s*(\d+);', webpage))]
-            if not entries:
-                webpage = self._download_webpage(url, page_id)
-                if 'getAuthStates("' in webpage:
-                    entries = [ninecninemedia_url_result(clip_id) for clip_id in
-                               self._search_regex(r'getAuthStates\("([\d+,]+)"', webpage, 'clip ids').split(',')]
-            return self.playlist_result(entries, page_id)
+        return self.playlist_result(entries, page_id)


Test patch: