Repository: yt-dlp/yt-dlp

Issue:
[9now.com.au] Unable to extract page data
### 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), [the FAQ](https://github.com/yt-dlp/yt-dlp/wiki/FAQ), and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=is%3Aissue%20-label%3Aspam%20%20) for similar issues **including closed ones**. DO NOT post duplicates
- [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

Australia

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

yt-dlp now outputs error when tring to download webpage for data scrape.

If you go into devtools the m3u8 is still visible and that can be inputted into yt-dlp without issue. Looks to simple be webpage download step that is failing. 

### Provide verbose output that clearly demonstrates the problem

- [x] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [ ] 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
[debug] Command-line config: ['-vU', 'https://www.9now.com.au/married-at-first-sight/season-12/episode-1']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version nightly@2025.03.07.232704 from yt-dlp/yt-dlp-nightly-builds [05c8023a2] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.22631-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg N-116754-ga754ee0844-20240821 (setts), ffprobe N-116754-ga754ee0844-20240821
[debug] Optional libraries: Cryptodome-3.21.0, brotli-1.1.0, certifi-2025.01.31, curl_cffi-0.5.10, mutagen-1.47.0, requests-2.32.3, sqlite3-3.40.1, urllib3-2.3.0, websockets-15.0.1
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Plugin directories: none
[debug] Loaded 1844 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest
Latest version: nightly@2025.03.07.232704 from yt-dlp/yt-dlp-nightly-builds
yt-dlp is up to date (nightly@2025.03.07.232704 from yt-dlp/yt-dlp-nightly-builds)
[debug] Using fake IP 1.134.182.26 (AU) as X-Forwarded-For
[9now.com.au] Extracting URL: https://www.9now.com.au/married-at-first-sight/season-12/episode-1
[9now.com.au] episode-1: Downloading webpage
ERROR: [9now.com.au] episode-1: Unable to extract page data; please report this issue on  https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using  yt-dlp -U
  File "yt_dlp\extractor\common.py", line 747, in extract
  File "yt_dlp\extractor\ninenow.py", line 68, in _real_extract
  File "yt_dlp\extractor\common.py", line 1338, in _search_regex
```

Hints from issue comments:
@daveo76: I'm having that same problem to. Also it worked for me to yesterday and other previous days@JuzzyJayy: I'm having the same issue and likewise, it worked yesterday.
Where are you finding the m3u8 in devtools and would you care to share the command line you use.

Also wanting to watch MAFs lol@wastedhate: > I'm having the same issue and likewise, it worked yesterday. Where are you finding the m3u8 in devtools and would you care to share the command line you use.
> 
> Also wanting to watch MAFs lol

Network tab

....master.m3u8...

yt-dlp.exe "m3u8 url"@gmhabchi: Its working again now 👌🏻 

```
~ yt-dlp -f bestvideo+bestaudio --merge-output-format mp4 https://www.9now.com.au/married-at-first-sight/season-12/episode-28
[9now.com.au] Extracting URL: https://www.9now.com.au/married-at-first-sight/season-12/episode-28
[9now.com.au] episode-28: Downloading webpage
[brightcove:new] Extracting URL: http://players.brightcove.net/4460760524001/default_default/index.html?videoId=6369915787112#__yo...3A+%5B%22AU%22%5D%7D
[brightcove:new] 6369915787112: Downloading JSON metadata
[brightcove:new] 6369915787112: Downloading m3u8 information
[brightcove:new] 6369915787112: Downloading m3u8 information
[brightcove:new] 6369915787112: Downloading m3u8 information
[brightcove:new] 6369915787112: Downloading m3u8 information
[brightcove:new] 6369915787112: Downloading m3u8 information
[brightcove:new] 6369915787112: Downloading m3u8 information
[brightcove:new] 6369915787112: Downloading MPD manifest
[brightcove:new] 6369915787112: Downloading MPD manifest
[brightcove:new] 6369915787112: Downloading MPD manifest
[brightcove:new] 6369915787112: Downloading MPD manifest
[info] 6369915787112: Downloading 1 format(s): hls-2193-3+dash-af1f4915-c4f5-4ba8-bc35-e24ce067d852-3
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 401
[download] Destination: Episode 28 [6369915787112].fhls-2193-3.mp4
[download]   1.3% of ~ 928.42MiB at    2.16MiB/s ETA 06:09 (frag 5/401)
```@wastedhate: Can confirm. Working for me too. @daveo76: I just tried it. I can confirm it working now for me to.@bashonly: Note for devs in case this needs to be reopened: 

I took a look at the page source last night before they rolled back the change, and it looked like the site was using the new version of NextJS with all the push calls. Fun stuff@mimixy: This issue is happening again@daveo76: Please update this. To make it work with 9now@daveo76: > > I'm having the same issue and likewise, it worked yesterday. Where are you finding the m3u8 in devtools and would you care to share the command line you use.
> > Also wanting to watch MAFs lol
> 
> Network tab
> 
> ....master.m3u8...
> 
> yt-dlp.exe "m3u8 url"

How do I get the m3u8. Using YTDLnis android app?@ashdurham: Whilst using the `master.m3u8` does appear to download the show, it seems to only be the video - the sound is missing.
Anyone figured out the solution to that?@quickdry: using master.m3u8 directly as found from the browser I was able to get both the video, sound, and subs (--embed-subs) but not when passing in the url of the episode's page@derrix060: > Whilst using the `master.m3u8` does appear to download the show, it seems to only be the video - the sound is missing. Anyone figured out the solution to that?

It was happening with me, then I used `-f bestvideo+bestaudio --merge-output-format mp4` tags and it worked.  Note, make sure you have ffmpeg installed, otherwise you will see this warning and then you will have 2 files (one for audio and one for video):

```
WARNING: You have requested merging of multiple formats but ffmpeg is not installed. The formats won't be merged
```@FlaminHat: How do I fix the "ERROR: Unable to extract page data" problem?

I'm using yt-dlp 2025.02.19@error-reporting: > > > I'm having the same issue and likewise, it worked yesterday. Where are you finding the m3u8 in devtools and would you care to share the command line you use.
> > > Also wanting to watch MAFs lol
> > 
> > 
> > Network tab
> > ....master.m3u8...
> > yt-dlp.exe "m3u8 url"
> 
> How do I get the m3u8. Using YTDLnis android app?

kiwi browser on github@FlaminHat: > > > > I'm having the same issue and likewise, it worked yesterday. Where are you finding the m3u8 in devtools and would you care to share the command line you use.
> > > > Also wanting to watch MAFs lol
> > > 
> > > 
> > > Network tab
> > > ....master.m3u8...
> > > yt-dlp.exe "m3u8 url"
> > 
> > 
> > How do I get the m3u8. Using YTDLnis android app?
> 
> kiwi browser on github

I'm using the CLI version in Terminal@dmc1961: Was working for MAFS with an automatic bash script that I have used for years with all these kinds of auto-fetch from TV websites:

~/bin/yt-dlp -f 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio' --merge-output-format mp4 https://www.9now.com.au/married-at-first-sight/season-12/episode-32

up until last night. Now even if I go back to the episodes that worked before (so 31 back to 28), all give this same error.

~/bin/yt-dlp -vU https://www.9now.com.au/married-at-first-sight/season-12/episode-32 looks happy to start with but then I get:

[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest
Latest version: nightly@2025.03.16.232921 from yt-dlp/yt-dlp-nightly-builds
yt-dlp is up to date (nightly@2025.03.16.232921 from yt-dlp/yt-dlp-nightly-builds)
[debug] Using fake IP 1.144.162.110 (AU) as X-Forwarded-For
[9now.com.au] Extracting URL: https://www.9now.com.au/married-at-first-sight/season-12/episode-32
[9now.com.au] episode-32: Downloading webpage
ERROR: [9now.com.au] episode-32: Unable to extract page data; please report this issue on  https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using  yt-dlp -U
  File "yt_dlp/extractor/common.py", line 747, in extract
  File "yt_dlp/extractor/ninenow.py", line 68, in _real_extract
  File "yt_dlp/extractor/common.py", line 1338, in _search_regex

I also tried my username and password options last night but it still gave the same error.
It doesn't look like a DRM issue but feels kind of remote end changed something-ish.

Also tried another show in my autv download setup which gives the same error:

https://www.9now.com.au/space-invaders/season-5/episode-6

I am a Linux bash based user - been at the UNIX/Linux command line since 1987 so am an old dog at all of this but not a python expert by any stretch.@dirkf: Just to clarify [the problem](https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2725955231), which is not specific to this site:
* the site uses the satanic Next.js web technology that produces page HTML  in the form of a skeleton fleshed out by JS loaded from the page, using so-called _hydration_ JSON embedded in the page
* the extractor expects a version of this stuff where the JSON is one big chunk with a certain structure
* the site has gradually switched to a newer version of Next.js where the hydration JSON is obscurely split into chunks and, even after reassembly, does not have the same structure
* therefore the extractor has to be modified to handle this, whether by creating a version of the common routine that extracts Next.js hydration  JSON to handle the new version, switching to fetching the necessary data using a site API URL, or otherwise.

So, not straightforward.@majibow: Its not so bad. As a test run I was able to search for the _"brightcoveId"_ and then replace the bright cove template variable with the id directly and it proceeded to download just fine. The _"brightcoveId"_ appears to be intact within the NextJS framework, meaning full reassembly is probably not a requirement for a useable extractor.@JuzzyJayy: For those that need it in clearer instructions on how to download while this issue is happening, see the video attached. The quality isn't the best but gives enough.

**In chrome:**
ChromeDev tools (right click Inspect), select the Network tab, type m3u8 in the filter and select Fetch/XHR to help filter it more.
Click Stop recording network log or press Ctrl/cmd + E
Click Clear network log or Ctrl/cmd + L
Click Start recording network log or press Ctrl/cmd + E
Refresh the page.

Wait till you see the master.m3u8 file in the network log
Click it, then to the right you'll see Request URL.
Copy the whole url, not just to the end of .m3u8

I had issues playing the files in Plex when using _"-f bestvideo+bestaudio --merge-output-format mp4"_

**This is the command I use:**

_yt-dlp -S "ext,height:720" --referer "****The base domain url the video is hosted on****" "****The m3u8 Request URL****"_


**This is MAFS episode 31 with the full m3u8 link as an example:**

yt-dlp -S "ext,height:720" --referer "https://www.9now.com.au" "https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/5e78af1b-9ab5-4bde-b9ec-10912859a663/10s/master.m3u8?fastly_token=NjdkZDA2MGVfOTA1NWVmODk1ZDI1ZmUxOTRkMGM4OTdiOWUwNjc3NjBjZTFmYTMxZWM3NmE3ZGY3NTcwNGViZThlYjFjNTFhZQ%3D%3D"

The referer line isn't needed for 9Now but that's the same command I use for websites where the videos are private and harder coded to the website (like educational course websites)

As others have said, you still need ffmpeg installed.

**And MAFS Episode 32 if you can't be bothered**
yt-dlp -S "ext,height:720" --referer "https://www.9now.com.au" "https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/2b2f9893-893b-4202-8d94-6609849f966b/10s/master.m3u8?fastly_token=NjdkYzhlYzBfYTZjZWFjMWM4MDNjOWQ3ZjJmZTRlZjBmZDU1YWZlMDFlNzc2MmYxYTkzZWU4ZmQwYzY2NzRiYjFmNzI3YWM1Mg%3D%3D"

**For those outside of Aus**
If you haven't already noticed, use a VPN server in Aus and it'll download significantly quicker

https://github.com/user-attachments/assets/a2f015b5-91ab-41ee-a4ac-1fc3a80d148a@dirkf: >... I was able to search for the "brightcoveId" ...

Yes, as long as the `episodes` or `clips` JSON isn't split between chunks, this can be done. There are many tens of `brightcoveId`s in (eg) the page for MAFS s12e2, but what we want is the JSON with `items` that list the episodes (or clips if it's a clip page) for the show, specifically the item whose `.link.webUrl` matches the path part of the URL. That JSON is equivalent to what the old extraction was finding. 

<details><summary>Here's a patch that might implement the above idea, but it's quite extensive:</summary>

(Style tweaked for `.encode()`)

```diff
--- old/yt-dlp/yt_dlp/extractor/ninenow.py
+++ new/yt-dlp/yt_dlp/extractor/ninenow.py
@@ -1,34 +1,54 @@
+import re
+
 from .common import InfoExtractor
 from ..utils import (
     ExtractorError,
-    float_or_none,
     int_or_none,
-    smuggle_url,
-    str_or_none,
-    try_get,
+    require,
+    traverse_obj,
     unified_strdate,
     unified_timestamp,
+    url_or_none,
 )
+
+
+AUS_RATINGS = {
+    'P': 0,
+    'C': 7,
+    'G': 0,
+    'PG': 10,
+    'M': 13,
+    'AO': 13,
+    'MA15+': 15,
+    'AV15+': 15,
+    'MAV15+': 15,
+    'R18+': 18,
+}
 
 
 class NineNowIE(InfoExtractor):
     IE_NAME = '9now.com.au'
-    _VALID_URL = r'https?://(?:www\.)?9now\.com\.au/(?:[^/]+/){2}(?P<id>[^/?#]+)'
+    _VALID_URL = r'https?://(?:www\.)?9now\.com\.au(?P<id>/(?:[^/]+/){2}[^/?#]+)'
     _GEO_COUNTRIES = ['AU']
+    _GEO_BYPASS = False
     _TESTS = [{
         # clip
-        'url': 'https://www.9now.com.au/afl-footy-show/2016/clip-ciql02091000g0hp5oktrnytc',
-        'md5': '17cf47d63ec9323e562c9957a968b565',
+        'url': 'https://www.9now.com.au/today/season-2025/clip-cm8hw9h5z00080hquqa5hszq7',
+        'add_ie': ['BrightcoveNew'],
         'info_dict': {
-            'id': '16801',
+            'id': '6370295582112',
             'ext': 'mp4',
-            'title': 'St. Kilda\'s Joey Montagna on the potential for a player\'s strike',
-            'description': 'Is a boycott of the NAB Cup "on the table"?',
+            'title': 'Would Karl Stefanovic be able to land a plane?',
+            'description': 'The Today host\'s skills are put to the test with the latest simulation tech.',
             'uploader_id': '4460760524001',
-            'upload_date': '20160713',
-            'timestamp': 1468421266,
+            'upload_date': '20250320',
+            'timestamp': 1742507988,
+            'duration': 197,
+            'thumbnail': r're:https?://.+/image\.jpg$',
         },
-        'skip': 'Only available in Australia',
+        'params': {
+            'skip_download': 'Only available in Australia',
+        }
     }, {
         # episode
         'url': 'https://www.9now.com.au/afl-footy-show/2016/episode-19',
@@ -40,8 +60,9 @@
     }, {
         # episode of series
         'url': 'https://www.9now.com.au/lego-masters/season-3/episode-3',
+        'add_ie': ['BrightcoveNew'],
         'info_dict': {
-            'id': '6249614030001',
+            'id': '6308830406112',
             'title': 'Episode 3',
             'ext': 'mp4',
             'season_number': 3,
@@ -55,12 +76,44 @@
         'params': {
             'skip_download': True,
         },
+    }, {
+        # episode of series
+        'url': 'https://www.9now.com.au/married-at-first-sight/season-12/episode-2',
+        'add_ie': ['BrightcoveNew'],
+        'info_dict': {
+            'id': '6367870970112',
+            'ext': 'mp4',
+            'title': 'Episode 2',
+            'description': 'The experiment continues as two new couples tie the knot. For some, there\'s instant chemistry; for others, doubts begin to surface, throwing their fairytale day into turmoil.',
+            'timestamp': 1738059300,
+            'upload_date': '20250128',
+            'uploader_id': '4460760524001',
+            'season_number': 12,
+            'episode_number': 2,
+            'series': 'Married at First Sight',
+            'age_limit': 13,
+        },
+        'expected_warnings': ['Ignoring subtitle tracks'],
+        'params': {
+            'skip_download': True,
+        },
     }]
     BRIGHTCOVE_URL_TEMPLATE = 'http://players.brightcove.net/4460760524001/default_default/index.html?videoId=%s'
 
-    def _real_extract(self, url):
-        display_id = self._match_id(url)
-        webpage = self._download_webpage(url, display_id)
+    @staticmethod
+    def _parse_age_limit(s):
+        if isinstance(s, int):
+            return s if 0 <= s <= 21 else None
+        if not isinstance(s, str):
+            return None
+        if s in AUS_RATINGS:
+            return AUS_RATINGS[s]
+        m = re.match(r'^(?P<age>\d{1,2})\+?$', s)
+        if m:
+            return int(m.group('age'))
+        return None
+
+    def _old_extract_common_data(self, webpage, display_id):
         page_data = self._parse_json(self._search_regex(
             r'window\.__data\s*=\s*({.*?});', webpage,
             'page data', default='{}'), display_id, fatal=False)
@@ -77,45 +130,55 @@
             cache = page_data.get(kind, {}).get(f'{kind}Cache', {})
             if not cache:
                 continue
-            common_data = {
+            return {
                 'episode': (cache.get(current_key) or next(iter(cache.values())))[kind],
                 'season': (cache.get(current_key) or next(iter(cache.values()))).get('season', None),
             }
-            break
-        else:
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        webpage = self._download_webpage(url, display_id)
+
+        shows = self._search_json(
+            r'\\\"(?:episode|clip)s\\\":', webpage, 'episode data', display_id,
+            transform_source=lambda s: s.encode().decode('unicode_escape'),
+            default={})
+        common_data = traverse_obj(shows, (
+            'items', lambda _, v: v['link']['webUrl'] == display_id, any, {
+                'episode': None,
+            })) or self._old_extract_common_data(webpage, display_id)
+        if not common_data:
             raise ExtractorError('Unable to find video data')
 
-        if not self.get_param('allow_unplayable_formats') and try_get(common_data, lambda x: x['episode']['video']['drm'], bool):
+        if not self.get_param('allow_unplayable_formats') and traverse_obj(common_data, ('episode', 'video', 'drm')):
             self.report_drm(display_id)
-        brightcove_id = try_get(
-            common_data, lambda x: x['episode']['video']['brightcoveId'], str) or 'ref:{}'.format(common_data['episode']['video']['referenceId'])
-        video_id = str_or_none(try_get(common_data, lambda x: x['episode']['video']['id'])) or brightcove_id
+        video_id = traverse_obj(common_data, ('episode', 'video', {
+            'bc_id': (('brightcoveId',
+                      ('referenceId', {'ref:{}'.format})),
+                      any, {require('brightcove_id')}),
+            'id': ('id', {str}),
+        }))
+        brightcove_id = video_id['bc_id']
+        video_id = video_id.get('id') or brightcove_id
 
-        title = try_get(common_data, lambda x: x['episode']['name'], str)
-        season_number = try_get(common_data, lambda x: x['season']['seasonNumber'], int)
-        episode_number = try_get(common_data, lambda x: x['episode']['episodeNumber'], int)
-        timestamp = unified_timestamp(try_get(common_data, lambda x: x['episode']['airDate'], str))
-        release_date = unified_strdate(try_get(common_data, lambda x: x['episode']['availability'], str))
-        thumbnails_data = try_get(common_data, lambda x: x['episode']['image']['sizes'], dict) or {}
-        thumbnails = [{
-            'id': thumbnail_id,
-            'url': thumbnail_url,
-            'width': int_or_none(thumbnail_id[1:]),
-        } for thumbnail_id, thumbnail_url in thumbnails_data.items()]
-
-        return {
-            '_type': 'url_transparent',
-            'url': smuggle_url(
-                self.BRIGHTCOVE_URL_TEMPLATE % brightcove_id,
-                {'geo_countries': self._GEO_COUNTRIES}),
-            'id': video_id,
-            'title': title,
-            'description': try_get(common_data, lambda x: x['episode']['description'], str),
-            'duration': float_or_none(try_get(common_data, lambda x: x['episode']['video']['duration'], float), 1000),
-            'thumbnails': thumbnails,
-            'ie_key': 'BrightcoveNew',
-            'season_number': season_number,
-            'episode_number': episode_number,
-            'timestamp': timestamp,
-            'release_date': release_date,
-        }
+        return self.url_result(
+            self.BRIGHTCOVE_URL_TEMPLATE % (brightcove_id,),
+            'BrightcoveNew', video_id, url_transparent=True,
+            **traverse_obj(common_data, ('episode', {
+                'title': ('name', {str}),
+                'description': ('description', {str}),
+                'duration': ('video', 'duration', {int_or_none(scale=1000)}),
+                'thumbnails': ('image', 'sizes', {dict.items}, ..., {
+                    'id': 0,
+                    'url': (1, {url_or_none}),
+                    'width': (0, {lambda x: int_or_none(x[1:])}),
+                }, {lambda x: x.get('url') and x}),
+                'episode_number': ('episodeNumber', {int}),
+                'timestamp': ('airDate', {unified_timestamp}),
+                'release_date': ('availability', {unified_strdate}),
+                'season_number': ('partOfSeason', 'name', {lambda s: int(s.split()[-1])}),
+                'series': ('partOfSeries', 'name', {str}),
+                'age_limit': ('classification', {self._parse_age_limit}),
+            })), **traverse_obj(common_data, {
+                'season_number': ('season', 'seasonNumber', {int_or_none}),
+            }))
```

</details>@daveo76: > > ... I was able to search for the "brightcoveId" ...
> 
> Yes, as long as the `episodes` or `clips` JSON isn't split between chunks, this can be done. There are many tens of `brightcoveId`s in (eg) the page for MAFS s12e2, but what we want is the JSON with `items` that list the episodes (or clips if it's a clip page) for the show, specifically the item whose `.link.webUrl` matches the path part of the URL. That JSON is equivalent to what the old extraction was finding.
> Here's a patch that might implement the above idea, but it's quite extensive:

What your patch meant to do and how to use it?@daveo76: /> For those that need it in clearer instructions on how to download while this issue is happening, see the video attached. The quality isn't the best but gives enough.
> 
> **In chrome:** ChromeDev tools (right click Inspect), select the Network tab, type m3u8 in the filter and select Fetch/XHR to help filter it more. Click Stop recording network log or press Ctrl/cmd + E Click Clear network log or Ctrl/cmd + L Click Start recording network log or press Ctrl/cmd + E Refresh the page.
> 
> Wait till you see the master.m3u8 file in the network log Click it, then to the right you'll see Request URL. Copy the whole url, not just to the end of .m3u8
> 
> I had issues playing the files in Plex when using _"-f bestvideo+bestaudio --merge-output-format mp4"_
> 
> **This is the command I use:**
> 
> _yt-dlp -S "ext,height:720" --referer "**The base domain url the video is hosted on**" "**The m3u8 Request URL**"_
> 
> **This is MAFS episode 31 with the full m3u8 link as an example:**
> 
> yt-dlp -S "ext,height:720" --referer "https://www.9now.com.au" "https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/5e78af1b-9ab5-4bde-b9ec-10912859a663/10s/master.m3u8?fastly_token=NjdkZDA2MGVfOTA1NWVmODk1ZDI1ZmUxOTRkMGM4OTdiOWUwNjc3NjBjZTFmYTMxZWM3NmE3ZGY3NTcwNGViZThlYjFjNTFhZQ%3D%3D"
> 
> The referer line isn't needed for 9Now but that's the same command I use for websites where the videos are private and harder coded to the website (like educational course websites)
> 
> As others have said, you still need ffmpeg installed.
> 
> **And MAFS Episode 32 if you can't be bothered** yt-dlp -S "ext,height:720" --referer "https://www.9now.com.au" "https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/2b2f9893-893b-4202-8d94-6609849f966b/10s/master.m3u8?fastly_token=NjdkYzhlYzBfYTZjZWFjMWM4MDNjOWQ3ZjJmZTRlZjBmZDU1YWZlMDFlNzc2MmYxYTkzZWU4ZmQwYzY2NzRiYjFmNzI3YWM1Mg%3D%3D"
> 
> **For those outside of Aus** If you haven't already noticed, use a VPN server in Aus and it'll download significantly quicker
> -9Now.mp4

@JuzzyJayy  Thanks. Your instruction worked for me.@dirkf: >For those outside of Aus
>If you haven't already noticed, use a VPN server in Aus and it'll download significantly quicker

Or actually, at all?

As far as I can see from UK, the show pages and manifests are not geo-blocked (even without XFF, which I disabled in the above patch) whereas the actual media links are blocked, even with XFF. Maybe there are shows with unblocked media (but not the test-cases in the extractor)? Or some territories are throttled rather than blocked.?
@JuzzyJayy: > > For those outside of Aus
> > If you haven't already noticed, use a VPN server in Aus and it'll download significantly quicker
> 
> Or actually, at all?
> 
> As far as I can see from UK, the show pages and manifests are not geo-blocked (even without XFF, which I disabled in the above patch) whereas the actual media links are blocked, even with XFF. Maybe there are shows with unblocked media (but not the test-cases in the extractor)? Or some territories are throttled rather than blocked.?

I'm in Canada and as you said, when on 9now, the media is geo blocked.
However, I can run yt-dlp without a VPN and successfully download when using my command, maybe because the referer line is in there 🤷🏼‍♂️ but it's heavily throttled. Max 250Kbps vs 1.5Mbps when using an Aussie VPN @dirkf: With the test clip from the extractor patch and forcing `Referer: https://www.9now.com.au` (confirmed to be in the HLS download request) to get format `hls-344-4`, Akamai responds with a 302 sending me to a `Sorry, 9Now is only available in Australia` page at https://www.9now.com.au/outside-australia?... with headers including `X-Error-Reason: geo-blocked`.

Restoring the XFF makes no difference.

Is CA an honorary part of Oz now?@mdav43: Alternative - viewing the 9now UI, watch required video. Click on Share. Review the embed code url, add prefix "https://"@FlaminHat: > Alternative - viewing the 9now UI, watch required video. Click on Share. Review the embed code url, add prefix "https://"

Doesn't work@majibow: > > ... I was able to search for the "brightcoveId" ... 
> Here's a patch that might implement the above idea...

@dirkf 
it looks perfect... are you gonna submit the pull request and tag this issue?

Its pretty unlikely they will break the info json across multiple... and if they do we will come back and revise. Until then this looks good to go. @dirkf: Since the page is just the result of the satanic tooling, any technically valid structure could be generated, regardless of common sense. Some show with an enormous number of episodes might reveal problems, but by then (looks down towering WIP/TODO stack) we might have an extractor routine that recombines the new-style Next.js chunks. 

I'll update the upstream extractor but anyone is welcome to pick up the patch and PR it here, the more the merrier (use the patch code under Unlicense).

I'm still confused about the geo-restriction situation, though, but that's obviously a second-order issue.@majibow: > ...any technically valid structure could be generated, regardless of common sense. Some show with an enormous number of episodes...

Truly its fine, its not split randomly one example with a ridiculous number of episodes being:
https://www.9now.com.au/nrl-premiership/season-2024/episode-187 

To which upstream extrator are you referring? @FlaminHat: > > ... I was able to search for the "brightcoveId" ...
> 
> Yes, as long as the `episodes` or `clips` JSON isn't split between chunks, this can be done. There are many tens of `brightcoveId`s in (eg) the page for MAFS s12e2, but what we want is the JSON with `items` that list the episodes (or clips if it's a clip page) for the show, specifically the item whose `.link.webUrl` matches the path part of the URL. That JSON is equivalent to what the old extraction was finding.
> Here's a patch that might implement the above idea, but it's quite extensive:

Tried replacing all my ninenow.py files with the modifications suggested here, but no luck.

Is there a patched ninenow.py I can download, that will fix this problem?@majibow: > I'm still confused about the geo-restriction situation, though, but that's obviously a second-order issue.

Looking at the patch and the existing _brightcove:new_ extractor code
https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/brightcove.py#L853

I see the patch removed `smuggle_url `which would appear to be required to pass the geo info through. Makes no difference for me being in Australia, I have no way of testing whether it works or not, but you have a lead...
@slmingol: Confirming that the suggested workaround by @daveo76 worked for me so we could download Space Invaders' latest ep - https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2742934021.

```
yt-dlp  -S "ext,height:720" --referer https://www.9now.com.au/space-invaders/season-5/episode-7 'https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/d2ca988e-8836-461d-8411-d4c872920905/10s/master.m3u8?fastly_token=NjdkZmU2YzdfNTIxMmY0NzAyN2VmNTYyZGFiZTJjOTI0MTliNGM3ODQyMzVjNzU2N2FlZWIxMDc1OTUwNjU4NjE4NWUwYzQxZQ%3D%3D'
```@daveo76: > Confirming that the suggested workaround by [@daveo76](https://github.com/daveo76) worked for me so we could download Space Invaders' latest ep - [#12591 (comment)](https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2742934021).
> 
> ```
> yt-dlp  -S "ext,height:720" --referer https://www.9now.com.au/space-invaders/season-5/episode-7 'https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/d2ca988e-8836-461d-8411-d4c872920905/10s/master.m3u8?fastly_token=NjdkZmU2YzdfNTIxMmY0NzAyN2VmNTYyZGFiZTJjOTI0MTliNGM3ODQyMzVjNzU2N2FlZWIxMDc1OTUwNjU4NjE4NWUwYzQxZQ%3D%3D'
> ```

That was not me that come up with that. I don't know how to do coding. That was posted by @JuzzyJayy in [#12591](https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2740970464)@dirkf: >I see the patch removed smuggle_url which would appear to be required to pass the geo info through.

XFF (fake regional IP) appeared to make no difference, so I removed it and the smuggling required to pass the geo bypass data to `BrightcoveNew`. But https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2743040447.@FlaminHat: > > Confirming that the suggested workaround by [@daveo76](https://github.com/daveo76) worked for me so we could download Space Invaders' latest ep - [#12591 (comment)](https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2742934021).
> > ```
> > yt-dlp  -S "ext,height:720" --referer https://www.9now.com.au/space-invaders/season-5/episode-7 'https://manifest.prod.boltdns.net/manifest/v1/hls/v5/clear/4460760524001/d2ca988e-8836-461d-8411-d4c872920905/10s/master.m3u8?fastly_token=NjdkZmU2YzdfNTIxMmY0NzAyN2VmNTYyZGFiZTJjOTI0MTliNGM3ODQyMzVjNzU2N2FlZWIxMDc1OTUwNjU4NjE4NWUwYzQxZQ%3D%3D'
> > ```
> 
> That was not me that come up with that. I don't know how to do coding. That was posted by [@JuzzyJayy](https://github.com/JuzzyJayy) in [#12591](https://github.com/yt-dlp/yt-dlp/issues/12591#issuecomment-2740970464)

This workaround works for me. In Firefox, I go to Tools -> Browser Tools -> Web Developer Tools
and in Network sort by domain and look up 🔒manifest....
if I can't find "🔒manifest...." I have to go back one page and go forward back into the video's page. Then I find the "🔒manifest...." URL, then find the master one, and copy the URL after the word "GET"@daveo76: Thanks. It fully working now. Using the lastest master version source@FlaminHat: > Thanks. It fully working now. Using the lastest master version source


Where do I put the new ninenow.py file?

I have tried:
```
/usr/local/Cellar/youtube-dl/2021.12.17/libexec/lib/python3.12/site-packages/youtube_dl/extractor/ninenow.py
/usr/local/Cellar/yt-dlp/2025.3.21/libexec/lib/python3.13/site-packages/yt_dlp/extractor/ninenow.py
/Users/<myusername>/miniconda3/lib/python3.5/site-packages/youtube_dl/extractor/ninenow.py
/Users/<myusername>/miniconda3/lib/python3.5/site-packages/yt_dlp/extractor/ninenow.py
/Users/<myusername>/youtube-dl/youtube_dl/extractor/ninenow.py
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages/youtube_dl/extractor/ninenow.py
/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages/yt_dlp/extractor/ninenow.py
/Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/yt_dlp/extractor/ninenow.py
```
@daveo76: > > Thanks. It fully working now. Using the lastest master version source
> 
> Where do I put the new ninenow.py file?
> 
> I have tried:
> 
> ```
> /usr/local/Cellar/youtube-dl/2021.12.17/libexec/lib/python3.12/site-packages/youtube_dl/extractor/ninenow.py
> /usr/local/Cellar/yt-dlp/2025.3.21/libexec/lib/python3.13/site-packages/yt_dlp/extractor/ninenow.py
> /Users/<myusername>/miniconda3/lib/python3.5/site-packages/youtube_dl/extractor/ninenow.py
> /Users/<myusername>/miniconda3/lib/python3.5/site-packages/yt_dlp/extractor/ninenow.py
> /Users/<myusername>/youtube-dl/youtube_dl/extractor/ninenow.py
> /Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages/youtube_dl/extractor/ninenow.py
> /Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages/yt_dlp/extractor/ninenow.py
> /Library/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages/yt_dlp/extractor/ninenow.py
> ```

No need. Update master source. Then all good@FlaminHat: 
> No need. Update master source. Then all good


Tried `brew upgrade yt-dlp 2025.03.23.163602`

It says `Error: No available formula with the name "2025.03.23.163602".`
@bashonly: @FlaminHat 

```
brew uninstall yt-dlp
brew update && brew install --HEAD yt-dlp
```@FlaminHat: 
Tried uninstalling and reinstalling with brew with --HEAD, tried pip and python3 -m pip, etc.

Still getting the "unable to extract page data" error
@bashonly: show complete verbose output @FlaminHat @derrix060: > Tried uninstalling and reinstalling with brew with --HEAD, tried pip and python3 -m pip, etc.
> 
> Still getting the "unable to extract page data" error

@FlaminHat  I'll show how it is possible to install the latest version


<details><summary>Test current version</summary>
<p>


```console
$ cat $(which yt-dlp)
#!/opt/homebrew/Cellar/yt-dlp/2025.3.21/libexec/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from yt_dlp import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

# Show that it fails
$ yt-dlp https://www.9now.com.au/married-at-first-sight/season-12/episode-31
[9now.com.au] Extracting URL: https://www.9now.com.au/married-at-first-sight/season-12/episode-31
[9now.com.au] episode-31: Downloading webpage
ERROR: [9now.com.au] episode-31: Unable to extract page data; please report this issue on  https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using  yt-dlp -U

```

</p>
</details>

<details><summary>Uninstall: brew uninstall yt-dlp</summary>
<p>


```console
$ brew uninstall yt-dlp
Uninstalling /opt/homebrew/Cellar/yt-dlp/2025.3.21... (765 files, 11.4MB)
```

</p>
</details>


<details><summary>Install latest: brew install --HEAD yt-dlp</summary>
<p>

```
$ brew install --HEAD yt-dlp
==> Downloading https://formulae.brew.sh/api/formula.jws.json
==> Downloading https://formulae.brew.sh/api/cask.jws.json
==> Fetching dependencies for yt-dlp: pandoc and make
==> Fetching pandoc
==> Downloading https://ghcr.io/v2/homebrew/core/pandoc/manifests/3.6.4
Already downloaded: /Users/mario.apra/Library/Caches/Homebrew/downloads/d9d68bff02e22a964ec96b2efa52a537085605f66cd08407bf96e124e6ac5945--pandoc-3.6.4.bottle_manifest.json
==> Downloading https://ghcr.io/v2/homebrew/core/pandoc/blobs/sha256:5f0301201b216abcc0ba4bf6bc39ee6
Already downloaded: /Users/mario.apra/Library/Caches/Homebrew/downloads/3cff23783c7b61eb0afa952fe555e2e40681f18483fcbc5f7b01d3c5a400a6b6--pandoc--3.6.4.arm64_sequoia.bottle.tar.gz
==> Fetching make
==> Downloading https://ghcr.io/v2/homebrew/core/make/manifests/4.4.1-1
Already downloaded: /Users/mario.apra/Library/Caches/Homebrew/downloads/6d80a49585dfd17106c8ea4aaeed2dab30677c00519e06a3547e0bb391cbf704--make-4.4.1-1.bottle_manifest.json
==> Downloading https://ghcr.io/v2/homebrew/core/make/blobs/sha256:f361639a5ec1a9355e12f985c511dd663
Already downloaded: /Users/mario.apra/Library/Caches/Homebrew/downloads/83f1deb0293b80ab17e1bc04da79f171f8b0b381b8c38cd56fff63cd32466483--make--4.4.1.arm64_sequoia.bottle.1.tar.gz
==> Fetching yt-dlp
==> Downloading https://raw.githubusercontent.com/Homebrew/homebrew-core/24f8ef93f2eeb25e1da9d8bf071
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a211559
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/ba/d5/861a7daada160fcf6b0393fb741eeb0d0910b0
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997
############################################################################################# 100.0%
==> Downloading https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a4030
############################################################################################# 100.0%
==> Cloning https://github.com/yt-dlp/yt-dlp.git
Updating /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--git
==> Checking out branch master
Already on 'master'
Your branch is up to date with 'origin/master'.
HEAD is now at 9d5e6de2e [ie/9now.com.au] Fix extractor (#12702)
==> Installing dependencies for yt-dlp: pandoc and make
==> Installing yt-dlp dependency: pandoc
==> Downloading https://ghcr.io/v2/homebrew/core/pandoc/manifests/3.6.4
Already downloaded: /Users/mario.apra/Library/Caches/Homebrew/downloads/d9d68bff02e22a964ec96b2efa52a537085605f66cd08407bf96e124e6ac5945--pandoc-3.6.4.bottle_manifest.json
==> Pouring pandoc--3.6.4.arm64_sequoia.bottle.tar.gz
🍺  /opt/homebrew/Cellar/pandoc/3.6.4: 11 files, 262.7MB
==> Installing yt-dlp dependency: make
==> Downloading https://ghcr.io/v2/homebrew/core/make/manifests/4.4.1-1
Already downloaded: /Users/mario.apra/Library/Caches/Homebrew/downloads/6d80a49585dfd17106c8ea4aaeed2dab30677c00519e06a3547e0bb391cbf704--make-4.4.1-1.bottle_manifest.json
==> Pouring make--4.4.1.arm64_sequoia.bottle.1.tar.gz
🍺  /opt/homebrew/Cellar/make/4.4.1: 17 files, 1.3MB
==> Installing yt-dlp --HEAD
==> gmake lazy-extractors pypi-files
==> python3.13 -m venv --system-site-packages --without-pip /opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> python3.13 -m pip --python=/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python install /
==> Caveats
zsh completions have been installed to:
  /opt/homebrew/share/zsh/site-functions
==> Summary
🍺  /opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de: 1,722 files, 19.2MB, built in 55 seconds
==> Running `brew cleanup yt-dlp`...
Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp_bottle_manifest--2025.3.21... (19.6KB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--2025.3.21... (5.0MB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--brotli--1.1.0.tar.gz... (7MB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--charset-normalizer--3.4.1.tar.gz... (120.3KB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--idna--3.10.tar.gz... (186KB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--mutagen--1.47.0.tar.gz... (1.2MB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--pycryptodomex--3.22.0.tar.gz... (4.7MB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--requests--2.32.3.tar.gz... (128.1KB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--urllib3--2.3.0.tar.gz... (300KB)
Removing: /Users/mario.apra/Library/Caches/Homebrew/yt-dlp--websockets--15.0.1.tar.gz... (172.9KB)
==> Caveats
==> yt-dlp
zsh completions have been installed to:
  /opt/homebrew/share/zsh/site-functions
```

</p>
</details>


## Test latest version

```console
$ cat $(which yt-dlp)
#!/opt/homebrew/Cellar/yt-dlp/HEAD-9d5e6de/libexec/bin/python
...

# Download the episode
$ yt-dlp -f bestvideo+bestaudio https://www.9now.com.au/married-at-first-sight/season-12/episode-31
[9now.com.au] Extracting URL: https://www.9now.com.au/married-at-first-sight/season-12/episode-31
[9now.com.au] episode-31: Downloading webpage
[brightcove:new] Extracting URL: http://players.brightcove.net/4460760524001/default_default/index.html?videoId=6370174971112
[brightcove:new] 6370174971112: Downloading JSON metadata
[brightcove:new] 6370174971112: Downloading m3u8 information
[brightcove:new] 6370174971112: Downloading m3u8 information
[brightcove:new] 6370174971112: Downloading m3u8 information
[brightcove:new] 6370174971112: Downloading m3u8 information
[brightcove:new] 6370174971112: Downloading m3u8 information
[brightcove:new] 6370174971112: Downloading m3u8 information
[brightcove:new] 6370174971112: Downloading MPD manifest
[brightcove:new] 6370174971112: Downloading MPD manifest
[brightcove:new] 6370174971112: Downloading MPD manifest
[brightcove:new] 6370174971112: Downloading MPD manifest
[info] 6370174971112: Downloading 1 format(s): hls-2230-3+dash-303a0cb7-1721-4539-9a9b-88632ae08700-3
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 410
[download] Destination: Episode 31 [6370174971112].fhls-2230-3.mp4

```@FlaminHat: MY MISTAKE

I apologise,

I was using youtube-dl instead of yt-dlp

Please disregard my error reports of the past day.

I apologise for wasting anyone's time, I made a mistake.

I am 
striking through
 the posts I made in error.

I am happy to report this has been resolved. Thank you.

Solution patch:
diff --git a/pyproject.toml b/pyproject.toml
index b5d77d35b..5e987a6fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,8 +55,7 @@ default = [
     "websockets>=13.0",
 ]
 curl-cffi = [
-    "curl-cffi==0.5.10; os_name=='nt' and implementation_name=='cpython'",
-    "curl-cffi>=0.5.10,!=0.6.*,<0.7.2; os_name!='nt' and implementation_name=='cpython'",
+    "curl-cffi>=0.5.10,!=0.6.*,!=0.7.*,!=0.8.*,!=0.9.*,<0.11; implementation_name=='cpython'",
 ]
 secretstorage = [
     "cffi",
diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py
index 6c0b12e66..63e6e11b2 100644
--- a/yt_dlp/YoutubeDL.py
+++ b/yt_dlp/YoutubeDL.py
@@ -4152,7 +4152,7 @@ def _get_available_impersonate_targets(self):
             (target, rh.RH_NAME)
             for rh in self._request_director.handlers.values()
             if isinstance(rh, ImpersonateRequestHandler)
-            for target in rh.supported_targets
+            for target in reversed(rh.supported_targets)
         ]
 
     def _impersonate_target_available(self, target):
diff --git a/yt_dlp/__init__.py b/yt_dlp/__init__.py
index 7d8f10047..714d9ad5c 100644
--- a/yt_dlp/__init__.py
+++ b/yt_dlp/__init__.py
@@ -1021,8 +1021,9 @@ def _real_main(argv=None):
                 # List of simplified targets we know are supported,
                 # to help users know what dependencies may be required.
                 (ImpersonateTarget('chrome'), 'curl_cffi'),
-                (ImpersonateTarget('edge'), 'curl_cffi'),
                 (ImpersonateTarget('safari'), 'curl_cffi'),
+                (ImpersonateTarget('firefox'), 'curl_cffi>=0.10'),
+                (ImpersonateTarget('edge'), 'curl_cffi'),
             ]
 
             available_targets = ydl._get_available_impersonate_targets()
@@ -1038,12 +1039,12 @@ def make_row(target, handler):
 
             for known_target, known_handler in known_targets:
                 if not any(
-                    known_target in target and handler == known_handler
+                    known_target in target and known_handler.startswith(handler)
                     for target, handler in available_targets
                 ):
-                    rows.append([
+                    rows.insert(0, [
                         ydl._format_out(text, ydl.Styles.SUPPRESS)
-                        for text in make_row(known_target, f'{known_handler} (not available)')
+                        for text in make_row(known_target, f'{known_handler} (unavailable)')
                     ])
 
             ydl.to_screen('[info] Available impersonate targets')
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 38e9673cd..c56ec9df6 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -496,10 +496,6 @@
 from .daystar import DaystarClipIE
 from .dbtv import DBTVIE
 from .dctp import DctpTvIE
-from .deezer import (
-    DeezerAlbumIE,
-    DeezerPlaylistIE,
-)
 from .democracynow import DemocracynowIE
 from .detik import DetikEmbedIE
 from .deuxm import (
@@ -2352,10 +2348,6 @@
     ViewLiftIE,
 )
 from .viidea import ViideaIE
-from .viki import (
-    VikiChannelIE,
-    VikiIE,
-)
 from .vimeo import (
     VHXEmbedIE,
     VimeoAlbumIE,
diff --git a/yt_dlp/extractor/deezer.py b/yt_dlp/extractor/deezer.py
deleted file mode 100644
index 2ca8be5ca..000000000
--- a/yt_dlp/extractor/deezer.py
+++ /dev/null
@@ -1,142 +0,0 @@
-import json
-
-from .common import InfoExtractor
-from ..utils import (
-    ExtractorError,
-    int_or_none,
-    orderedSet,
-)
-
-
-class DeezerBaseInfoExtractor(InfoExtractor):
-    def get_data(self, url):
-        if not self.get_param('test'):
-            self.report_warning('For now, this extractor only supports the 30 second previews. Patches welcome!')
-
-        mobj = self._match_valid_url(url)
-        data_id = mobj.group('id')
-
-        webpage = self._download_webpage(url, data_id)
-        geoblocking_msg = self._html_search_regex(
-            r'<p class="soon-txt">(.*?)</p>', webpage, 'geoblocking message',
-            default=None)
-        if geoblocking_msg is not None:
-            raise ExtractorError(
-                f'Deezer said: {geoblocking_msg}', expected=True)
-
-        data_json = self._search_regex(
-            (r'__DZR_APP_STATE__\s*=\s*({.+?})\s*</script>',
-             r'naboo\.display\(\'[^\']+\',\s*(.*?)\);\n'),
-            webpage, 'data JSON')
-        data = json.loads(data_json)
-        return data_id, webpage, data
-
-
-class DeezerPlaylistIE(DeezerBaseInfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?deezer\.com/(../)?playlist/(?P<id>[0-9]+)'
-    _TEST = {
-        'url': 'http://www.deezer.com/playlist/176747451',
-        'info_dict': {
-            'id': '176747451',
-            'title': 'Best!',
-            'uploader': 'anonymous',
-            'thumbnail': r're:^https?://(e-)?cdns-images\.dzcdn\.net/images/cover/.*\.jpg$',
-        },
-        'playlist_count': 29,
-    }
-
-    def _real_extract(self, url):
-        playlist_id, webpage, data = self.get_data(url)
-
-        playlist_title = data.get('DATA', {}).get('TITLE')
-        playlist_uploader = data.get('DATA', {}).get('PARENT_USERNAME')
-        playlist_thumbnail = self._search_regex(
-            r'<img id="naboo_playlist_image".*?src="([^"]+)"', webpage,
-            'playlist thumbnail')
-
-        entries = []
-        for s in data.get('SONGS', {}).get('data'):
-            formats = [{
-                'format_id': 'preview',
-                'url': s.get('MEDIA', [{}])[0].get('HREF'),
-                'preference': -100,  # Only the first 30 seconds
-                'ext': 'mp3',
-            }]
-            artists = ', '.join(
-                orderedSet(a.get('ART_NAME') for a in s.get('ARTISTS')))
-            entries.append({
-                'id': s.get('SNG_ID'),
-                'duration': int_or_none(s.get('DURATION')),
-                'title': '{} - {}'.format(artists, s.get('SNG_TITLE')),
-                'uploader': s.get('ART_NAME'),
-                'uploader_id': s.get('ART_ID'),
-                'age_limit': 16 if s.get('EXPLICIT_LYRICS') == '1' else 0,
-                'formats': formats,
-            })
-
-        return {
-            '_type': 'playlist',
-            'id': playlist_id,
-            'title': playlist_title,
-            'uploader': playlist_uploader,
-            'thumbnail': playlist_thumbnail,
-            'entries': entries,
-        }
-
-
-class DeezerAlbumIE(DeezerBaseInfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?deezer\.com/(../)?album/(?P<id>[0-9]+)'
-    _TEST = {
-        'url': 'https://www.deezer.com/fr/album/67505622',
-        'info_dict': {
-            'id': '67505622',
-            'title': 'Last Week',
-            'uploader': 'Home Brew',
-            'thumbnail': r're:^https?://(e-)?cdns-images\.dzcdn\.net/images/cover/.*\.jpg$',
-        },
-        'playlist_count': 7,
-    }
-
-    def _real_extract(self, url):
-        album_id, webpage, data = self.get_data(url)
-
-        album_title = data.get('DATA', {}).get('ALB_TITLE')
-        album_uploader = data.get('DATA', {}).get('ART_NAME')
-        album_thumbnail = self._search_regex(
-            r'<img id="naboo_album_image".*?src="([^"]+)"', webpage,
-            'album thumbnail')
-
-        entries = []
-        for s in data.get('SONGS', {}).get('data'):
-            formats = [{
-                'format_id': 'preview',
-                'url': s.get('MEDIA', [{}])[0].get('HREF'),
-                'preference': -100,  # Only the first 30 seconds
-                'ext': 'mp3',
-            }]
-            artists = ', '.join(
-                orderedSet(a.get('ART_NAME') for a in s.get('ARTISTS')))
-            entries.append({
-                'id': s.get('SNG_ID'),
-                'duration': int_or_none(s.get('DURATION')),
-                'title': '{} - {}'.format(artists, s.get('SNG_TITLE')),
-                'uploader': s.get('ART_NAME'),
-                'uploader_id': s.get('ART_ID'),
-                'age_limit': 16 if s.get('EXPLICIT_LYRICS') == '1' else 0,
-                'formats': formats,
-                'track': s.get('SNG_TITLE'),
-                'track_number': int_or_none(s.get('TRACK_NUMBER')),
-                'track_id': s.get('SNG_ID'),
-                'artist': album_uploader,
-                'album': album_title,
-                'album_artist': album_uploader,
-            })
-
-        return {
-            '_type': 'playlist',
-            'id': album_id,
-            'title': album_title,
-            'uploader': album_uploader,
-            'thumbnail': album_thumbnail,
-            'entries': entries,
-        }
diff --git a/yt_dlp/extractor/ninenow.py b/yt_dlp/extractor/ninenow.py
index f17531e62..7b0cb77a7 100644
--- a/yt_dlp/extractor/ninenow.py
+++ b/yt_dlp/extractor/ninenow.py
@@ -1,34 +1,46 @@
+import json
+import re
+
+from .brightcove import BrightcoveNewIE
 from .common import InfoExtractor
 from ..utils import (
-    ExtractorError,
     float_or_none,
     int_or_none,
-    smuggle_url,
+    parse_iso8601,
+    parse_resolution,
     str_or_none,
-    try_get,
-    unified_strdate,
-    unified_timestamp,
+    url_or_none,
 )
+from ..utils.traversal import require, traverse_obj, value
 
 
 class NineNowIE(InfoExtractor):
     IE_NAME = '9now.com.au'
-    _VALID_URL = r'https?://(?:www\.)?9now\.com\.au/(?:[^/]+/){2}(?P<id>[^/?#]+)'
-    _GEO_COUNTRIES = ['AU']
+    _VALID_URL = r'https?://(?:www\.)?9now\.com\.au/(?:[^/?#]+/){2}(?P<id>(?P<type>clip|episode)-[^/?#]+)'
+    _GEO_BYPASS = False
     _TESTS = [{
         # clip
-        'url': 'https://www.9now.com.au/afl-footy-show/2016/clip-ciql02091000g0hp5oktrnytc',
-        'md5': '17cf47d63ec9323e562c9957a968b565',
+        'url': 'https://www.9now.com.au/today/season-2025/clip-cm8hw9h5z00080hquqa5hszq7',
         'info_dict': {
-            'id': '16801',
+            'id': '6370295582112',
             'ext': 'mp4',
-            'title': 'St. Kilda\'s Joey Montagna on the potential for a player\'s strike',
-            'description': 'Is a boycott of the NAB Cup "on the table"?',
+            'title': 'Would Karl Stefanovic be able to land a plane?',
+            'description': 'The Today host\'s skills are put to the test with the latest simulation tech.',
             'uploader_id': '4460760524001',
-            'upload_date': '20160713',
-            'timestamp': 1468421266,
+            'duration': 197.376,
+            'tags': ['flights', 'technology', 'Karl Stefanovic'],
+            'season': 'Season 2025',
+            'season_number': 2025,
+            'series': 'TODAY',
+            'timestamp': 1742507988,
+            'upload_date': '20250320',
+            'release_timestamp': 1742507983,
+            'release_date': '20250320',
+            'thumbnail': r're:https?://.+/1920x0/.+\.jpg',
+        },
+        'params': {
+            'skip_download': 'HLS/DASH fragments and mp4 URLs are geo-restricted; only available in AU',
         },
-        'skip': 'Only available in Australia',
     }, {
         # episode
         'url': 'https://www.9now.com.au/afl-footy-show/2016/episode-19',
@@ -41,7 +53,7 @@ class NineNowIE(InfoExtractor):
         # episode of series
         'url': 'https://www.9now.com.au/lego-masters/season-3/episode-3',
         'info_dict': {
-            'id': '6249614030001',
+            'id': '6308830406112',
             'title': 'Episode 3',
             'ext': 'mp4',
             'season_number': 3,
@@ -50,72 +62,87 @@ class NineNowIE(InfoExtractor):
             'uploader_id': '4460760524001',
             'timestamp': 1619002200,
             'upload_date': '20210421',
+            'duration': 3574.085,
+            'thumbnail': r're:https?://.+/1920x0/.+\.jpg',
+            'tags': ['episode'],
+            'series': 'Lego Masters',
+            'season': 'Season 3',
+            'episode': 'Episode 3',
+            'release_timestamp': 1619002200,
+            'release_date': '20210421',
+        },
+        'params': {
+            'skip_download': 'HLS/DASH fragments and mp4 URLs are geo-restricted; only available in AU',
+        },
+    }, {
+        'url': 'https://www.9now.com.au/married-at-first-sight/season-12/episode-1',
+        'info_dict': {
+            'id': '6367798770112',
+            'ext': 'mp4',
+            'title': 'Episode 1',
+            'description': r're:The cultural sensation of Married At First Sight returns with our first weddings! .{90}$',
+            'uploader_id': '4460760524001',
+            'duration': 5415.079,
+            'thumbnail': r're:https?://.+/1920x0/.+\.png',
+            'tags': ['episode'],
+            'season': 'Season 12',
+            'season_number': 12,
+            'episode': 'Episode 1',
+            'episode_number': 1,
+            'series': 'Married at First Sight',
+            'timestamp': 1737973800,
+            'upload_date': '20250127',
+            'release_timestamp': 1737973800,
+            'release_date': '20250127',
         },
-        'expected_warnings': ['Ignoring subtitle tracks'],
         'params': {
-            'skip_download': True,
+            'skip_download': 'HLS/DASH fragments and mp4 URLs are geo-restricted; only available in AU',
         },
     }]
-    BRIGHTCOVE_URL_TEMPLATE = 'http://players.brightcove.net/4460760524001/default_default/index.html?videoId=%s'
+    BRIGHTCOVE_URL_TEMPLATE = 'http://players.brightcove.net/4460760524001/default_default/index.html?videoId={}'
+
+    # XXX: For parsing next.js v15+ data; see also yt_dlp.extractor.francetv and yt_dlp.extractor.goplay
+    def _find_json(self, s):
+        return self._search_json(
+            r'\w+\s*:\s*', s, 'next js data', None, contains_pattern=r'\[(?s:.+)\]', default=None)
 
     def _real_extract(self, url):
-        display_id = self._match_id(url)
+        display_id, video_type = self._match_valid_url(url).group('id', 'type')
         webpage = self._download_webpage(url, display_id)
-        page_data = self._parse_json(self._search_regex(
-            r'window\.__data\s*=\s*({.*?});', webpage,
-            'page data', default='{}'), display_id, fatal=False)
-        if not page_data:
-            page_data = self._parse_json(self._parse_json(self._search_regex(
-                r'window\.__data\s*=\s*JSON\.parse\s*\(\s*(".+?")\s*\)\s*;',
-                webpage, 'page data'), display_id), display_id)
 
-        for kind in ('episode', 'clip'):
-            current_key = page_data.get(kind, {}).get(
-                f'current{kind.capitalize()}Key')
-            if not current_key:
-                continue
-            cache = page_data.get(kind, {}).get(f'{kind}Cache', {})
-            if not cache:
-                continue
-            common_data = {
-                'episode': (cache.get(current_key) or next(iter(cache.values())))[kind],
-                'season': (cache.get(current_key) or next(iter(cache.values()))).get('season', None),
-            }
-            break
-        else:
-            raise ExtractorError('Unable to find video data')
+        common_data = traverse_obj(
+            re.findall(r'<script[^>]*>\s*self\.__next_f\.push\(\s*(\[.+?\])\s*\);?\s*</script>', webpage),
+            (..., {json.loads}, ..., {self._find_json},
+             lambda _, v: v['payload'][video_type]['slug'] == display_id,
+             'payload', any, {require('video data')}))
 
-        if not self.get_param('allow_unplayable_formats') and try_get(common_data, lambda x: x['episode']['video']['drm'], bool):
+        if traverse_obj(common_data, (video_type, 'video', 'drm', {bool})):
             self.report_drm(display_id)
-        brightcove_id = try_get(
-            common_data, lambda x: x['episode']['video']['brightcoveId'], str) or 'ref:{}'.format(common_data['episode']['video']['referenceId'])
-        video_id = str_or_none(try_get(common_data, lambda x: x['episode']['video']['id'])) or brightcove_id
-
-        title = try_get(common_data, lambda x: x['episode']['name'], str)
-        season_number = try_get(common_data, lambda x: x['season']['seasonNumber'], int)
-        episode_number = try_get(common_data, lambda x: x['episode']['episodeNumber'], int)
-        timestamp = unified_timestamp(try_get(common_data, lambda x: x['episode']['airDate'], str))
-        release_date = unified_strdate(try_get(common_data, lambda x: x['episode']['availability'], str))
-        thumbnails_data = try_get(common_data, lambda x: x['episode']['image']['sizes'], dict) or {}
-        thumbnails = [{
-            'id': thumbnail_id,
-            'url': thumbnail_url,
-            'width': int_or_none(thumbnail_id[1:]),
-        } for thumbnail_id, thumbnail_url in thumbnails_data.items()]
+        brightcove_id = traverse_obj(common_data, (
+            video_type, 'video', (
+                ('brightcoveId', {str}),
+                ('referenceId', {str}, {lambda x: f'ref:{x}' if x else None}),
+            ), any, {require('brightcove ID')}))
 
         return {
             '_type': 'url_transparent',
-            'url': smuggle_url(
-                self.BRIGHTCOVE_URL_TEMPLATE % brightcove_id,
-                {'geo_countries': self._GEO_COUNTRIES}),
-            'id': video_id,
-            'title': title,
-            'description': try_get(common_data, lambda x: x['episode']['description'], str),
-            'duration': float_or_none(try_get(common_data, lambda x: x['episode']['video']['duration'], float), 1000),
-            'thumbnails': thumbnails,
-            'ie_key': 'BrightcoveNew',
-            'season_number': season_number,
-            'episode_number': episode_number,
-            'timestamp': timestamp,
-            'release_date': release_date,
+            'ie_key': BrightcoveNewIE.ie_key(),
+            'url': self.BRIGHTCOVE_URL_TEMPLATE.format(brightcove_id),
+            **traverse_obj(common_data, {
+                'id': (video_type, 'video', 'id', {int}, ({str_or_none}, {value(brightcove_id)}), any),
+                'title': (video_type, 'name', {str}),
+                'description': (video_type, 'description', {str}),
+                'duration': (video_type, 'video', 'duration', {float_or_none(scale=1000)}),
+                'tags': (video_type, 'tags', ..., 'name', {str}, all, filter),
+                'series': ('tvSeries', 'name', {str}),
+                'season_number': ('season', 'seasonNumber', {int_or_none}),
+                'episode_number': ('episode', 'episodeNumber', {int_or_none}),
+                'timestamp': ('episode', 'airDate', {parse_iso8601}),
+                'release_timestamp': (video_type, 'availability', {parse_iso8601}),
+                'thumbnails': (video_type, 'image', 'sizes', {dict.items}, lambda _, v: url_or_none(v[1]), {
+                    'id': 0,
+                    'url': 1,
+                    'width': (1, {parse_resolution}, 'width'),
+                }),
+            }),
         }
diff --git a/yt_dlp/extractor/unsupported.py b/yt_dlp/extractor/unsupported.py
index e8d6ae128..31393b02a 100644
--- a/yt_dlp/extractor/unsupported.py
+++ b/yt_dlp/extractor/unsupported.py
@@ -51,6 +51,8 @@ class KnownDRMIE(UnsupportedInfoExtractor):
         r'(?:watch|front)\.njpwworld\.com',
         r'qub\.ca/vrai',
         r'(?:beta\.)?crunchyroll\.com',
+        r'viki\.com',
+        r'deezer\.com',
     )
 
     _TESTS = [{
@@ -160,6 +162,12 @@ class KnownDRMIE(UnsupportedInfoExtractor):
     }, {
         'url': 'https://beta.crunchyroll.com/pt-br/watch/G8WUN8VKP/the-ruler-of-conspiracy',
         'only_matching': True,
+    }, {
+        'url': 'https://www.viki.com/videos/1175236v-choosing-spouse-by-lottery-episode-1',
+        'only_matching': True,
+    }, {
+        'url': 'http://www.deezer.com/playlist/176747451',
+        'only_matching': True,
     }]
 
     def _real_extract(self, url):
diff --git a/yt_dlp/extractor/viki.py b/yt_dlp/extractor/viki.py
deleted file mode 100644
index 75f9cdf2f..000000000
--- a/yt_dlp/extractor/viki.py
+++ /dev/null
@@ -1,346 +0,0 @@
-import hashlib
-import hmac
-import json
-import time
-
-from .common import InfoExtractor
-from ..utils import (
-    ExtractorError,
-    int_or_none,
-    parse_age_limit,
-    parse_iso8601,
-    try_get,
-)
-
-
-class VikiBaseIE(InfoExtractor):
-    _VALID_URL_BASE = r'https?://(?:www\.)?viki\.(?:com|net|mx|jp|fr)/'
-    _API_URL_TEMPLATE = 'https://api.viki.io%s'
-
-    _DEVICE_ID = '112395910d'
-    _APP = '100005a'
-    _APP_VERSION = '6.11.3'
-    _APP_SECRET = 'd96704b180208dbb2efa30fe44c48bd8690441af9f567ba8fd710a72badc85198f7472'
-
-    _GEO_BYPASS = False
-    _NETRC_MACHINE = 'viki'
-
-    _token = None
-
-    _ERRORS = {
-        'geo': 'Sorry, this content is not available in your region.',
-        'upcoming': 'Sorry, this content is not yet available.',
-        'paywall': 'Sorry, this content is only available to Viki Pass Plus subscribers',
-    }
-
-    def _stream_headers(self, timestamp, sig):
-        return {
-            'X-Viki-manufacturer': 'vivo',
-            'X-Viki-device-model': 'vivo 1606',
-            'X-Viki-device-os-ver': '6.0.1',
-            'X-Viki-connection-type': 'WIFI',
-            'X-Viki-carrier': '',
-            'X-Viki-as-id': '100005a-1625321982-3932',
-            'timestamp': str(timestamp),
-            'signature': str(sig),
-            'x-viki-app-ver': self._APP_VERSION,
-        }
-
-    def _api_query(self, path, version=4, **kwargs):
-        path += '?' if '?' not in path else '&'
-        query = f'/v{version}/{path}app={self._APP}'
-        if self._token:
-            query += f'&token={self._token}'
-        return query + ''.join(f'&{name}={val}' for name, val in kwargs.items())
-
-    def _sign_query(self, path):
-        timestamp = int(time.time())
-        query = self._api_query(path, version=5)
-        sig = hmac.new(
-            self._APP_SECRET.encode('ascii'), f'{query}&t={timestamp}'.encode('ascii'), hashlib.sha1).hexdigest()
-        return timestamp, sig, self._API_URL_TEMPLATE % query
-
-    def _call_api(
-            self, path, video_id, note='Downloading JSON metadata', data=None, query=None, fatal=True):
-        if query is None:
-            timestamp, sig, url = self._sign_query(path)
-        else:
-            url = self._API_URL_TEMPLATE % self._api_query(path, version=4)
-        resp = self._download_json(
-            url, video_id, note, fatal=fatal, query=query,
-            data=json.dumps(data).encode() if data else None,
-            headers=({'x-viki-app-ver': self._APP_VERSION} if data
-                     else self._stream_headers(timestamp, sig) if query is None
-                     else None), expected_status=400) or {}
-
-        self._raise_error(resp.get('error'), fatal)
-        return resp
-
-    def _raise_error(self, error, fatal=True):
-        if error is None:
-            return
-        msg = f'{self.IE_NAME} said: {error}'
-        if fatal:
-            raise ExtractorError(msg, expected=True)
-        else:
-            self.report_warning(msg)
-
-    def _check_errors(self, data):
-        for reason, status in (data.get('blocking') or {}).items():
-            if status and reason in self._ERRORS:
-                message = self._ERRORS[reason]
-                if reason == 'geo':
-                    self.raise_geo_restricted(msg=message)
-                elif reason == 'paywall':
-                    if try_get(data, lambda x: x['paywallable']['tvod']):
-                        self._raise_error('This video is for rent only or TVOD (Transactional Video On demand)')
-                    self.raise_login_required(message)
-                self._raise_error(message)
-
-    def _perform_login(self, username, password):
-        self._token = self._call_api(
-            'sessions.json', None, 'Logging in', fatal=False,
-            data={'username': username, 'password': password}).get('token')
-        if not self._token:
-            self.report_warning('Login Failed: Unable to get session token')
-
-    @staticmethod
-    def dict_selection(dict_obj, preferred_key):
-        if preferred_key in dict_obj:
-            return dict_obj[preferred_key]
-        return (list(filter(None, dict_obj.values())) or [None])[0]
-
-
-class VikiIE(VikiBaseIE):
-    IE_NAME = 'viki'
-    _VALID_URL = rf'{VikiBaseIE._VALID_URL_BASE}(?:videos|player)/(?P<id>[0-9]+v)'
-    _TESTS = [{
-        'note': 'Free non-DRM video with storyboards in MPD',
-        'url': 'https://www.viki.com/videos/1175236v-choosing-spouse-by-lottery-episode-1',
-        'info_dict': {
-            'id': '1175236v',
-            'ext': 'mp4',
-            'title': 'Choosing Spouse by Lottery - Episode 1',
-            'timestamp': 1606463239,
-            'age_limit': 13,
-            'uploader': 'FCC',
-            'upload_date': '20201127',
-        },
-    }, {
-        'url': 'http://www.viki.com/videos/1023585v-heirs-episode-14',
-        'info_dict': {
-            'id': '1023585v',
-            'ext': 'mp4',
-            'title': 'Heirs - Episode 14',
-            'uploader': 'SBS Contents Hub',
-            'timestamp': 1385047627,
-            'upload_date': '20131121',
-            'age_limit': 13,
-            'duration': 3570,
-            'episode_number': 14,
-        },
-        'skip': 'Blocked in the US',
-    }, {
-        # clip
-        'url': 'http://www.viki.com/videos/1067139v-the-avengers-age-of-ultron-press-conference',
-        'md5': '86c0b5dbd4d83a6611a79987cc7a1989',
-        'info_dict': {
-            'id': '1067139v',
-            'ext': 'mp4',
-            'title': "'The Avengers: Age of Ultron' Press Conference",
-            'description': 'md5:d70b2f9428f5488321bfe1db10d612ea',
-            'duration': 352,
-            'timestamp': 1430380829,
-            'upload_date': '20150430',
-            'uploader': 'Arirang TV',
-            'like_count': int,
-            'age_limit': 0,
-        },
-        'skip': 'Sorry. There was an error loading this video',
-    }, {
-        'url': 'http://www.viki.com/videos/1048879v-ankhon-dekhi',
-        'info_dict': {
-            'id': '1048879v',
-            'ext': 'mp4',
-            'title': 'Ankhon Dekhi',
-            'duration': 6512,
-            'timestamp': 1408532356,
-            'upload_date': '20140820',
-            'uploader': 'Spuul',
-            'like_count': int,
-            'age_limit': 13,
-        },
-        'skip': 'Blocked in the US',
-    }, {
-        # episode
-        'url': 'http://www.viki.com/videos/44699v-boys-over-flowers-episode-1',
-        'md5': '0a53dc252e6e690feccd756861495a8c',
-        'info_dict': {
-            'id': '44699v',
-            'ext': 'mp4',
-            'title': 'Boys Over Flowers - Episode 1',
-            'description': 'md5:b89cf50038b480b88b5b3c93589a9076',
-            'duration': 4172,
-            'timestamp': 1270496524,
-            'upload_date': '20100405',
-            'uploader': 'group8',
-            'like_count': int,
-            'age_limit': 13,
-            'episode_number': 1,
-        },
-    }, {
-        # youtube external
-        'url': 'http://www.viki.com/videos/50562v-poor-nastya-complete-episode-1',
-        'md5': '63f8600c1da6f01b7640eee7eca4f1da',
-        'info_dict': {
-            'id': '50562v',
-            'ext': 'webm',
-            'title': 'Poor Nastya [COMPLETE] - Episode 1',
-            'description': '',
-            'duration': 606,
-            'timestamp': 1274949505,
-            'upload_date': '20101213',
-            'uploader': 'ad14065n',
-            'uploader_id': 'ad14065n',
-            'like_count': int,
-            'age_limit': 13,
-        },
-        'skip': 'Page not found!',
-    }, {
-        'url': 'http://www.viki.com/player/44699v',
-        'only_matching': True,
-    }, {
-        # non-English description
-        'url': 'http://www.viki.com/videos/158036v-love-in-magic',
-        'md5': '41faaba0de90483fb4848952af7c7d0d',
-        'info_dict': {
-            'id': '158036v',
-            'ext': 'mp4',
-            'uploader': 'I Planet Entertainment',
-            'upload_date': '20111122',
-            'timestamp': 1321985454,
-            'description': 'md5:44b1e46619df3a072294645c770cef36',
-            'title': 'Love In Magic',
-            'age_limit': 13,
-        },
-    }]
-
-    def _real_extract(self, url):
-        video_id = self._match_id(url)
-        video = self._call_api(f'videos/{video_id}.json', video_id, 'Downloading video JSON', query={})
-        self._check_errors(video)
-
-        title = try_get(video, lambda x: x['titles']['en'], str)
-        episode_number = int_or_none(video.get('number'))
-        if not title:
-            title = f'Episode {episode_number}' if video.get('type') == 'episode' else video.get('id') or video_id
-            container_titles = try_get(video, lambda x: x['container']['titles'], dict) or {}
-            container_title = self.dict_selection(container_titles, 'en')
-            title = f'{container_title} - {title}'
-
-        thumbnails = [{
-            'id': thumbnail_id,
-            'url': thumbnail['url'],
-        } for thumbnail_id, thumbnail in (video.get('images') or {}).items() if thumbnail.get('url')]
-
-        resp = self._call_api(
-            f'playback_streams/{video_id}.json?drms=dt3&device_id={self._DEVICE_ID}',
-            video_id, 'Downloading video streams JSON')['main'][0]
-
-        stream_id = try_get(resp, lambda x: x['properties']['track']['stream_id'])
-        subtitles = dict((lang, [{
-            'ext': ext,
-            'url': self._API_URL_TEMPLATE % self._api_query(
-                f'videos/{video_id}/auth_subtitles/{lang}.{ext}', stream_id=stream_id),
-        } for ext in ('srt', 'vtt')]) for lang in (video.get('subtitle_completions') or {}))
-
-        mpd_url = resp['url']
-        # 720p is hidden in another MPD which can be found in the current manifest content
-        mpd_content = self._download_webpage(mpd_url, video_id, note='Downloading initial MPD manifest')
-        mpd_url = self._search_regex(
-            r'(?mi)<BaseURL>(http.+.mpd)', mpd_content, 'new manifest', default=mpd_url)
-        if 'mpdhd_high' not in mpd_url and 'sig=' not in mpd_url:
-            # Modify the URL to get 1080p
-            mpd_url = mpd_url.replace('mpdhd', 'mpdhd_high')
-        formats = self._extract_mpd_formats(mpd_url, video_id)
-
-        return {
-            'id': video_id,
-            'formats': formats,
-            'title': title,
-            'description': self.dict_selection(video.get('descriptions', {}), 'en'),
-            'duration': int_or_none(video.get('duration')),
-            'timestamp': parse_iso8601(video.get('created_at')),
-            'uploader': video.get('author'),
-            'uploader_url': video.get('author_url'),
-            'like_count': int_or_none(try_get(video, lambda x: x['likes']['count'])),
-            'age_limit': parse_age_limit(video.get('rating')),
-            'thumbnails': thumbnails,
-            'subtitles': subtitles,
-            'episode_number': episode_number,
-        }
-
-
-class VikiChannelIE(VikiBaseIE):
-    IE_NAME = 'viki:channel'
-    _VALID_URL = rf'{VikiBaseIE._VALID_URL_BASE}(?:tv|news|movies|artists)/(?P<id>[0-9]+c)'
-    _TESTS = [{
-        'url': 'http://www.viki.com/tv/50c-boys-over-flowers',
-        'info_dict': {
-            'id': '50c',
-            'title': 'Boys Over Flowers',
-            'description': 'md5:804ce6e7837e1fd527ad2f25420f4d59',
-        },
-        'playlist_mincount': 51,
-    }, {
-        'url': 'http://www.viki.com/tv/1354c-poor-nastya-complete',
-        'info_dict': {
-            'id': '1354c',
-            'title': 'Poor Nastya [COMPLETE]',
-            'description': 'md5:05bf5471385aa8b21c18ad450e350525',
-        },
-        'playlist_count': 127,
-        'skip': 'Page not found',
-    }, {
-        'url': 'http://www.viki.com/news/24569c-showbiz-korea',
-        'only_matching': True,
-    }, {
-        'url': 'http://www.viki.com/movies/22047c-pride-and-prejudice-2005',
-        'only_matching': True,
-    }, {
-        'url': 'http://www.viki.com/artists/2141c-shinee',
-        'only_matching': True,
-    }]
-
-    _video_types = ('episodes', 'movies', 'clips', 'trailers')
-
-    def _entries(self, channel_id):
-        params = {
-            'app': self._APP, 'token': self._token, 'only_ids': 'true',
-            'direction': 'asc', 'sort': 'number', 'per_page': 30,
-        }
-        video_types = self._configuration_arg('video_types') or self._video_types
-        for video_type in video_types:
-            if video_type not in self._video_types:
-                self.report_warning(f'Unknown video_type: {video_type}')
-            page_num = 0
-            while True:
-                page_num += 1
-                params['page'] = page_num
-                res = self._call_api(
-                    f'containers/{channel_id}/{video_type}.json', channel_id, query=params, fatal=False,
-                    note=f'Downloading {video_type.title()} JSON page {page_num}')
-
-                for video_id in res.get('response') or []:
-                    yield self.url_result(f'https://www.viki.com/videos/{video_id}', VikiIE.ie_key(), video_id)
-                if not res.get('more'):
-                    break
-
-    def _real_extract(self, url):
-        channel_id = self._match_id(url)
-        channel = self._call_api(f'containers/{channel_id}.json', channel_id, 'Downloading channel JSON')
-        self._check_errors(channel)
-        return self.playlist_result(
-            self._entries(channel_id), channel_id,
-            self.dict_selection(channel['titles'], 'en'),
-            self.dict_selection(channel['descriptions'], 'en'))
diff --git a/yt_dlp/networking/_curlcffi.py b/yt_dlp/networking/_curlcffi.py
index 0643348e7..c800f2c09 100644
--- a/yt_dlp/networking/_curlcffi.py
+++ b/yt_dlp/networking/_curlcffi.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import io
+import itertools
 import math
 import re
 import urllib.parse
@@ -31,9 +32,9 @@
 
 curl_cffi_version = tuple(map(int, re.split(r'[^\d]+', curl_cffi.__version__)[:3]))
 
-if curl_cffi_version != (0, 5, 10) and not ((0, 7, 0) <= curl_cffi_version < (0, 7, 2)):
+if curl_cffi_version != (0, 5, 10) and not (0, 10) <= curl_cffi_version:
     curl_cffi._yt_dlp__version = f'{curl_cffi.__version__} (unsupported)'
-    raise ImportError('Only curl_cffi versions 0.5.10, 0.7.0 and 0.7.1 are supported')
+    raise ImportError('Only curl_cffi versions 0.5.10 and 0.10.x are supported')
 
 import curl_cffi.requests
 from curl_cffi.const import CurlECode, CurlOpt
@@ -97,7 +98,7 @@ def read(self, amt=None):
             return self.fp.read(amt)
         except curl_cffi.requests.errors.RequestsError as e:
             if e.code == CurlECode.PARTIAL_FILE:
-                content_length = int_or_none(e.response.headers.get('Content-Length'))
+                content_length = e.response and int_or_none(e.response.headers.get('Content-Length'))
                 raise IncompleteRead(
                     partial=self.fp.bytes_read,
                     expected=content_length - self.fp.bytes_read if content_length is not None else None,
@@ -105,6 +106,51 @@ def read(self, amt=None):
             raise TransportError(cause=e) from e
 
 
+# See: https://github.com/lexiforest/curl_cffi?tab=readme-ov-file#supported-impersonate-browsers
+#      https://github.com/lexiforest/curl-impersonate?tab=readme-ov-file#supported-browsers
+BROWSER_TARGETS: dict[tuple[int, ...], dict[str, ImpersonateTarget]] = {
+    (0, 5): {
+        'chrome99': ImpersonateTarget('chrome', '99', 'windows', '10'),
+        'chrome99_android': ImpersonateTarget('chrome', '99', 'android', '12'),
+        'chrome100': ImpersonateTarget('chrome', '100', 'windows', '10'),
+        'chrome101': ImpersonateTarget('chrome', '101', 'windows', '10'),
+        'chrome104': ImpersonateTarget('chrome', '104', 'windows', '10'),
+        'chrome107': ImpersonateTarget('chrome', '107', 'windows', '10'),
+        'chrome110': ImpersonateTarget('chrome', '110', 'windows', '10'),
+        'edge99': ImpersonateTarget('edge', '99', 'windows', '10'),
+        'edge101': ImpersonateTarget('edge', '101', 'windows', '10'),
+        'safari15_3': ImpersonateTarget('safari', '15.3', 'macos', '11'),
+        'safari15_5': ImpersonateTarget('safari', '15.5', 'macos', '12'),
+    },
+    (0, 7): {
+        'chrome116': ImpersonateTarget('chrome', '116', 'windows', '10'),
+        'chrome119': ImpersonateTarget('chrome', '119', 'macos', '14'),
+        'chrome120': ImpersonateTarget('chrome', '120', 'macos', '14'),
+        'chrome123': ImpersonateTarget('chrome', '123', 'macos', '14'),
+        'chrome124': ImpersonateTarget('chrome', '124', 'macos', '14'),
+        'safari17_0': ImpersonateTarget('safari', '17.0', 'macos', '14'),
+        'safari17_2_ios': ImpersonateTarget('safari', '17.2', 'ios', '17.2'),
+    },
+    (0, 9): {
+        'safari15_3': ImpersonateTarget('safari', '15.3', 'macos', '14'),
+        'safari15_5': ImpersonateTarget('safari', '15.5', 'macos', '14'),
+        'chrome119': ImpersonateTarget('chrome', '119', 'macos', '14'),
+        'chrome120': ImpersonateTarget('chrome', '120', 'macos', '14'),
+        'chrome123': ImpersonateTarget('chrome', '123', 'macos', '14'),
+        'chrome124': ImpersonateTarget('chrome', '124', 'macos', '14'),
+        'chrome131': ImpersonateTarget('chrome', '131', 'macos', '14'),
+        'chrome131_android': ImpersonateTarget('chrome', '131', 'android', '14'),
+        'chrome133a': ImpersonateTarget('chrome', '133', 'macos', '15'),
+        'firefox133': ImpersonateTarget('firefox', '133', 'macos', '14'),
+        'safari18_0': ImpersonateTarget('safari', '18.0', 'macos', '15'),
+        'safari18_0_ios': ImpersonateTarget('safari', '18.0', 'ios', '18.0'),
+    },
+    (0, 10): {
+        'firefox135': ImpersonateTarget('firefox', '135', 'macos', '14'),
+    },
+}
+
+
 @register_rh
 class CurlCFFIRH(ImpersonateRequestHandler, InstanceStoreMixin):
     RH_NAME = 'curl_cffi'
@@ -112,30 +158,21 @@ class CurlCFFIRH(ImpersonateRequestHandler, InstanceStoreMixin):
     _SUPPORTED_FEATURES = (Features.NO_PROXY, Features.ALL_PROXY)
     _SUPPORTED_PROXY_SCHEMES = ('http', 'https', 'socks4', 'socks4a', 'socks5', 'socks5h')
     _SUPPORTED_IMPERSONATE_TARGET_MAP = {
-        **({
-            ImpersonateTarget('chrome', '124', 'macos', '14'): curl_cffi.requests.BrowserType.chrome124,
-            ImpersonateTarget('chrome', '123', 'macos', '14'): curl_cffi.requests.BrowserType.chrome123,
-            ImpersonateTarget('chrome', '120', 'macos', '14'): curl_cffi.requests.BrowserType.chrome120,
-            ImpersonateTarget('chrome', '119', 'macos', '14'): curl_cffi.requests.BrowserType.chrome119,
-            ImpersonateTarget('chrome', '116', 'windows', '10'): curl_cffi.requests.BrowserType.chrome116,
-        } if curl_cffi_version >= (0, 7, 0) else {}),
-        ImpersonateTarget('chrome', '110', 'windows', '10'): curl_cffi.requests.BrowserType.chrome110,
-        ImpersonateTarget('chrome', '107', 'windows', '10'): curl_cffi.requests.BrowserType.chrome107,
-        ImpersonateTarget('chrome', '104', 'windows', '10'): curl_cffi.requests.BrowserType.chrome104,
-        ImpersonateTarget('chrome', '101', 'windows', '10'): curl_cffi.requests.BrowserType.chrome101,
-        ImpersonateTarget('chrome', '100', 'windows', '10'): curl_cffi.requests.BrowserType.chrome100,
-        ImpersonateTarget('chrome', '99', 'windows', '10'): curl_cffi.requests.BrowserType.chrome99,
-        ImpersonateTarget('edge', '101', 'windows', '10'): curl_cffi.requests.BrowserType.edge101,
-        ImpersonateTarget('edge', '99', 'windows', '10'): curl_cffi.requests.BrowserType.edge99,
-        **({
-            ImpersonateTarget('safari', '17.0', 'macos', '14'): curl_cffi.requests.BrowserType.safari17_0,
-        } if curl_cffi_version >= (0, 7, 0) else {}),
-        ImpersonateTarget('safari', '15.5', 'macos', '12'): curl_cffi.requests.BrowserType.safari15_5,
-        ImpersonateTarget('safari', '15.3', 'macos', '11'): curl_cffi.requests.BrowserType.safari15_3,
-        ImpersonateTarget('chrome', '99', 'android', '12'): curl_cffi.requests.BrowserType.chrome99_android,
-        **({
-            ImpersonateTarget('safari', '17.2', 'ios', '17.2'): curl_cffi.requests.BrowserType.safari17_2_ios,
-        } if curl_cffi_version >= (0, 7, 0) else {}),
+        target: name if curl_cffi_version >= (0, 9) else curl_cffi.requests.BrowserType[name]
+        for name, target in dict(sorted(itertools.chain.from_iterable(
+            targets.items()
+            for version, targets in BROWSER_TARGETS.items()
+            if curl_cffi_version >= version
+        ), key=lambda x: (
+            # deprioritize mobile targets since they give very different behavior
+            x[1].os not in ('ios', 'android'),
+            # prioritize edge < firefox < safari < chrome
+            ('edge', 'firefox', 'safari', 'chrome').index(x[1].client),
+            # prioritize newest version
+            float(x[1].version) if x[1].version else 0,
+            # group by os name
+            x[1].os,
+        ), reverse=True)).items()
     }
 
     def _create_instance(self, cookiejar=None):


Test patch:
diff --git a/test/test_networking.py b/test/test_networking.py
index 63914bc4b..3ab60fe83 100644
--- a/test/test_networking.py
+++ b/test/test_networking.py
@@ -614,7 +614,6 @@ def test_source_address(self, handler):
                 rh, Request(f'http://127.0.0.1:{self.http_port}/source_address')).read().decode()
             assert source_address == data
 
-    # Not supported by CurlCFFI
     @pytest.mark.skip_handler('CurlCFFI', 'not supported by curl-cffi')
     def test_gzip_trailing_garbage(self, handler):
         with handler() as rh:
diff --git a/test/test_subtitles.py b/test/test_subtitles.py
index f3b005617..efd69b33d 100644
--- a/test/test_subtitles.py
+++ b/test/test_subtitles.py
@@ -23,7 +23,6 @@
     TedTalkIE,
     ThePlatformFeedIE,
     ThePlatformIE,
-    VikiIE,
     VimeoIE,
     WallaIE,
     YoutubeIE,
@@ -331,20 +330,6 @@ def test_subtitles_array_key(self):
         self.assertEqual(md5(subtitles['it']), '4b3264186fbb103508abe5311cfcb9cd')
 
 
-@is_download_test
-@unittest.skip('IE broken - DRM only')
-class TestVikiSubtitles(BaseTestSubtitles):
-    url = 'http://www.viki.com/videos/1060846v-punch-episode-18'
-    IE = VikiIE
-
-    def test_allsubtitles(self):
-        self.DL.params['writesubtitles'] = True
-        self.DL.params['allsubtitles'] = True
-        subtitles = self.getSubtitles()
-        self.assertEqual(set(subtitles.keys()), {'en'})
-        self.assertEqual(md5(subtitles['en']), '53cb083a5914b2d84ef1ab67b880d18a')
-
-
 @is_download_test
 class TestThePlatformSubtitles(BaseTestSubtitles):
     # from http://www.3playmedia.com/services-features/tools/integrations/theplatform/