Repository: yt-dlp/yt-dlp

Issue:
[FranceTVSite] [FranceTV] No video formats found! (HTTP Error 400: Bad Request) france.tv
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

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

### Checklist

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

### Region

France

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

Downloading videos from france.tv is not possible anymore (as of 29/02/2024 - 10:00 UTC) 

Unable to download JSON metadata: HTTP Error 400: Bad Request
ERROR: 'NoneType' object has no attribute 'strip'

### 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
laurent@inoculum:Vidéos$ yt-dlp -vU -F https://www.france.tv/france-2/laissez-vous-guider/laissez-vous-guider-saison-1/5706441-les-merveilles-de-l-egypte-antique.html
[debug] Command-line config: ['-vU', '-F', 'https://www.france.tv/france-2/laissez-vous-guider/laissez-vous-guider-saison-1/5706441-les-merveilles-de-l-egypte-antique.html']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2023.12.30 from yt-dlp/yt-dlp [f10589e34] (zip)
[debug] Python 3.11.2 (CPython x86_64 64bit) - Linux-6.1.0-17-amd64-x86_64-with-glibc2.36 (OpenSSL 3.0.11 19 Sep 2023, glibc 2.36)
[debug] exe versions: ffmpeg 5.1.4-0 (setts), ffprobe 5.1.4-0
[debug] Optional libraries: brotli-1.0.9, certifi-2022.09.24, mutagen-1.46.0, requests-2.28.1, secretstorage-3.3.3, sqlite3-3.40.1, urllib3-1.26.12
[debug] Proxy map: {}
[debug] Request Handlers: urllib
[debug] Loaded 1798 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2023.12.30 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2023.12.30 from yt-dlp/yt-dlp)
[FranceTVSite] Extracting URL: https://www.france.tv/france-2/laissez-vous-guider/laissez-vous-guider-saison-1/5706441-les-merveilles-de-l-egypte-antique.html
[FranceTVSite] 5706441-les-merveilles-de-l-egypte-antique: Downloading webpage
[FranceTV] Extracting URL: francetv:906e5b03-5cb8-4d65-9924-dc60f13f7c5b
[FranceTV] 906e5b03-5cb8-4d65-9924-dc60f13f7c5b: Downloading desktop video JSON
WARNING: [FranceTV] Unable to download JSON metadata: HTTP Error 400: Bad Request
[FranceTV] 906e5b03-5cb8-4d65-9924-dc60f13f7c5b: Downloading mobile video JSON
WARNING: [FranceTV] Unable to download JSON metadata: HTTP Error 400: Bad Request
[debug] Extractor gave empty title. Creating a generic title
ERROR: [FranceTV] 906e5b03-5cb8-4d65-9924-dc60f13f7c5b: No video formats found!; 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
Traceback (most recent call last):
  File "/home/laurent/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1587, in wrapper
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/laurent/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1743, in __extract_info
    return self.process_ie_result(ie_result, download, extra_info)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/laurent/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1802, in process_ie_result
    ie_result = self.process_video_result(ie_result, download=download)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/laurent/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 2781, in process_video_result
    self.raise_no_formats(info_dict)
  File "/home/laurent/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1085, in raise_no_formats
    raise ExtractorError(msg, video_id=info['id'], ie=info['extractor'],
yt_dlp.utils.ExtractorError: [FranceTV] 906e5b03-5cb8-4d65-9924-dc60f13f7c5b: No video formats found!; 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
```



Discussion:
I confirm this bug, with exactly same parameters. France.tv is a mainstream site in France. This fact drastically rise the level of this bug. (I'm not sure of my english, sorry). All url of this site are blocked.
Stopped working very recently (was working 1 day ago). Also when using the nighly build.

Latest version: nightly@2024.02.29.232658 from yt-dlp/yt-dlp-nightly-builds

I also confirm. Worked like a charm last week (2024-02-23). Same symptom, same error, same log. Last version. On 2024-03-01 doesn't work anymore.
I've tried to fake the user-agent, using the Firefox one, it didn't work either.
I also confirm this issue :)

Resolving patch:
diff --git a/yt_dlp/extractor/francetv.py b/yt_dlp/extractor/francetv.py
--- a/yt_dlp/extractor/francetv.py
+++ b/yt_dlp/extractor/francetv.py
@@ -1,21 +1,31 @@
+import urllib.parse
+
 from .common import InfoExtractor
 from .dailymotion import DailymotionIE
+from ..networking import HEADRequest
 from ..utils import (
     ExtractorError,
     determine_ext,
+    filter_dict,
     format_field,
     int_or_none,
     join_nonempty,
     parse_iso8601,
     parse_qs,
+    smuggle_url,
+    unsmuggle_url,
+    url_or_none,
 )
+from ..utils.traversal import traverse_obj
 
 
 class FranceTVBaseInfoExtractor(InfoExtractor):
-    def _make_url_result(self, video_or_full_id, catalog=None):
+    def _make_url_result(self, video_or_full_id, catalog=None, url=None):
         full_id = 'francetv:%s' % video_or_full_id
         if '@' not in video_or_full_id and catalog:
             full_id += '@%s' % catalog
+        if url:
+            full_id = smuggle_url(full_id, {'hostname': urllib.parse.urlparse(url).hostname})
         return self.url_result(
             full_id, ie=FranceTVIE.ie_key(),
             video_id=video_or_full_id.split('@')[0])
@@ -35,6 +45,8 @@ class FranceTVIE(InfoExtractor):
                     )
                     '''
     _EMBED_REGEX = [r'<iframe[^>]+?src=(["\'])(?P<url>(?:https?://)?embed\.francetv\.fr/\?ue=.+?)\1']
+    _GEO_COUNTRIES = ['FR']
+    _GEO_BYPASS = False
 
     _TESTS = [{
         # without catalog
@@ -76,10 +88,8 @@ class FranceTVIE(InfoExtractor):
         'only_matching': True,
     }]
 
-    def _extract_video(self, video_id, catalogue=None):
-        # Videos are identified by idDiffusion so catalogue part is optional.
-        # However when provided, some extra formats may be returned so we pass
-        # it if available.
+    def _extract_video(self, video_id, catalogue=None, hostname=None):
+        # TODO: Investigate/remove 'catalogue'/'catalog'; it has not been used since 2021
         is_live = None
         videos = []
         title = None
@@ -94,15 +104,16 @@ def _extract_video(self, video_id, catalogue=None):
         for device_type in ('desktop', 'mobile'):
             dinfo = self._download_json(
                 'https://player.webservices.francetelevisions.fr/v1/videos/%s' % video_id,
-                video_id, 'Downloading %s video JSON' % device_type, query={
+                video_id, f'Downloading {device_type} video JSON', query=filter_dict({
                     'device_type': device_type,
                     'browser': 'chrome',
-                }, fatal=False)
+                    'domain': hostname,
+                }), fatal=False)
 
             if not dinfo:
                 continue
 
-            video = dinfo.get('video')
+            video = traverse_obj(dinfo, ('video', {dict}))
             if video:
                 videos.append(video)
                 if duration is None:
@@ -112,7 +123,7 @@ def _extract_video(self, video_id, catalogue=None):
                 if spritesheets is None:
                     spritesheets = video.get('spritesheets')
 
-            meta = dinfo.get('meta')
+            meta = traverse_obj(dinfo, ('meta', {dict}))
             if meta:
                 if title is None:
                     title = meta.get('title')
@@ -126,22 +137,21 @@ def _extract_video(self, video_id, catalogue=None):
                 if timestamp is None:
                     timestamp = parse_iso8601(meta.get('broadcasted_at'))
 
-        formats = []
-        subtitles = {}
-        for video in videos:
+        formats, subtitles, video_url = [], {}, None
+        for video in traverse_obj(videos, lambda _, v: url_or_none(v['url'])):
+            video_url = video['url']
             format_id = video.get('format')
 
-            video_url = None
-            if video.get('workflow') == 'token-akamai':
-                token_url = video.get('token')
-                if token_url:
-                    token_json = self._download_json(
-                        token_url, video_id,
-                        'Downloading signed %s manifest URL' % format_id)
-                    if token_json:
-                        video_url = token_json.get('url')
-            if not video_url:
-                video_url = video.get('url')
+            token_url = url_or_none(video.get('token'))
+            if token_url and video.get('workflow') == 'token-akamai':
+                tokenized_url = traverse_obj(self._download_json(
+                    token_url, video_id, f'Downloading signed {format_id} manifest URL',
+                    fatal=False, query={
+                        'format': 'json',
+                        'url': video_url,
+                    }), ('url', {url_or_none}))
+                if tokenized_url:
+                    video_url = tokenized_url
 
             ext = determine_ext(video_url)
             if ext == 'f4m':
@@ -174,6 +184,13 @@ def _extract_video(self, video_id, catalogue=None):
 
             # XXX: what is video['captions']?
 
+        if not formats and video_url:
+            urlh = self._request_webpage(
+                HEADRequest(video_url), video_id, 'Checking for geo-restriction',
+                fatal=False, expected_status=403)
+            if urlh and urlh.headers.get('x-errortype') == 'geo':
+                self.raise_geo_restricted(countries=self._GEO_COUNTRIES, metadata_available=True)
+
         for f in formats:
             if f.get('acodec') != 'none' and f.get('language') in ('qtz', 'qad'):
                 f['language_preference'] = -10
@@ -213,6 +230,7 @@ def _extract_video(self, video_id, catalogue=None):
         }
 
     def _real_extract(self, url):
+        url, smuggled_data = unsmuggle_url(url, {})
         mobj = self._match_valid_url(url)
         video_id = mobj.group('id')
         catalog = mobj.group('catalog')
@@ -224,7 +242,7 @@ def _real_extract(self, url):
             if not video_id:
                 raise ExtractorError('Invalid URL', expected=True)
 
-        return self._extract_video(video_id, catalog)
+        return self._extract_video(video_id, catalog, hostname=smuggled_data.get('hostname'))
 
 
 class FranceTVSiteIE(FranceTVBaseInfoExtractor):
@@ -314,7 +332,7 @@ def _real_extract(self, url):
                 r'(?:href=|player\.setVideo\(\s*)"http://videos?\.francetv\.fr/video/([^@]+@[^"]+)"',
                 webpage, 'video ID').split('@')
 
-        return self._make_url_result(video_id, catalogue)
+        return self._make_url_result(video_id, catalogue, url=url)
 
 
 class FranceTVInfoIE(FranceTVBaseInfoExtractor):
@@ -405,4 +423,4 @@ def _real_extract(self, url):
              r'(?:data-id|<figure[^<]+\bid)=["\']([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})'),
             webpage, 'video id')
 
-        return self._make_url_result(video_id)
+        return self._make_url_result(video_id, url=url)
diff --git a/yt_dlp/extractor/lumni.py b/yt_dlp/extractor/lumni.py
--- a/yt_dlp/extractor/lumni.py
+++ b/yt_dlp/extractor/lumni.py
@@ -1,8 +1,7 @@
-from .common import InfoExtractor
-from .francetv import FranceTVIE
+from .francetv import FranceTVBaseInfoExtractor
 
 
-class LumniIE(InfoExtractor):
+class LumniIE(FranceTVBaseInfoExtractor):
     _VALID_URL = r'https?://(?:www\.)?lumni\.fr/video/(?P<id>[\w-]+)'
     _TESTS = [{
         'url': 'https://www.lumni.fr/video/l-homme-et-son-environnement-dans-la-revolution-industrielle',
@@ -21,4 +20,4 @@ def _real_extract(self, url):
         webpage = self._download_webpage(url, display_id)
         video_id = self._html_search_regex(
             r'<div[^>]+data-factoryid\s*=\s*["\']([^"\']+)', webpage, 'video id')
-        return self.url_result(f'francetv:{video_id}', FranceTVIE, video_id)
+        return self._make_url_result(video_id, url=url)


Test patch: