Repository: yt-dlp/yt-dlp
Pull request #13563 (2025-06-28 22:00:27)

[ie] Implement check for HLS streaming header
<!--
    **IMPORTANT**: PRs without the template will be CLOSED
    
    Due to the high volume of pull requests, it may be a while before your PR is reviewed.
    Please try to keep your pull request focused on a single bugfix or new feature.
    Pull requests with a vast scope and/or very large diff will take much longer to review.
    It is recommended for new contributors to stick to smaller pull requests, so you can receive much more immediate feedback as you familiarize yourself with the codebase.

    PLEASE AVOID FORCE-PUSHING after opening a PR, as it makes reviewing more difficult.
-->

### Description of your *pull request* and other information

Checks if a m3u8 manifest extraction content starts with the expected header.

Related: #10184


<details open><summary>Template</summary> <!-- OPEN is intentional -->

<!--
    # PLEASE FOLLOW THE GUIDE BELOW

    - You will be asked some questions, please read them **carefully** and answer honestly
    - Put an `x` into all the boxes `[ ]` relevant to your *pull request* (like [x])
    - Use *Preview* tab to see what your *pull request* will actually look like
-->

### Before submitting a *pull request* make sure you have:
- [x] At least skimmed through [contributing guidelines](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions) including [yt-dlp coding conventions](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#yt-dlp-coding-conventions)
- [x] [Searched](https://github.com/yt-dlp/yt-dlp/search?q=is%3Apr&type=Issues) the bugtracker for similar pull requests

### In order to be accepted and merged into yt-dlp each piece of code must be in public domain or released under [Unlicense](http://unlicense.org/). Check those that apply and remove the others:
- [x] I am the original author of the code in this PR, and I am willing to release it under [Unlicense](http://unlicense.org/)

### What is the purpose of your *pull request*? Check those that apply and remove the others:
- [x] Core bug fix/improvement

</details>



Patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index fbbd9571f..61cc05d31 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -805,9 +805,7 @@
 from .hotnewhiphop import HotNewHipHopIE
 from .hotstar import (
     HotStarIE,
-    HotStarPlaylistIE,
     HotStarPrefixIE,
-    HotStarSeasonIE,
     HotStarSeriesIE,
 )
 from .hrefli import HrefLiRedirectIE
@@ -921,10 +919,6 @@
     ShugiinItvVodIE,
 )
 from .jeuxvideo import JeuxVideoIE
-from .jiocinema import (
-    JioCinemaIE,
-    JioCinemaSeriesIE,
-)
 from .jiosaavn import (
     JioSaavnAlbumIE,
     JioSaavnArtistIE,
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index 32b4680b7..b75e80623 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -1,5 +1,6 @@
 import base64
 import collections
+import contextlib
 import functools
 import getpass
 import http.client
@@ -2129,21 +2130,33 @@ def _extract_m3u8_formats_and_subtitles(
                     raise ExtractorError(errnote, video_id=video_id)
                 self.report_warning(f'{errnote}{bug_reports_message()}')
             return [], {}
-
-        res = self._download_webpage_handle(
-            m3u8_url, video_id,
-            note='Downloading m3u8 information' if note is None else note,
-            errnote='Failed to download m3u8 information' if errnote is None else errnote,
+        if note is None:
+            note = 'Downloading m3u8 information'
+        if errnote is None:
+            errnote = 'Failed to download m3u8 information'
+        response = self._request_webpage(
+            m3u8_url, video_id, note=note, errnote=errnote,
             fatal=fatal, data=data, headers=headers, query=query)
-
-        if res is False:
+        if response is False:
             return [], {}
 
-        m3u8_doc, urlh = res
-        m3u8_url = urlh.url
+        with contextlib.closing(response):
+            prefix = response.read(512)
+            if not prefix.startswith(b'#EXTM3U'):
+                msg = 'Response data has no m3u header'
+                if fatal:
+                    raise ExtractorError(msg, video_id=video_id)
+                self.report_warning(f'{msg}{bug_reports_message()}', video_id=video_id)
+                return [], {}
+
+            content = self._webpage_read_content(
+                response, m3u8_url, video_id, note=note, errnote=errnote,
+                fatal=fatal, prefix=prefix, data=data)
+        if content is False:
+            return [], {}
 
         return self._parse_m3u8_formats_and_subtitles(
-            m3u8_doc, m3u8_url, ext=ext, entry_protocol=entry_protocol,
+            content, response.url, ext=ext, entry_protocol=entry_protocol,
             preference=preference, quality=quality, m3u8_id=m3u8_id,
             note=note, errnote=errnote, fatal=fatal, live=live, data=data,
             headers=headers, query=query, video_id=video_id)
diff --git a/yt_dlp/extractor/hotstar.py b/yt_dlp/extractor/hotstar.py
index 358b5ce75..c4fae00a9 100644
--- a/yt_dlp/extractor/hotstar.py
+++ b/yt_dlp/extractor/hotstar.py
@@ -1,3 +1,4 @@
+import functools
 import hashlib
 import hmac
 import json
@@ -9,6 +10,7 @@
 from ..networking.exceptions import HTTPError
 from ..utils import (
     ExtractorError,
+    OnDemandPagedList,
     determine_ext,
     int_or_none,
     join_nonempty,
@@ -71,12 +73,36 @@ def _call_api_v2(self, path, video_id, content_type, cookies=None, st=None):
             }, separators=(',', ':')),
         }, st=st, cookies=cookies)
 
-    def _playlist_entries(self, path, item_id, root=None, **kwargs):
-        results = self._call_api_v1(path, item_id, **kwargs)['body']['results']
-        for video in traverse_obj(results, (('assets', None), 'items', ...)):
-            if video.get('contentId'):
-                yield self.url_result(
-                    HotStarIE._video_url(video['contentId'], root=root), HotStarIE, video['contentId'])
+    @staticmethod
+    def _parse_metadata_v1(video_data):
+        return traverse_obj(video_data, {
+            'id': ('contentId', {str}),
+            'title': ('title', {str}),
+            'description': ('description', {str}),
+            'duration': ('duration', {int_or_none}),
+            'timestamp': (('broadcastDate', 'startDate'), {int_or_none}, any),
+            'release_year': ('year', {int_or_none}),
+            'channel': ('channelName', {str}),
+            'channel_id': ('channelId', {int}, {str_or_none}),
+            'series': ('showName', {str}),
+            'season': ('seasonName', {str}),
+            'season_number': ('seasonNo', {int_or_none}),
+            'season_id': ('seasonId', {int}, {str_or_none}),
+            'episode': ('title', {str}),
+            'episode_number': ('episodeNo', {int_or_none}),
+        })
+
+    def _fetch_page(self, path, item_id, name, query, root, page):
+        results = self._call_api_v1(
+            path, item_id, note=f'Downloading {name} page {page + 1} JSON', query={
+                **query,
+                'tao': page * self._PAGE_SIZE,
+                'tas': self._PAGE_SIZE,
+            })['body']['results']
+
+        for video in traverse_obj(results, (('assets', None), 'items', lambda _, v: v['contentId'])):
+            yield self.url_result(
+                HotStarIE._video_url(video['contentId'], root=root), HotStarIE, **self._parse_metadata_v1(video))
 
 
 class HotStarIE(HotStarBaseIE):
@@ -245,14 +271,11 @@ def _real_extract(self, url):
         video_type = self._TYPE[video_type]
         cookies = self._get_cookies(url)  # Cookies before any request
 
-        # tas=10000 can cause HTTP Error 504, see https://github.com/yt-dlp/yt-dlp/issues/7946
-        for tas, err in [(10000, False), (0, None)]:
-            query = {'tas': tas, 'contentId': video_id}
-            video_data = traverse_obj(
-                self._call_api_v1(f'{video_type}/detail', video_id, fatal=False, errnote=err, query=query),
-                ('body', 'results', 'item', {dict})) or {}
-            if video_data:
-                break
+        video_data = traverse_obj(
+            self._call_api_v1(f'{video_type}/detail', video_id, fatal=False, query={
+                'tas': 5,  # See https://github.com/yt-dlp/yt-dlp/issues/7946
+                'contentId': video_id,
+            }), ('body', 'results', 'item', {dict})) or {}
 
         if video_data.get('drmProtected'):
             self.report_drm(video_id)
@@ -343,22 +366,10 @@ def _real_extract(self, url):
             f.setdefault('http_headers', {}).update(headers)
 
         return {
+            **self._parse_metadata_v1(video_data),
             'id': video_id,
-            'title': video_data.get('title'),
-            'description': video_data.get('description'),
-            'duration': int_or_none(video_data.get('duration')),
-            'timestamp': int_or_none(traverse_obj(video_data, 'broadcastDate', 'startDate')),
-            'release_year': int_or_none(video_data.get('year')),
             'formats': formats,
             'subtitles': subs,
-            'channel': video_data.get('channelName'),
-            'channel_id': str_or_none(video_data.get('channelId')),
-            'series': video_data.get('showName'),
-            'season': video_data.get('seasonName'),
-            'season_number': int_or_none(video_data.get('seasonNo')),
-            'season_id': str_or_none(video_data.get('seasonId')),
-            'episode': video_data.get('title'),
-            'episode_number': int_or_none(video_data.get('episodeNo')),
         }
 
 
@@ -399,65 +410,6 @@ def _real_extract(self, url):
         return self.url_result(HotStarIE._video_url(video_id, video_type), HotStarIE, video_id)
 
 
-class HotStarPlaylistIE(HotStarBaseIE):
-    IE_NAME = 'hotstar:playlist'
-    _VALID_URL = r'https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)(?:/[^/]+){2}/list/[^/]+/t-(?P<id>\w+)'
-    _TESTS = [{
-        'url': 'https://www.hotstar.com/tv/savdhaan-india/s-26/list/popular-clips/t-3_2_26',
-        'info_dict': {
-            'id': '3_2_26',
-        },
-        'playlist_mincount': 20,
-    }, {
-        'url': 'https://www.hotstar.com/shows/savdhaan-india/s-26/list/popular-clips/t-3_2_26',
-        'only_matching': True,
-    }, {
-        'url': 'https://www.hotstar.com/tv/savdhaan-india/s-26/list/extras/t-2480',
-        'only_matching': True,
-    }, {
-        'url': 'https://www.hotstar.com/in/tv/karthika-deepam/15457/list/popular-clips/t-3_2_1272',
-        'only_matching': True,
-    }]
-
-    def _real_extract(self, url):
-        id_ = self._match_id(url)
-        return self.playlist_result(
-            # XXX: If receiving HTTP Error 504, try with tas=0
-            self._playlist_entries('tray/find', id_, query={'tas': 10000, 'uqId': id_}), id_)
-
-
-class HotStarSeasonIE(HotStarBaseIE):
-    IE_NAME = 'hotstar:season'
-    _VALID_URL = r'(?P<url>https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)/[^/]+/\w+)/seasons/[^/]+/ss-(?P<id>\w+)'
-    _TESTS = [{
-        'url': 'https://www.hotstar.com/tv/radhakrishn/1260000646/seasons/season-2/ss-8028',
-        'info_dict': {
-            'id': '8028',
-        },
-        'playlist_mincount': 35,
-    }, {
-        'url': 'https://www.hotstar.com/in/tv/ishqbaaz/9567/seasons/season-2/ss-4357',
-        'info_dict': {
-            'id': '4357',
-        },
-        'playlist_mincount': 30,
-    }, {
-        'url': 'https://www.hotstar.com/in/tv/bigg-boss/14714/seasons/season-4/ss-8208/',
-        'info_dict': {
-            'id': '8208',
-        },
-        'playlist_mincount': 19,
-    }, {
-        'url': 'https://www.hotstar.com/in/shows/bigg-boss/14714/seasons/season-4/ss-8208/',
-        'only_matching': True,
-    }]
-
-    def _real_extract(self, url):
-        url, season_id = self._match_valid_url(url).groups()
-        return self.playlist_result(self._playlist_entries(
-            'season/asset', season_id, url, query={'tao': 0, 'tas': 0, 'size': 10000, 'id': season_id}), season_id)
-
-
 class HotStarSeriesIE(HotStarBaseIE):
     IE_NAME = 'hotstar:series'
     _VALID_URL = r'(?P<url>https?://(?:www\.)?hotstar\.com(?:/in)?/(?:tv|shows)/[^/]+/(?P<id>\d+))/?(?:[#?]|$)'
@@ -472,26 +424,29 @@ class HotStarSeriesIE(HotStarBaseIE):
         'info_dict': {
             'id': '1260050431',
         },
-        'playlist_mincount': 43,
+        'playlist_mincount': 42,
     }, {
         'url': 'https://www.hotstar.com/in/tv/mahabharat/435/',
         'info_dict': {
             'id': '435',
         },
         'playlist_mincount': 267,
-    }, {
+    }, {  # HTTP Error 504 with tas=10000 (possibly because total size is over 1000 items?)
         'url': 'https://www.hotstar.com/in/shows/anupama/1260022017/',
         'info_dict': {
             'id': '1260022017',
         },
-        'playlist_mincount': 940,
+        'playlist_mincount': 1601,
     }]
+    _PAGE_SIZE = 100
 
     def _real_extract(self, url):
-        url, series_id = self._match_valid_url(url).groups()
-        id_ = self._call_api_v1(
+        url, series_id = self._match_valid_url(url).group('url', 'id')
+        eid = self._call_api_v1(
             'show/detail', series_id, query={'contentId': series_id})['body']['results']['item']['id']
 
-        return self.playlist_result(self._playlist_entries(
-            # XXX: If receiving HTTP Error 504, try with tas=0
-            'tray/g/1/items', series_id, url, query={'tao': 0, 'tas': 10000, 'etid': 0, 'eid': id_}), series_id)
+        entries = OnDemandPagedList(functools.partial(
+            self._fetch_page, 'tray/g/1/items', series_id,
+            'series', {'etid': 0, 'eid': eid}, url), self._PAGE_SIZE)
+
+        return self.playlist_result(entries, series_id)
diff --git a/yt_dlp/extractor/jiocinema.py b/yt_dlp/extractor/jiocinema.py
deleted file mode 100644
index 94c85064e..000000000
--- a/yt_dlp/extractor/jiocinema.py
+++ /dev/null
@@ -1,408 +0,0 @@
-import base64
-import itertools
-import json
-import random
-import re
-import string
-import time
-
-from .common import InfoExtractor
-from ..utils import (
-    ExtractorError,
-    float_or_none,
-    int_or_none,
-    jwt_decode_hs256,
-    parse_age_limit,
-    try_call,
-    url_or_none,
-)
-from ..utils.traversal import traverse_obj
-
-
-class JioCinemaBaseIE(InfoExtractor):
-    _NETRC_MACHINE = 'jiocinema'
-    _GEO_BYPASS = False
-    _ACCESS_TOKEN = None
-    _REFRESH_TOKEN = None
-    _GUEST_TOKEN = None
-    _USER_ID = None
-    _DEVICE_ID = None
-    _API_HEADERS = {'Origin': 'https://www.jiocinema.com', 'Referer': 'https://www.jiocinema.com/'}
-    _APP_NAME = {'appName': 'RJIL_JioCinema'}
-    _APP_VERSION = {'appVersion': '5.0.0'}
-    _API_SIGNATURES = 'o668nxgzwff'
-    _METADATA_API_BASE = 'https://content-jiovoot.voot.com/psapi'
-    _ACCESS_HINT = 'the `accessToken` from your browser local storage'
-    _LOGIN_HINT = (
-        'Log in with "-u phone -p <PHONE_NUMBER>" to authenticate with OTP, '
-        f'or use "-u token -p <ACCESS_TOKEN>" to log in with {_ACCESS_HINT}. '
-        'If you have previously logged in with yt-dlp and your session '
-        'has been cached, you can use "-u device -p <DEVICE_ID>"')
-
-    def _cache_token(self, token_type):
-        assert token_type in ('access', 'refresh', 'all')
-        if token_type in ('access', 'all'):
-            self.cache.store(
-                JioCinemaBaseIE._NETRC_MACHINE, f'{JioCinemaBaseIE._DEVICE_ID}-access', JioCinemaBaseIE._ACCESS_TOKEN)
-        if token_type in ('refresh', 'all'):
-            self.cache.store(
-                JioCinemaBaseIE._NETRC_MACHINE, f'{JioCinemaBaseIE._DEVICE_ID}-refresh', JioCinemaBaseIE._REFRESH_TOKEN)
-
-    def _call_api(self, url, video_id, note='Downloading API JSON', headers={}, data={}):
-        return self._download_json(
-            url, video_id, note, data=json.dumps(data, separators=(',', ':')).encode(), headers={
-                'Content-Type': 'application/json',
-                'Accept': 'application/json',
-                **self._API_HEADERS,
-                **headers,
-            }, expected_status=(400, 403, 474))
-
-    def _call_auth_api(self, service, endpoint, note, headers={}, data={}):
-        return self._call_api(
-            f'https://auth-jiocinema.voot.com/{service}service/apis/v4/{endpoint}',
-            None, note=note, headers=headers, data=data)
-
-    def _refresh_token(self):
-        if not JioCinemaBaseIE._REFRESH_TOKEN or not JioCinemaBaseIE._DEVICE_ID:
-            raise ExtractorError('User token has expired', expected=True)
-        response = self._call_auth_api(
-            'token', 'refreshtoken', 'Refreshing token',
-            headers={'accesstoken': self._ACCESS_TOKEN}, data={
-                **self._APP_NAME,
-                'deviceId': self._DEVICE_ID,
-                'refreshToken': self._REFRESH_TOKEN,
-                **self._APP_VERSION,
-            })
-        refresh_token = response.get('refreshTokenId')
-        if refresh_token and refresh_token != JioCinemaBaseIE._REFRESH_TOKEN:
-            JioCinemaBaseIE._REFRESH_TOKEN = refresh_token
-            self._cache_token('refresh')
-        JioCinemaBaseIE._ACCESS_TOKEN = response['authToken']
-        self._cache_token('access')
-
-    def _fetch_guest_token(self):
-        JioCinemaBaseIE._DEVICE_ID = ''.join(random.choices(string.digits, k=10))
-        guest_token = self._call_auth_api(
-            'token', 'guest', 'Downloading guest token', data={
-                **self._APP_NAME,
-                'deviceType': 'phone',
-                'os': 'ios',
-                'deviceId': self._DEVICE_ID,
-                'freshLaunch': False,
-                'adId': self._DEVICE_ID,
-                **self._APP_VERSION,
-            })
-        self._GUEST_TOKEN = guest_token['authToken']
-        self._USER_ID = guest_token['userId']
-
-    def _call_login_api(self, endpoint, guest_token, data, note):
-        return self._call_auth_api(
-            'user', f'loginotp/{endpoint}', note, headers={
-                **self.geo_verification_headers(),
-                'accesstoken': self._GUEST_TOKEN,
-                **self._APP_NAME,
-                **traverse_obj(guest_token, 'data', {
-                    'deviceType': ('deviceType', {str}),
-                    'os': ('os', {str}),
-                })}, data=data)
-
-    def _is_token_expired(self, token):
-        return (try_call(lambda: jwt_decode_hs256(token)['exp']) or 0) <= int(time.time() - 180)
-
-    def _perform_login(self, username, password):
-        if self._ACCESS_TOKEN and not self._is_token_expired(self._ACCESS_TOKEN):
-            return
-
-        UUID_RE = r'[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}'
-
-        if username.lower() == 'token':
-            if try_call(lambda: jwt_decode_hs256(password)):
-                JioCinemaBaseIE._ACCESS_TOKEN = password
-                refresh_hint = 'the `refreshToken` UUID from your browser local storage'
-                refresh_token = self._configuration_arg('refresh_token', [''], ie_key=JioCinemaIE)[0]
-                if not refresh_token:
-                    self.to_screen(
-                        'To extend the life of your login session, in addition to your access token, '
-                        'you can pass --extractor-args "jiocinema:refresh_token=REFRESH_TOKEN" '
-                        f'where REFRESH_TOKEN is {refresh_hint}')
-                elif re.fullmatch(UUID_RE, refresh_token):
-                    JioCinemaBaseIE._REFRESH_TOKEN = refresh_token
-                else:
-                    self.report_warning(f'Invalid refresh_token value. Use {refresh_hint}')
-            else:
-                raise ExtractorError(
-                    f'The password given could not be decoded as a token; use {self._ACCESS_HINT}', expected=True)
-
-        elif username.lower() == 'device' and re.fullmatch(rf'(?:{UUID_RE}|\d+)', password):
-            JioCinemaBaseIE._REFRESH_TOKEN = self.cache.load(JioCinemaBaseIE._NETRC_MACHINE, f'{password}-refresh')
-            JioCinemaBaseIE._ACCESS_TOKEN = self.cache.load(JioCinemaBaseIE._NETRC_MACHINE, f'{password}-access')
-            if not JioCinemaBaseIE._REFRESH_TOKEN or not JioCinemaBaseIE._ACCESS_TOKEN:
-                raise ExtractorError(f'Failed to load cached tokens for device ID "{password}"', expected=True)
-
-        elif username.lower() == 'phone' and re.fullmatch(r'\+?\d+', password):
-            self._fetch_guest_token()
-            guest_token = jwt_decode_hs256(self._GUEST_TOKEN)
-            initial_data = {
-                'number': base64.b64encode(password.encode()).decode(),
-                **self._APP_VERSION,
-            }
-            response = self._call_login_api('send', guest_token, initial_data, 'Requesting OTP')
-            if not traverse_obj(response, ('OTPInfo', {dict})):
-                raise ExtractorError('There was a problem with the phone number login attempt')
-
-            is_iphone = guest_token.get('os') == 'ios'
-            response = self._call_login_api('verify', guest_token, {
-                'deviceInfo': {
-                    'consumptionDeviceName': 'iPhone' if is_iphone else 'Android',
-                    'info': {
-                        'platform': {'name': 'iPhone OS' if is_iphone else 'Android'},
-                        'androidId': self._DEVICE_ID,
-                        'type': 'iOS' if is_iphone else 'Android',
-                    },
-                },
-                **initial_data,
-                'otp': self._get_tfa_info('the one-time password sent to your phone'),
-            }, 'Submitting OTP')
-            if traverse_obj(response, 'code') == 1043:
-                raise ExtractorError('Wrong OTP', expected=True)
-            JioCinemaBaseIE._REFRESH_TOKEN = response['refreshToken']
-            JioCinemaBaseIE._ACCESS_TOKEN = response['authToken']
-
-        else:
-            raise ExtractorError(self._LOGIN_HINT, expected=True)
-
-        user_token = jwt_decode_hs256(JioCinemaBaseIE._ACCESS_TOKEN)['data']
-        JioCinemaBaseIE._USER_ID = user_token['userId']
-        JioCinemaBaseIE._DEVICE_ID = user_token['deviceId']
-        if JioCinemaBaseIE._REFRESH_TOKEN and username != 'device':
-            self._cache_token('all')
-            if self.get_param('cachedir') is not False:
-                self.to_screen(
-                    f'NOTE: For subsequent logins you can use "-u device -p {JioCinemaBaseIE._DEVICE_ID}"')
-        elif not JioCinemaBaseIE._REFRESH_TOKEN:
-            JioCinemaBaseIE._REFRESH_TOKEN = self.cache.load(
-                JioCinemaBaseIE._NETRC_MACHINE, f'{JioCinemaBaseIE._DEVICE_ID}-refresh')
-            if JioCinemaBaseIE._REFRESH_TOKEN:
-                self._cache_token('access')
-        self.to_screen(f'Logging in as device ID "{JioCinemaBaseIE._DEVICE_ID}"')
-        if self._is_token_expired(JioCinemaBaseIE._ACCESS_TOKEN):
-            self._refresh_token()
-
-
-class JioCinemaIE(JioCinemaBaseIE):
-    IE_NAME = 'jiocinema'
-    _VALID_URL = r'https?://(?:www\.)?jiocinema\.com/?(?:movies?/[^/?#]+/|tv-shows/(?:[^/?#]+/){3})(?P<id>\d{3,})'
-    _TESTS = [{
-        'url': 'https://www.jiocinema.com/tv-shows/agnisakshi-ek-samjhauta/1/pradeep-to-stop-the-wedding/3759931',
-        'info_dict': {
-            'id': '3759931',
-            'ext': 'mp4',
-            'title': 'Pradeep to stop the wedding?',
-            'description': 'md5:75f72d1d1a66976633345a3de6d672b1',
-            'episode': 'Pradeep to stop the wedding?',
-            'episode_number': 89,
-            'season': 'Agnisakshi…Ek Samjhauta-S1',
-            'season_number': 1,
-            'series': 'Agnisakshi Ek Samjhauta',
-            'duration': 1238.0,
-            'thumbnail': r're:https?://.+\.jpg',
-            'age_limit': 13,
-            'season_id': '3698031',
-            'upload_date': '20230606',
-            'timestamp': 1686009600,
-            'release_date': '20230607',
-            'genres': ['Drama'],
-        },
-        'params': {'skip_download': 'm3u8'},
-    }, {
-        'url': 'https://www.jiocinema.com/movies/bhediya/3754021/watch',
-        'info_dict': {
-            'id': '3754021',
-            'ext': 'mp4',
-            'title': 'Bhediya',
-            'description': 'md5:a6bf2900371ac2fc3f1447401a9f7bb0',
-            'episode': 'Bhediya',
-            'duration': 8500.0,
-            'thumbnail': r're:https?://.+\.jpg',
-            'age_limit': 13,
-            'upload_date': '20230525',
-            'timestamp': 1685026200,
-            'release_date': '20230524',
-            'genres': ['Comedy'],
-        },
-        'params': {'skip_download': 'm3u8'},
-    }]
-
-    def _extract_formats_and_subtitles(self, playback, video_id):
-        m3u8_url = traverse_obj(playback, (
-            'data', 'playbackUrls', lambda _, v: v['streamtype'] == 'hls', 'url', {url_or_none}, any))
-        if not m3u8_url:  # DRM-only content only serves dash urls
-            self.report_drm(video_id)
-        formats, subtitles = self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, m3u8_id='hls')
-        self._remove_duplicate_formats(formats)
-
-        return {
-            # '/_definst_/smil:vod/' m3u8 manifests claim to have 720p+ formats but max out at 480p
-            'formats': traverse_obj(formats, (
-                lambda _, v: '/_definst_/smil:vod/' not in v['url'] or v['height'] <= 480)),
-            'subtitles': subtitles,
-        }
-
-    def _real_extract(self, url):
-        video_id = self._match_id(url)
-        if not self._ACCESS_TOKEN and self._is_token_expired(self._GUEST_TOKEN):
-            self._fetch_guest_token()
-        elif self._ACCESS_TOKEN and self._is_token_expired(self._ACCESS_TOKEN):
-            self._refresh_token()
-
-        playback = self._call_api(
-            f'https://apis-jiovoot.voot.com/playbackjv/v3/{video_id}', video_id,
-            'Downloading playback JSON', headers={
-                **self.geo_verification_headers(),
-                'accesstoken': self._ACCESS_TOKEN or self._GUEST_TOKEN,
-                **self._APP_NAME,
-                'deviceid': self._DEVICE_ID,
-                'uniqueid': self._USER_ID,
-                'x-apisignatures': self._API_SIGNATURES,
-                'x-platform': 'androidweb',
-                'x-platform-token': 'web',
-            }, data={
-                '4k': False,
-                'ageGroup': '18+',
-                'appVersion': '3.4.0',
-                'bitrateProfile': 'xhdpi',
-                'capability': {
-                    'drmCapability': {
-                        'aesSupport': 'yes',
-                        'fairPlayDrmSupport': 'none',
-                        'playreadyDrmSupport': 'none',
-                        'widevineDRMSupport': 'none',
-                    },
-                    'frameRateCapability': [{
-                        'frameRateSupport': '30fps',
-                        'videoQuality': '1440p',
-                    }],
-                },
-                'continueWatchingRequired': False,
-                'dolby': False,
-                'downloadRequest': False,
-                'hevc': False,
-                'kidsSafe': False,
-                'manufacturer': 'Windows',
-                'model': 'Windows',
-                'multiAudioRequired': True,
-                'osVersion': '10',
-                'parentalPinValid': True,
-                'x-apisignatures': self._API_SIGNATURES,
-            })
-
-        status_code = traverse_obj(playback, ('code', {int}))
-        if status_code == 474:
-            self.raise_geo_restricted(countries=['IN'])
-        elif status_code == 1008:
-            error_msg = 'This content is only available for premium users'
-            if self._ACCESS_TOKEN:
-                raise ExtractorError(error_msg, expected=True)
-            self.raise_login_required(f'{error_msg}. {self._LOGIN_HINT}', method=None)
-        elif status_code == 400:
-            raise ExtractorError('The requested content is not available', expected=True)
-        elif status_code is not None and status_code != 200:
-            raise ExtractorError(
-                f'JioCinema says: {traverse_obj(playback, ("message", {str})) or status_code}')
-
-        metadata = self._download_json(
-            f'{self._METADATA_API_BASE}/voot/v1/voot-web/content/query/asset-details',
-            video_id, fatal=False, query={
-                'ids': f'include:{video_id}',
-                'responseType': 'common',
-                'devicePlatformType': 'desktop',
-            })
-
-        return {
-            'id': video_id,
-            'http_headers': self._API_HEADERS,
-            **self._extract_formats_and_subtitles(playback, video_id),
-            **traverse_obj(playback, ('data', {
-                # fallback metadata
-                'title': ('name', {str}),
-                'description': ('fullSynopsis', {str}),
-                'series': ('show', 'name', {str}, filter),
-                'season': ('tournamentName', {str}, {lambda x: x if x != 'Season 0' else None}),
-                'season_number': ('episode', 'season', {int_or_none}, filter),
-                'episode': ('fullTitle', {str}),
-                'episode_number': ('episode', 'episodeNo', {int_or_none}, filter),
-                'age_limit': ('ageNemonic', {parse_age_limit}),
-                'duration': ('totalDuration', {float_or_none}),
-                'thumbnail': ('images', {url_or_none}),
-            })),
-            **traverse_obj(metadata, ('result', 0, {
-                'title': ('fullTitle', {str}),
-                'description': ('fullSynopsis', {str}),
-                'series': ('showName', {str}, filter),
-                'season': ('seasonName', {str}, filter),
-                'season_number': ('season', {int_or_none}),
-                'season_id': ('seasonId', {str}, filter),
-                'episode': ('fullTitle', {str}),
-                'episode_number': ('episode', {int_or_none}),
-                'timestamp': ('uploadTime', {int_or_none}),
-                'release_date': ('telecastDate', {str}),
-                'age_limit': ('ageNemonic', {parse_age_limit}),
-                'duration': ('duration', {float_or_none}),
-                'genres': ('genres', ..., {str}),
-                'thumbnail': ('seo', 'ogImage', {url_or_none}),
-            })),
-        }
-
-
-class JioCinemaSeriesIE(JioCinemaBaseIE):
-    IE_NAME = 'jiocinema:series'
-    _VALID_URL = r'https?://(?:www\.)?jiocinema\.com/tv-shows/(?P<slug>[\w-]+)/(?P<id>\d{3,})'
-    _TESTS = [{
-        'url': 'https://www.jiocinema.com/tv-shows/naagin/3499917',
-        'info_dict': {
-            'id': '3499917',
-            'title': 'naagin',
-        },
-        'playlist_mincount': 120,
-    }, {
-        'url': 'https://www.jiocinema.com/tv-shows/mtv-splitsvilla-x5/3499820',
-        'info_dict': {
-            'id': '3499820',
-            'title': 'mtv-splitsvilla-x5',
-        },
-        'playlist_mincount': 310,
-    }]
-
-    def _entries(self, series_id):
-        seasons = traverse_obj(self._download_json(
-            f'{self._METADATA_API_BASE}/voot/v1/voot-web/view/show/{series_id}', series_id,
-            'Downloading series metadata JSON', query={'responseType': 'common'}), (
-            'trays', lambda _, v: v['trayId'] == 'season-by-show-multifilter',
-            'trayTabs', lambda _, v: v['id']))
-
-        for season_num, season in enumerate(seasons, start=1):
-            season_id = season['id']
-            label = season.get('label') or season_num
-            for page_num in itertools.count(1):
-                episodes = traverse_obj(self._download_json(
-                    f'{self._METADATA_API_BASE}/voot/v1/voot-web/content/generic/series-wise-episode',
-                    season_id, f'Downloading season {label} page {page_num} JSON', query={
-                        'sort': 'episode:asc',
-                        'id': season_id,
-                        'responseType': 'common',
-                        'page': page_num,
-                    }), ('result', lambda _, v: v['id'] and url_or_none(v['slug'])))
-                if not episodes:
-                    break
-                for episode in episodes:
-                    yield self.url_result(
-                        episode['slug'], JioCinemaIE, **traverse_obj(episode, {
-                            'video_id': 'id',
-                            'video_title': ('fullTitle', {str}),
-                            'season_number': ('season', {int_or_none}),
-                            'episode_number': ('episode', {int_or_none}),
-                        }))
-
-    def _real_extract(self, url):
-        slug, series_id = self._match_valid_url(url).group('slug', 'id')
-        return self.playlist_result(self._entries(series_id), series_id, slug)


Test patch:
diff --git a/test/test_InfoExtractor.py b/test/test_InfoExtractor.py
index e6c8d574e..c9f70431f 100644
--- a/test/test_InfoExtractor.py
+++ b/test/test_InfoExtractor.py
@@ -36,6 +36,18 @@ def do_GET(self):
             self.send_header('Content-Type', 'text/html; charset=utf-8')
             self.end_headers()
             self.wfile.write(TEAPOT_RESPONSE_BODY.encode())
+        elif self.path == '/fake.m3u8':
+            self.send_response(200)
+            self.send_header('Content-Length', '1024')
+            self.end_headers()
+            self.wfile.write(1024 * b'\x00')
+        elif self.path == '/bipbop.m3u8':
+            with open('test/testdata/m3u8/bipbop_16x9.m3u8', 'rb') as f:
+                data = f.read()
+            self.send_response(200)
+            self.send_header('Content-Length', str(len(data)))
+            self.end_headers()
+            self.wfile.write(data)
         else:
             assert False
 
@@ -2079,5 +2091,45 @@ def test_search_nuxt_json(self):
                 self.ie._search_nuxt_json(HTML_TMPL.format(data), None, default=DEFAULT), DEFAULT)
 
 
+class TestInfoExtractorNetwork(unittest.TestCase):
+    def setUp(self, /):
+        self.httpd = http.server.HTTPServer(
+            ('127.0.0.1', 0), InfoExtractorTestRequestHandler)
+        self.port = http_server_port(self.httpd)
+
+        self.server_thread = threading.Thread(target=self.httpd.serve_forever)
+        self.server_thread.daemon = True
+        self.server_thread.start()
+
+        self.called = False
+
+        def require_warning(*args, **kwargs):
+            self.called = True
+
+        self.ydl = FakeYDL()
+        self.ydl.report_warning = require_warning
+        self.ie = DummyIE(self.ydl)
+
+    def tearDown(self, /):
+        self.ydl.close()
+        self.httpd.shutdown()
+        self.httpd.server_close()
+        self.server_thread.join(1)
+
+    def test_extract_m3u8_formats(self):
+        formats, subtitles = self.ie._extract_m3u8_formats_and_subtitles(
+            f'http://127.0.0.1:{self.port}/bipbop.m3u8', None, fatal=False)
+        self.assertFalse(self.called)
+        self.assertTrue(formats)
+        self.assertTrue(subtitles)
+
+    def test_extract_m3u8_formats_warning(self):
+        formats, subtitles = self.ie._extract_m3u8_formats_and_subtitles(
+            f'http://127.0.0.1:{self.port}/fake.m3u8', None, fatal=False)
+        self.assertTrue(self.called, 'Warning was not issued for binary m3u8 file')
+        self.assertFalse(formats)
+        self.assertFalse(subtitles)
+
+
 if __name__ == '__main__':
     unittest.main()