Repository: yt-dlp/yt-dlp
Pull request #12779: [ie/zdf] Fix extractors
Author: login: InvalidUsernameException
Labels: name: site-bug
description: Issue with a specific website
Created: 2025-03-29T21:51:30Z | Merged: 2025-04-30T22:27:42Z

### Description of your *pull request* and other information

This PR fixes the recent breakage introduced by the website rewrite of zdf.de. Primarily the extractor now relies on a new GraphQL API that is in use by both the website itself and the Android app.

Unfortunately, as a consequence the video IDs had to change. I could not figure out a way to obtain the original ID from the GraphQL API for playlist extraction. Fortunately they are still available during extraction of individual videos and therefore now provided via `_old_archive_ids`.

The prior approach of obtaining information via the API at `https://api.zdf.de/content/documents/` is no longer in use by the original site and the necessary input to call this API no longer available in the page source. So this approach has been removed for zdf.de. However, it is still in use for 3sat.de, which still uses this API on their website.

The prior approach of obtaining information via the API at `https://zdf-prod-futura.zdf.de/mediathekV2/document/` has been removed for the playlist extraction completely. It is no longer in use by the main ZDF Android app and the data returned by it has been observed to be incomplete or out of data in some cases. However, the rewritten extractor still uses it as fallback for extraction of individual videos. Effectively this case should only ever be reached for videos from the _ZDF heute_ sub-site at https://zdf.de/nachrichten/. This part of the domain uses a separate website that has not been updated like the main site. The extraction for this sub-site is effectively the same as it was before, except for some small improvements done along the way.

Quiet a bit of code has been moved around in this PR. This is mostly because various parts of the extractor code are only needed in a single extractor now and therefore have been removed from `ZDFBaseIE` to the respective extractor that needs the code.

Additionally several improvements have been made to the extractor compared to the original behavior. Metadata is now extracted more consistently. Particularly, resolution information is available for more cases and sign-language variants of a video are marked explicitly as such. This also made it possible to both improve and simplify the default format sorting of the extractor, which used to behave poorly in certain circumstances as pointed out by commenters on this PR.

Fixes #12647

PS Sorry this has gotten so big. I hope it is still somewhat reviewable.

<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/)
- [X] I am not the original author of the code in this PR, but it is in the public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)
  - A small amount of code was taken from the [initial patch provided by dirkf](https://github.com/yt-dlp/yt-dlp/issues/12647#issuecomment-2733653223) in the related issue with the explicit intention of it being included in a PR.

### What is the purpose of your *pull request*? Check those that apply and remove the others:
- [X] Fix or improvement to an extractor (Make sure to add/update tests)

</details>


Linked issue #12647: ZDF.de broken / [ZDFChannel] Unable to extract document id
### 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
- [ ] 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

Germany

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

ZDF introduced a new design for their website yesterday. Now all downloads fail.

Here are a couple test URLs, all of which fail (I couldn't find a single example on the site that doesn't):

- https://www.zdf.de/video/dokus/zdf-info-100/panama-duerre-flucht-und-kriminelle-clans-100
- https://www.zdf.de/magazine/heute-19-uhr-102
- https://www.zdf.de/serien/the-tourist-110?staffel=1
- https://www.zdf.de/video/reportagen/am-puls-100/am-puls-corona-tacke-long-covid-impfen-querdenker-100
- https://www.zdf.de/video/talk/maybrit-illner-128/gute-schulden-schlechte-schulden-hat-merz-sich-verzockt-maybrit-illner-vom-13-maerz-2025-100
- https://www.zdf.de/video/shows/zdf-magazin-royale-102/zdf-magazin-royale-vom-14-maerz-2025-100

Interestingly, if using the old URL format, the downloads still work. These permanently redirect (308) to new URLs, but yt-dlp can download them no problem. Running yt-dlp on the new URLs fails, however. Some examples found through googling – there's not really a way to reverse-engineer these that I could find:

- https://www.zdf.de/nachrichten-sendungen/heute-journal/heute-journal-vom-16-maerz-2025-100.html ✅ → https://www.zdf.de/video/magazine/heute-journal-104/heute-journal-vom-16-maerz-2025-100 ❌
- https://www.zdf.de/verbraucher/wiso/wiso-vom-9-dezember-2024-100.html ✅ → https://www.zdf.de/video/magazine/wiso-104/wiso-vom-9-dezember-2024-100 ❌
- https://www.zdf.de/serien/the-rookie/rueckschlag-100.html ✅ → https://www.zdf.de/video/serien/the-rookie-100/rueckschlag-100

But for these examples, even merely appending ".html" to the new URLs works, though not with the "staffel" URL and the "heute-19-uhr" category page.

yt-dlp seems to use a different extractor for those, showing "[ZDF]" on the old ones (working) and "[ZDFChannel]" on the new ones (not working), so it seems to miscategorize the new URL format.

### 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: ['https://www.zdf.de/video/magazine/heute-journal-104/heute-journal-vom-16-maerz-2025-100', '--skip-download', '-vU']
[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.16.232921 from yt-dlp/yt-dlp-nightly-builds [2ee3a0aff] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.22.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 1847 extractors
[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 53.140.162.95 (DE) as X-Forwarded-For
[ZDFChannel] Extracting URL: https://www.zdf.de/video/magazine/heute-journal-104/heute-journal-vom-16-maerz-2025-100
[ZDFChannel] heute-journal-vom-16-maerz-2025-100: Downloading webpage
ERROR: [ZDFChannel] heute-journal-vom-16-maerz-2025-100: Unable to extract document id; 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\zdf.py", line 487, in _real_extract
  File "yt_dlp\extractor\common.py", line 1338, in _search_regex

[debug] Command-line config: ['https://www.zdf.de/nachrichten-sendungen/heute-journal/heute-journal-vom-16-maerz-2025-100.html', '--skip-download', '-vU']
[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.16.232921 from yt-dlp/yt-dlp-nightly-builds [2ee3a0aff] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.22.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 1847 extractors
[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 53.147.186.36 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/nachrichten-sendungen/heute-journal/heute-journal-vom-16-maerz-2025-100.html
[ZDF] heute-journal-vom-16-maerz-2025-100: Downloading webpage
[ZDF] heute-journal-vom-16-maerz-2025-100: Downloading JSON metadata
[ZDF] 250316_2145_sendung_hjo: Downloading m3u8 information
[ZDF] 250316_2145_sendung_hjo: Downloading m3u8 information
[ZDF] 250316_2145_sendung_hjo: Downloading m3u8 information
[ZDF] 250316_2145_sendung_hjo: Downloading m3u8 information
[debug] Formats sorted by: hasvid, ie_pref, lang, quality, res, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[debug] Default format spec: bestvideo*+bestaudio/best
[info] 250316_2145_sendung_hjo: Downloading 1 format(s): http-h264_aac_mp4_http_na_na-fhd
```
Linked issue comments: user: login: Cyborgscode
created_at: 2025-03-18T12:31:00Z
body: Workaround: 

Open Networktab in FF 
play video
stop video
check networktab for AKAMAI URL ending with .webm 
yt-dlp url 
Rename manually

author_association: NONE
user: login: dirkf
created_at: 2025-03-18T15:22:22Z
body: The old URLs succeed because they match the extractor pattern, where the ID is extracted, and then the redirected page has the same structure despite the new URL.

`ZDFChannel` only matches if `ZDF` fails, so fixing the `ZDF` extractor pattern solves that.

The `heute-19-uhr` page is a playlist with a lead video; similarly for `/serien/the-tourist-110?staffel=1`, which is a playlist of "season" 1 of The Tourist with episode 1 as lead video. Fortunately `ZDFChannel` (almost) knows how to handle these, with `--no-playlist` selecting the lead video:
* we have to assume that `/magazine/` URLs have at least 1 further path component ahead of the ID
* some hacking is needed to get season 1 instead of season 2 for the Tourist page, and to avoid trailer episodes
* finally some episodes fail with an empty result from `_download_v2_doc()`, but then the whole show is geo-restricted from the UK: the website says "Dieser Inhalt ist in deinem Land nicht verfügbar".

Tests have to be added and exercised (patch updated per https://github.com/yt-dlp/yt-dlp/issues/12647#issuecomment-2735103753):
```diff
--- old/yt-dlp/yt_dlp/extractor/zdf.py
+++ new/yt-dlp/yt_dlp/extractor/zdf.py
@@ -10,6 +10,7 @@
     join_nonempty,
     merge_dicts,
     parse_codecs,
+    parse_qs,
     qualities,
     traverse_obj,
     try_get,
@@ -249,7 +250,10 @@
 
 
 class ZDFIE(ZDFBaseIE):
-    _VALID_URL = r'https?://www\.zdf\.de/(?:[^/]+/)*(?P<id>[^/?#&]+)\.html'
+    _VALID_URL = (
+        r'https?://www\.zdf\.de/(?:play/)?(?:video|(?:magazine|reportagen|konzerte|filme)/[^/?#&]+)(?:/[^/?#&]+)*/(?P<id>[^/?#&.]+)',
+        r'https?://www\.zdf\.de/(?:[^/]+/)*(?P<id>[^/?#&]+)\.html',
+    )
     _TESTS = [{
         # Same as https://www.phoenix.de/sendungen/ereignisse/corona-nachgehakt/wohin-fuehrt-der-protest-in-der-pandemie-a-2050630.html
         'url': 'https://www.zdf.de/politik/phoenix-sendungen/wohin-fuehrt-der-protest-in-der-pandemie-100.html',
@@ -471,26 +475,50 @@
                 'duration': ('length', {float_or_none}),
                 'season_number': ('seasonNumber', {int_or_none}),
                 'episode_number': ('episodeNumber', {int_or_none}),
+                'timestamp': ('visibleFrom', {unified_timestamp}),
             }))
 
     def _entries(self, data, document_id):
+        structure_node_path = traverse_obj(data, ('document', 'structureNodePath'))
+        if structure_node_path:
+            def on_brand(v):
+                return v['structureNodePath'] == structure_node_path
+        else:
+            def on_brand(v):
+                return v['brandId'] == document_id
+
         for entry in traverse_obj(data, (
             'cluster', lambda _, v: v['type'] == 'teaser',
+            'teaser', lambda _, v: v['type'] == 'video' and v.get('contentType') in ('episode', None)
             # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
+            and on_brand(v) and v['sharingUrl'],
         )):
             yield self._extract_entry(entry)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
         webpage = self._download_webpage(url, channel_id)
+        hero_id = self._search_json(
+            re.escape(r'\"heroVideo\":'), webpage, 'hero video', channel_id,
+            transform_source=lambda s: s.encode().decode('unicode_escape'), default={})
+        hero_id = hero_id.get('canonical')
+        if hero_id:
+            return self._extract_mobile(hero_id)
+
         document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
+            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id',
+            default=channel_id)
         data = self._download_v2_doc(document_id)
-
+        teaser_content = {'teaserContent'}
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        if season_id:
+            data['cluster'] = traverse_obj(data, ('cluster', lambda _, v: re.match(rf'Staffel\s+{season_id}(?:\W|$)', v['name'])))
+            teaser_content.add('teaser')
         main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
+            'cluster', lambda _, v: v['type'] in teaser_content,
+            'teaser', lambda _, v: (
+                v['type'] == 'video' and v['basename'] and v['sharingUrl']
+                and v.get('contentType') in ('episode', None)), any)) or {}
 
         if not self._yes_playlist(channel_id, main_video.get('basename')):
             return self._extract_entry(main_video)
```
author_association: CONTRIBUTOR
user: login: adrgru
created_at: 2025-03-18T15:39:14Z
body: Thanks for looking into this.

Apparently, the format for programme pages is 
- "https://www.zdf.de/[category]/[programme]".

And the format for episode pages is 
- "https://www.zdf.de/video/[category]/[programme]/[episode]"
- "https://www.zdf.de/play/[category]/[programme]/[episode]" and
- "https://www.zdf.de/[category]/[programme]/[episode]".

A different season's URL is always the programme URL with "?staffel=[season]" appended.

So it's not specifically with "magazine". There can be many different categories.

Some examples:

programme: https://www.zdf.de/shows/bares-fuer-rares-104
different season: https://www.zdf.de/shows/bares-fuer-rares-104?staffel=2022
episode: https://www.zdf.de/video/shows/bares-fuer-rares-104/bares-fuer-rares-vom-18-maerz-2025-100

programme: https://www.zdf.de/serien/the-rookie-100
different season: https://www.zdf.de/serien/the-rookie-100?staffel=5
episode: https://www.zdf.de/video/serien/the-rookie-100/der-prozess-110

programme: https://www.zdf.de/reportagen/die-spur-224
different season: https://www.zdf.de/reportagen/die-spur-224?staffel=2023
episode: https://www.zdf.de/video/reportagen/die-spur-224/finanzen-influencer-geldanlage-socialmedia-boerse-copytrading-100

programme: https://www.zdf.de/dokus/faszination-erde-mit-hannah-emde-dokureihe-100
different season: https://www.zdf.de/dokus/faszination-erde-mit-hannah-emde-dokureihe-100?staffel=19
episode: https://www.zdf.de/video/dokus/faszination-erde-mit-hannah-emde-dokureihe-100/faszination-erde-ueberleben-planet-im-wandel-mit-dirk-steffens-100

programme: https://www.zdf.de/kurzfassungen/bundesliga-fussball-highlights-zusammenfassung-100
different season: https://www.zdf.de/kurzfassungen/bundesliga-fussball-highlights-zusammenfassung-100?staffel=19
episode: https://www.zdf.de/play/kurzfassungen/bundesliga-fussball-highlights-zusammenfassung-100/vfl-wolfsburg-holstein-kiel-highlights-zusammenfassung-saison-2024-25-100

Movies and standalone programmes function like programmes with a single episode:

main page: https://www.zdf.de/filme/a-breath-away-movie-100
player: https://www.zdf.de/play/filme/a-breath-away-movie-100/a-breath-away-100

main page: https://www.zdf.de/konzerte/judas-priest-epitaph-movie-100
player: https://www.zdf.de/play/konzerte/judas-priest-epitaph-movie-100/judas-priest-epitaph-live-at-hammersmith-apollo-100
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-03-18T22:44:33Z
body: The patch is not complete. The `ZDF` extractor implements two different APIs: The web API at `api.zdf.de` and the Android API at  `zdf-prod-futura.zdf.de`. Extraction via web API is silently skipped because the extractor doesn't find the necessary configuration data in the changed page structure anymore. So now it falls back to the Android API which only requires the document ID form the URL and doesn't look at the page content at all. But for the fallback Android API less metadata is extracted than for the web API. Also looking at network traffic, there is a (possibly new) GraphQL endpoint at `api.zdf.de/graphql` as well now.

So there is a couple options for this:
* Find the necessary information again and repair extraction via web API. Though it may be that this API will be replaced with the GraphQL API and get removed entirely. 
* Extend Android API extraction to match the information from the web API and make it the primary extraction. Though the web API would likely still be required for the 3sat extractor, for which the Android API did not work last time I tried.
* Look into the GraphQL API and see if that can be the primary API for extraction.
* There is also a `.m3u8` URL available inside a script tag. That may be another option, but it provides only a smaller set of formats and metadata would need to be extracted in a different way.
author_association: CONTRIBUTOR
user: login: InvalidUsernameException
created_at: 2025-03-18T22:48:36Z
body: > some hacking is needed [...] to avoid trailer episodes

Is it common practice to ignore trailer episodes entirely in playlist extractors? When I rewrote `ZDFChannel`, I intentionally left them in and provided a distinction via `media_type` so that users can filter them out if desired, but still have the option to download them along with the rest of the show. Later, the `media_type` field disappeared again in code review because it wasn't available from `ZDF` extractor I believe. But that now makes me wonder what the recommended approach is for such cases.
author_association: CONTRIBUTOR
user: login: dirkf
created_at: 2025-03-19T01:39:35Z
body: Yes, `reportagen` (also `konzerte`, `filme`, as below) needs to be rolled in with `magazine`. A small fix catches the season handling for `serien`.

But with, say, `bares-fuer-rares` or `die-spur`, the data from the API used by `ZDFChannel` generally shows no explicit season/episode values. There are some cases, though. The `document` data for `die-spur` counts 69 videos; listing the unique titles gets 66 items; the larger count includes two `doku-serien`, one with 3 episodes and one with 2; hence 69 = 64 + 3 + 2. And then the 3-episode series appears as lead video episode 1 with playlist episodes 2-3; these items do have explicit season/episode values, but I'm struggling to see a clean way of separating them out of the data from the API, so that (eg) https://www.zdf.de/reportagen/alina-lipp-russland-propaganda-ukraine-krieg-100 just gets the 3 episodes without additional unrelated videos. What can be done is to include the video timestamp in the playlist entry so that filtering by year will work effectively.  

Basically the API used by the `ZDFChannel` extractor is not really returning results that match the webpages. Tracing shows no relevant network API being used (the GraphQL API is used to get related items), so I suppose what we need must be in the new-style Next.js hydration JSON in the webpage itself.

@InvalidUsernameException makes a relevant and accurate observation regarding the main extractor:
>... Extraction via web API is silently skipped because the extractor doesn't find the necessary configuration data in the changed page structure anymore. ...

Presumably, the JSON returned by `_extract_player()` was the player input, but that player and its input don't exist in the new page structure. Again, there is no sign of any network API being used to populate a single-video page, although the GraphQL API operation `getNotificationsForBookmarks` is used -- is this a geo-restriction issue?

Regarding https://github.com/yt-dlp/yt-dlp/issues/12647#issuecomment-2734902145, in the YT extractor there is, or was, a code path to fetch a trailer for an upcoming video when the video is not yet available but a trailer is. In other sites geo-restriction may result in a trailer being sent instead of an actual show. Otherwise, personally I would expect that people want to get the actual episodes when fetching a series or season and be able to get the trailer directly by using its specific URL.

For ZDF, with the example of The Tourist, the website has 3 tabs (although the site CSS hides their nature) `Folgen` (episodes), `Empfehlungen` (related), `Extras`, where trailers are shown in `Extras`, so the links to all those are in the JS page. The extractor has to use an API URL or spelunk in the hydration JSON to find them. It could detect the fragment `t=extras` in a URL like https://www.zdf.de/serien/the-tourist-110?staffel=1#t=extras to select all extras, but note that the `staffel` parameter is ignored in the website.

Looking at the movie pages, if the URL pattern is fixed as above, https://www.zdf.de/play/konzerte/judas-priest-epitaph-movie-100/judas-priest-epitaph-live-at-hammersmith-apollo-100 should work in-region, but neither the site nor the API JSON  show me a link to that URL from https://www.zdf.de/konzerte/judas-priest-epitaph-movie-100/, or indeed mention `judas-priest-epitaph-live-at-hammersmith-apollo-100`. However this fragment is buried in the hydration JSON:
```py
'heroVideo': {
  '__typename': 'Video',
  'id': '4f5245f8-0810-4724-8263-2ec195cb02cd',
  'canonical': 'judas-priest-epitaph-live-at-hammersmith-apollo-100',
  ...
}
```
Also there is a ld+json block with a valid `contentUrl` for D, A, CH, so forcing the generic extractor should partially succeed in-region. But after a moment's thought the `.heroVideo.canonical` can be extracted without processing all the hydration JSON, so I've added that.

Similarly for https://www.zdf.de/play/filme/morbius-movie-100/morbius-100 and its parent.
author_association: CONTRIBUTOR
user: login: burk-castlehard
created_at: 2025-03-21T20:16:55Z
body: @theMatrix - 846321 wont work for f.e.:  https://www.zdf.de/video/serien/the-mallorca-files-102/unter-dem-radar-102 

I checked the zdf.py I use (t's the one of the patch :) )  and tried out adding "serien" to it as well in the ZDFBaseIE but did not work for me at all. ( so sure i am not deep enough into it :) )
So if you can try out by that example of me again it would make a lot of people happy again, i guess.
author_association: NONE
user: login: gunchleoc
created_at: 2025-03-21T20:52:04Z
body: > Workaround:
> 
> Open Networktab in FF play video stop video check networktab for AKAMAI URL ending with .webm yt-dlp url Rename manually

Alternative workaround: Search for the video on https://www.ardmediathek.de and stick that into yt-dlp, or find it on https://mediathekviewweb.de/ and download from there
author_association: NONE
user: login: theMarix
created_at: 2025-03-21T21:23:15Z
body: > [@thematrix](https://github.com/thematrix) - 846321 wont work for f.e.: https://www.zdf.de/video/serien/the-mallorca-files-102/unter-dem-radar-102

On [f1c13f8](https://github.com/theMarix/yt-dlp/commit/f1c13f83b776c2256aefb3c3c9df8d26e694e537), which simply applies the patch from above, that URL downloads fine.

The patch does still need some work, though. Not just because the tests are missing. It currently doesn't handle movies with access time constrains correctly. Instead of giving a proper error message, as I recall it to have been before the page change, we get an ugly error due to attempting to access an attribute on a `None` value.

```
> uvx --from git+https://github.com/theMarix/yt-dlp@f1c13f8 yt-dlp --list-formats https://www.zdf.de/filme/fight-club-movie-100
[ZDFChannel] Extracting URL: https://www.zdf.de/filme/fight-club-movie-100
[ZDFChannel] fight-club-movie-100: Downloading webpage
[ZDFChannel] fight-club-100: Downloading JSON metadata
ERROR: 'NoneType' object has no attribute 'get'
```

The same URL works past 23:00 Europe/Berlin, so the issue due to the rating-based time constraint.


author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-03-22T15:06:55Z
body: Hmm, this gets more annoying the closer you look at it. As shown by the various examples already posted, we can't reliably determine whether a certain URL is a playlist or an individual video based on number of path segments. We know `https://www.zdf.de/video/` and `https://www.zdf.de/play/` will be individual files, but `https://www.zdf.de/[category]/[id]` might both be an individual video or a playlist.

From the examples it looked like a specific category would contain either only video or only playlists. But that is not true either. There are for example the following:
* Individual video:
  * https://www.zdf.de/portraits/david-garrett--ein-weltstar-ganz-privat-movie-100
  * https://www.zdf.de/konzerte/david-garrett-auf-sizilien-movie-100
* Playlist:
  * https://www.zdf.de/portraits/the-true-story-of-100
  * https://www.zdf.de/konzerte/collection-index-page-ard-collection-ard-dxjuomfyzdpzag93omq3n2qymju2nmu2yzq2nji-134

So I don't think there is any reliable way to tell whether a page is a single video or a playlist just based on the URL alone. We would need to download at least the webpage do make that determination. I assume doing a network request in `suitable()` would be a big no-no. Does that mean that `ZDFIE` end `ZDFChannelIE` need to be combined into a single extractor?

----

I've also dug a little bit into the Android app again. That forced me to update to the latest version before proceeding. Looking at the traffic after the update, I've found that it has stopped using what I previously referred to as the "Android API" (i.e. `zdf-prod-futura.zdf.de`) for most of its requests. Instead it now also uses the GraphQL API for nearly all requests.

Combined with the findings from dirkf about discrepancies between what is shown on the website and what the API returns, this does not sound good. It might be that the CMS data behind the old API is no longer updated for things that aired since the redesign or similar.

Presumably that means that `zdf-prod-futura.zdf.de` is mostly deprecated and the extractor should stop relying on it.

I think the way forward here is using the GraphQL API. At a cursory look, it seems like it will contain everything we need for extraction, both for single items and for playlists. At least once we have determined which page is which to be able to make the correct requests.

----

> The same URL works past 23:00 Europe/Berlin, so the issue due to the rating-based time constraint.

@theMarix That is weird, the API URL in use (https://zdf-prod-futura.zdf.de/mediathekV2/document/fight-club-movie-100) did not contain any data for me, even during the correct time yesterday.

> Again, there is no sign of any network API being used to populate a single-video page, although the GraphQL API operation getNotificationsForBookmarks is used -- is this a geo-restriction issue?

@dirkf Seems likely. I do get a GraphQL request once I click play on a video page. But I don't get any GraphQL requests when the video is age-restricted and I press play outside the allowed time of day. So presumably you can't see them because the geo restriction error works similarly.


author_association: CONTRIBUTOR
user: login: bashonly
created_at: 2025-03-22T15:30:31Z
body: > So I don't think there is any reliable way to tell whether a page is a single video or a playlist just based on the URL alone. We would need to download at least the webpage do make that determination. I assume doing a network request in `suitable()` would be a big no-no.

Yeah

> Does that mean that `ZDFIE` and `ZDFChannelIE` need to be combined into a single extractor?

Not necessarily. I think we could do something like this:

First, in `ZDFIE`:

```py
    def _real_extract(self, url):
        video_id = self._match_id(url)
        webpage = self._download_webpage(url, video_id)
        # The code to determine whether the page is for a video or for a playlist goes here.
        # It should assign `playlist_id` a value other than `None` only if the page is for a playlist
        if self._yes_playlist(playlist_id, video_id):
            return self.url_result(smuggle_url(url, {'is_playlist': True}))
```
and then in `ZDFChannelIE`:

```py
    @classmethod
    def suitable(cls, url):
        from ..utils import unsmuggle_url

        url, smuggled_data = unsmuggle_url(url, {})
        return False if not smuggled_data.get('is_playlist') else super().suitable(url)
```

and the `ZDFChannelIE` entries should probably have `'force_noplaylist': True` smuggled in their URL results just to be safe (unless the site has nested playlists, which I really really hope they don't)
author_association: MEMBER
user: login: WhitePeter
created_at: 2025-03-22T17:58:24Z
body: > The patch does still need some work, though. Not just because the tests are missing. It currently doesn't handle movies with access time constrains correctly. Instead of giving a proper error message, as I recall it to have been before the page change, we get an ugly error due to attempting to access an attribute on a `None` value.
> 
> ```
> > uvx --from git+https://github.com/theMarix/yt-dlp@f1c13f8 yt-dlp --list-formats https://www.zdf.de/filme/fight-club-movie-100
> [ZDFChannel] Extracting URL: https://www.zdf.de/filme/fight-club-movie-100
> [ZDFChannel] fight-club-movie-100: Downloading webpage
> [ZDFChannel] fight-club-100: Downloading JSON metadata
> ERROR: 'NoneType' object has no attribute 'get'
> ```
> 
> The same URL works past 23:00 Europe/Berlin, so the issue due to the rating-based time constraint.

FWIW, the `ZDF*` extractors never cared about age restrictions, i.e. rated videos only being available in the night hours. Up until this redesign, I could just `yt-dlp <URL>` any video, no matter if it was age restricted, at any arbitrary time of day. If at all possible, I would really like it if it stayed that way.

Maybe you were thinking of the ARD media library, aka [ARD Mediathek](https://www.ardmediathek.de/)? Because there the restriction have worked for quite some time. So maybe ZDF is only catching up here.

BTW, I live in Germany, so there are no geo restrictions in play for me, if that makes a difference.
author_association: NONE
user: login: theMarix
created_at: 2025-03-22T19:22:53Z
body: > > The patch does still need some work, though. Not just because the tests are missing. It currently doesn't handle movies with access time constrains correctly. Instead of giving a proper error message, as I recall it to have been before the page change, we get an ugly error due to attempting to access an attribute on a `None` value.
> > ```
> > > uvx --from git+https://github.com/theMarix/yt-dlp@f1c13f8 yt-dlp --list-formats https://www.zdf.de/filme/fight-club-movie-100
> > [ZDFChannel] Extracting URL: https://www.zdf.de/filme/fight-club-movie-100
> > [ZDFChannel] fight-club-movie-100: Downloading webpage
> > [ZDFChannel] fight-club-100: Downloading JSON metadata
> > ERROR: 'NoneType' object has no attribute 'get'
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > The same URL works past 23:00 Europe/Berlin, so the issue due to the rating-based time constraint.
> 
> FWIW, the `ZDF*` extractors never cared about age restrictions, i.e. rated videos only being available in the night hours. Up until this redesign, I could just `yt-dlp <URL>` any video, no matter if it was age restricted, at any arbitrary time of day. If at all possible, I would really like it if it stayed that way.
> 
> Maybe you were thinking of the ARD media library, aka [ARD Mediathek](https://www.ardmediathek.de/)? Because there the restriction have worked for quite some time. So maybe ZDF is only catching up here.
> 
> BTW, I live in Germany, so there are no geo restrictions in play for me, if that makes a difference.

You might be right about me misremembering the error message from the ARD.  It would still be good if the extractor gave a better error message, though. The current one is hard to read for anyone that's not a programmer.
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-03-22T22:57:58Z
body: I've dug through the API requests a bit more. Turns out this is actually easier than expected, if we just model the extractor structure after the CMS structure that they have. In particular: treat everything that is not a `/video` or `/play` URL as collection/playlist. Here is my current idea of an approach:

### `ZDFChannelIE`
```py
_VALID_URL = r'https?://www\.zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#]+)'

@classmethod
def suitable(cls, url):
    return False if ZDFIE.suitable(url) else super().suitable(url)

def _real_extract(self, url):
    channel_id = self._match_id(url)
    webpage, urlh = self._download_webpage_handle(url, channel_id)
    # Make sure to get the correct ID in case of redirects
    channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id',  group='id')
    collection_data = self._download_graphql(channel_id, 'downloading smart collection data', {
        'operationName': 'GetSmartCollectionByCanonical',
        'variables': f'{{"canonical":"{channel_id}","videoPageSize":24}}',
        'extensions': '{"persistedQuery":{"version":1,"sha256Hash":"cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02"}}',
    })
```

`collection_data` contains a bunch of JSON.
* If `traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'video')` exists, return a URL result with `video['sharingUrl']`. This will be a URL starting with `https://www.zdf.de/video/` and be handled by `ZDFIE`.
* If `traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'seasons')` exists, generate playlist result from `seasons`.
* If both exist, maybe give a choice through `_yes_playlist()`. Though I haven't seen that case yet.

### `ZDFIE`
```py
_VALID_URL = [
    r'https?://(?:www\.)?zdf\.de/(?:video|play)/(?:[^/?#]+/)*(?P<id>[^/?#]+)/?',
    # Legacy URLs that redirect
    r'https?://(?:www\.)?zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#]+)\.html',
]

def _real_extract(self, url):
    video_id = self._match_id(url)
    webpage = self._download_webpage(url, video_id)
    video_data = self._download_graphql(video_id, 'downloading video metadata', {
        'operationName': 'GetVideoMetaByCanonical',
        'variables': f'{{"canonical":"{video_id}"}}',
        'extensions': '{"persistedQuery":{"version":1,"sha256Hash":"737eb4421d274259baa3051929f4ecfef2d2afc59f12a9d82285c14dbdd1dd0d"}}',
    })
```

Then it is simply `traverse_obj(video_data, ('data', 'videoByCanonical', 'currentMedia', 'nodes', 0, 'ptmdTemplate'))` which can be used to call the existing `_extract_ptmd()`.

----

I guess I'll take a stab at implementing that then.
author_association: CONTRIBUTOR
user: login: InvalidUsernameException
created_at: 2025-03-29T22:00:02Z
body: OK, an open PR to fix this issue is up now at #12779. This is quiet an extensive change. So if anyone who has the repo cloned wants to test it, that would be appreciated. I have tested pretty extensively I think, but there is always a chance I've missed something.
author_association: CONTRIBUTOR
user: login: ithod
created_at: 2025-04-07T06:49:25Z
body: A workaround seems to be `--force-generic`
author_association: NONE
user: login: thomasmerz
created_at: 2025-04-08T10:01:43Z
body: > A workaround seems to be `--force-generic`

This doesn't work with latest `yt-dlp` (2025.03.31) for https://www.zdf.de/play/dokus/terra-x-harald-lesch-102/und-wie-ki-uns-ueberwacht-100.
author_association: NONE
user: login: thomasmerz
created_at: 2025-04-08T10:13:19Z
body: @InvalidUsernameException 
> So if anyone who has the repo cloned wants to test it, that would be appreciated

I tested and my problem/URL from zdf.de was solved 👍🏼 

```
🦎 ✔ ~/dev/github/yt-dlp-fix/bundle/docker/static [fix-zdf-after-website-rewrite|✔]
12:05 $ docker compose up --build static
…
🦎 ✔ ~/dev/github/yt-dlp-fix/bundle/docker/static [fix-zdf-after-website-rewrite|✚ 1]
12:08 $ docker compose up -d
…
🦎 thomas@merz-nimbus:/automnt/external_2tb [3/2245]
12:09 $ docker exec -it docker-static-1 /yt-dlp/yt-dlp.sh https://www.zdf.de/play/dokus/terra-x-harald-lesch-102/und-wie-ki-uns-ueberwacht-100
ERROR: Invalid update channel '' requested. Valid channels are stable, nightly, master
[ZDF] Extracting URL: https://www.zdf.de/play/dokus/terra-x-harald-lesch-102/und-wie-ki-uns-ueberwacht-100
[ZDF] und-wie-ki-uns-ueberwacht-100: Downloading API token
[ZDF] und-wie-ki-uns-ueberwacht-100: Downloading video metadata
[ZDF] und-wie-ki-uns-ueberwacht-100: Downloading PTMD data
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] und-wie-ki-uns-ueberwacht-100: Downloading PTMD data
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[ZDF] 240917_sendung_thl_dgs: Downloading m3u8 information
[info] und-wie-ki-uns-ueberwacht-100: Downloading 1 format(s): http-h264_aac_mp4_http_na_na-fhd-2
[download] Destination: ... und wie KI uns überwacht [und-wie-ki-uns-ueberwacht-100].mp4
[download]   0.4% of    1.10GiB at    2.74MiB/s ETA 06:48
```
author_association: NONE
user: login: andreygursky
created_at: 2025-04-08T10:20:43Z
body: > >  A workaround seems to be --force-generic
>  
> This doesn't work with latest yt-dlp (2025.03.31) for https://www.zdf.de/play/dokus/terra-x-harald-lesch-102/und-wie-ki-uns-ueberwacht-100.

This does work by replacing ```play``` with ```video``` in the URL.
author_association: NONE
user: login: vitusson
created_at: 2025-04-08T12:04:41Z
body: Forget my last message. Didn't realize you were testing in the @InvalidUsernameException dev fork
author_association: NONE
user: login: andreygursky
created_at: 2025-04-08T12:46:19Z
body: No, it's about the proposed workaround for the released yt-dlp. You've just forgotten to add ```--force-generic``` (and actually the mentioned replacement in the URL).
author_association: NONE
user: login: thomasmerz
created_at: 2025-04-08T13:04:19Z
body: Life-hack for zdf.de and some others:
get the corresponding link from https://mediathekviewweb.de/ - this still works!
author_association: NONE
user: login: andreygursky
created_at: 2025-04-08T13:37:23Z
body: It's both: still for old records and again for new ones. https://github.com/mediathekview/MServer/issues/1039.
author_association: NONE
user: login: frumble
created_at: 2025-04-08T13:56:36Z
body: > Life-hack for zdf.de and some others: get the corresponding link from https://mediathekviewweb.de/ - this still works!

But MediathekViewWeb doesn’t provide you with links to the better WebM/VP9 encodings.
https://github.com/mediathekview/mediathekviewweb/issues/244
author_association: NONE

Diff:
diff --git a/yt_dlp/extractor/dreisat.py b/yt_dlp/extractor/dreisat.py
index 376ff672dff4..edd66e46cc78 100644
--- a/yt_dlp/extractor/dreisat.py
+++ b/yt_dlp/extractor/dreisat.py
@@ -1,9 +1,15 @@
 from .zdf import ZDFBaseIE
+from ..utils import (
+    int_or_none,
+    merge_dicts,
+    parse_iso8601,
+)
+from ..utils.traversal import require, traverse_obj
 
 
 class DreiSatIE(ZDFBaseIE):
     IE_NAME = '3sat'
-    _VALID_URL = r'https?://(?:www\.)?3sat\.de/(?:[^/]+/)*(?P<id>[^/?#&]+)\.html'
+    _VALID_URL = r'https?://(?:www\.)?3sat\.de/(?:[^/?#]+/)*(?P<id>[^/?#&]+)\.html'
     _TESTS = [{
         'url': 'https://www.3sat.de/dokumentation/reise/traumziele-suedostasiens-die-philippinen-und-vietnam-102.html',
         'info_dict': {
@@ -12,40 +18,59 @@ class DreiSatIE(ZDFBaseIE):
             'title': 'Traumziele Südostasiens (1/2): Die Philippinen und Vietnam',
             'description': 'md5:26329ce5197775b596773b939354079d',
             'duration': 2625.0,
-            'thumbnail': 'https://www.3sat.de/assets/traumziele-suedostasiens-die-philippinen-und-vietnam-100~2400x1350?cb=1699870351148',
+            'thumbnail': 'https://www.3sat.de/assets/traumziele-suedostasiens-die-philippinen-und-vietnam-100~original?cb=1699870351148',
             'episode': 'Traumziele Südostasiens (1/2): Die Philippinen und Vietnam',
             'episode_id': 'POS_cc7ff51c-98cf-4d12-b99d-f7a551de1c95',
-            'timestamp': 1738593000,
-            'upload_date': '20250203',
+            'timestamp': 1747920900,
+            'upload_date': '20250522',
         },
     }, {
-        # Same as https://www.zdf.de/dokumentation/ab-18/10-wochen-sommer-102.html
-        'url': 'https://www.3sat.de/film/ab-18/10-wochen-sommer-108.html',
-        'md5': '0aff3e7bc72c8813f5e0fae333316a1d',
+        'url': 'https://www.3sat.de/film/ab-18/ab-18---mein-fremdes-ich-100.html',
+        'md5': 'f92638413a11d759bdae95c9d8ec165c',
         'info_dict': {
-            'id': '141007_ab18_10wochensommer_film',
+            'id': '221128_mein_fremdes_ich2_ab18',
             'ext': 'mp4',
-            'title': 'Ab 18! - 10 Wochen Sommer',
-            'description': 'md5:8253f41dc99ce2c3ff892dac2d65fe26',
-            'duration': 2660,
-            'timestamp': 1608604200,
-            'upload_date': '20201222',
+            'title': 'Ab 18! - Mein fremdes Ich',
+            'description': 'md5:cae0c0b27b7426d62ca0dda181738bf0',
+            'duration': 2625.0,
+            'thumbnail': 'https://www.3sat.de/assets/ab-18---mein-fremdes-ich-106~original?cb=1666081865812',
+            'episode': 'Ab 18! - Mein fremdes Ich',
+            'episode_id': 'POS_6225d1ca-a0d5-45e3-870b-e783ee6c8a3f',
+            'timestamp': 1695081600,
+            'upload_date': '20230919',
         },
-        'skip': '410 Gone',
     }, {
-        'url': 'https://www.3sat.de/gesellschaft/schweizweit/waidmannsheil-100.html',
+        'url': 'https://www.3sat.de/gesellschaft/37-grad-leben/aus-dem-leben-gerissen-102.html',
+        'md5': 'a903eaf8d1fd635bd3317cd2ad87ec84',
         'info_dict': {
-            'id': '140913_sendung_schweizweit',
+            'id': '250323_0903_sendung_sgl',
             'ext': 'mp4',
-            'title': 'Waidmannsheil',
-            'description': 'md5:cce00ca1d70e21425e72c86a98a56817',
-            'timestamp': 1410623100,
-            'upload_date': '20140913',
+            'title': 'Plötzlich ohne dich',
+            'description': 'md5:380cc10659289dd91510ad8fa717c66b',
+            'duration': 1620.0,
+            'thumbnail': 'https://www.3sat.de/assets/37-grad-leben-106~original?cb=1645537156810',
+            'episode': 'Plötzlich ohne dich',
+            'episode_id': 'POS_faa7a93c-c0f2-4d51-823f-ce2ac3ee191b',
+            'timestamp': 1743162540,
+            'upload_date': '20250328',
         },
-        'params': {
-            'skip_download': True,
+    }, {
+        # Video with chapters
+        'url': 'https://www.3sat.de/kultur/buchmesse/dein-buch-das-beste-von-der-leipziger-buchmesse-2025-teil-1-100.html',
+        'md5': '6b95790ce52e75f0d050adcdd2711ee6',
+        'info_dict': {
+            'id': '250330_dein_buch1_bum',
+            'ext': 'mp4',
+            'title': 'dein buch  - Das Beste von der Leipziger Buchmesse 2025 - Teil 1',
+            'description': 'md5:bae51bfc22f15563ce3acbf97d2e8844',
+            'duration': 5399.0,
+            'thumbnail': 'https://www.3sat.de/assets/buchmesse-kerkeling-100~original?cb=1743329640903',
+            'chapters': 'count:24',
+            'episode': 'dein buch  - Das Beste von der Leipziger Buchmesse 2025 - Teil 1',
+            'episode_id': 'POS_1ef236cc-b390-401e-acd0-4fb4b04315fb',
+            'timestamp': 1743327000,
+            'upload_date': '20250330',
         },
-        'skip': '404 Not Found',
     }, {
         # Same as https://www.zdf.de/filme/filme-sonstige/der-hauptmann-112.html
         'url': 'https://www.3sat.de/film/spielfilm/der-hauptmann-100.html',
@@ -58,11 +83,42 @@ class DreiSatIE(ZDFBaseIE):
 
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        webpage = self._download_webpage(url, video_id)
+        player = self._search_json(
+            r'data-zdfplayer-jsb=(["\'])', webpage, 'player JSON', video_id)
+        player_url = player['content']
+        api_token = f'Bearer {player["apiToken"]}'
+
+        content = self._call_api(player_url, video_id, 'video metadata', api_token)
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id)
+        video_target = content['mainVideoContent']['http://zdf.de/rels/target']
+        ptmd_path = traverse_obj(video_target, (
+            (('streams', 'default'), None),
+            ('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
+            {str}, any, {require('ptmd path')}))
+        ptmd_url = self._expand_ptmd_template(player_url, ptmd_path)
+        aspect_ratio = self._parse_aspect_ratio(video_target.get('aspectRatio'))
+        info = self._extract_ptmd(ptmd_url, video_id, api_token, aspect_ratio)
 
-        return self._extract_mobile(video_id)
+        return merge_dicts(info, {
+            **traverse_obj(content, {
+                'title': (('title', 'teaserHeadline'), {str}, any),
+                'episode': (('title', 'teaserHeadline'), {str}, any),
+                'description': (('leadParagraph', 'teasertext'), {str}, any),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+            }),
+            **traverse_obj(video_target, {
+                'duration': ('duration', {int_or_none}),
+                'chapters': ('streamAnchorTag', {self._extract_chapters}),
+            }),
+            'thumbnails': self._extract_thumbnails(traverse_obj(content, ('teaserImageRef', 'layouts', {dict}))),
+            **traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
+                'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
+                'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
+                'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
+                'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
+                'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
+                'episode_number': ('episodeNumber', {int_or_none}),
+                'episode_id': ('contentId', {str}),
+            })),
+        })
diff --git a/yt_dlp/extractor/phoenix.py b/yt_dlp/extractor/phoenix.py
index 63c256019e98..9df34f8c911e 100644
--- a/yt_dlp/extractor/phoenix.py
+++ b/yt_dlp/extractor/phoenix.py
@@ -1,5 +1,3 @@
-import re
-
 from .youtube import YoutubeIE
 from .zdf import ZDFBaseIE
 from ..utils import (
@@ -7,44 +5,27 @@
     merge_dicts,
     try_get,
     unified_timestamp,
-    urljoin,
 )
 
 
 class PhoenixIE(ZDFBaseIE):
     IE_NAME = 'phoenix.de'
-    _VALID_URL = r'https?://(?:www\.)?phoenix\.de/(?:[^/]+/)*[^/?#&]*-a-(?P<id>\d+)\.html'
+    _VALID_URL = r'https?://(?:www\.)?phoenix\.de/(?:[^/?#]+/)*[^/?#&]*-a-(?P<id>\d+)\.html'
     _TESTS = [{
-        # Same as https://www.zdf.de/politik/phoenix-sendungen/wohin-fuehrt-der-protest-in-der-pandemie-100.html
-        'url': 'https://www.phoenix.de/sendungen/ereignisse/corona-nachgehakt/wohin-fuehrt-der-protest-in-der-pandemie-a-2050630.html',
-        'md5': '34ec321e7eb34231fd88616c65c92db0',
+        'url': 'https://www.phoenix.de/sendungen/dokumentationen/spitzbergen-a-893349.html',
+        'md5': 'a79e86d9774d0b3f2102aff988a0bd32',
         'info_dict': {
-            'id': '210222_phx_nachgehakt_corona_protest',
+            'id': '221215_phx_spitzbergen',
             'ext': 'mp4',
-            'title': 'Wohin führt der Protest in der Pandemie?',
-            'description': 'md5:7d643fe7f565e53a24aac036b2122fbd',
-            'duration': 1691,
-            'timestamp': 1613902500,
-            'upload_date': '20210221',
+            'title': 'Spitzbergen',
+            'description': 'Film von Tilmann Bünz',
+            'duration': 728.0,
+            'timestamp': 1555600500,
+            'upload_date': '20190418',
             'uploader': 'Phoenix',
-            'series': 'corona nachgehakt',
-            'episode': 'Wohin führt der Protest in der Pandemie?',
-        },
-    }, {
-        # Youtube embed
-        'url': 'https://www.phoenix.de/sendungen/gespraeche/phoenix-streitgut-brennglas-corona-a-1965505.html',
-        'info_dict': {
-            'id': 'hMQtqFYjomk',
-            'ext': 'mp4',
-            'title': 'phoenix streitgut: Brennglas Corona - Wie gerecht ist unsere Gesellschaft?',
-            'description': 'md5:ac7a02e2eb3cb17600bc372e4ab28fdd',
-            'duration': 3509,
-            'upload_date': '20201219',
-            'uploader': 'phoenix',
-            'uploader_id': 'phoenix',
-        },
-        'params': {
-            'skip_download': True,
+            'thumbnail': 'https://www.phoenix.de/sixcms/media.php/21/Bergspitzen1.png',
+            'series': 'Dokumentationen',
+            'episode': 'Spitzbergen',
         },
     }, {
         'url': 'https://www.phoenix.de/entwicklungen-in-russland-a-2044720.html',
@@ -90,8 +71,8 @@ def _real_extract(self, url):
         content_id = details['tracking']['nielsen']['content']['assetid']
 
         info = self._extract_ptmd(
-            f'https://tmd.phoenix.de/tmd/2/ngplayer_2_3/vod/ptmd/phoenix/{content_id}',
-            content_id, None, url)
+            f'https://tmd.phoenix.de/tmd/2/android_native_6/vod/ptmd/phoenix/{content_id}',
+            content_id)
 
         duration = int_or_none(try_get(
             details, lambda x: x['tracking']['nielsen']['content']['length']))
@@ -101,20 +82,8 @@ def _real_extract(self, url):
             str)
         episode = title if details.get('contentType') == 'episode' else None
 
-        thumbnails = []
         teaser_images = try_get(details, lambda x: x['teaserImageRef']['layouts'], dict) or {}
-        for thumbnail_key, thumbnail_url in teaser_images.items():
-            thumbnail_url = urljoin(url, thumbnail_url)
-            if not thumbnail_url:
-                continue
-            thumbnail = {
-                'url': thumbnail_url,
-            }
-            m = re.match('^([0-9]+)x([0-9]+)$', thumbnail_key)
-            if m:
-                thumbnail['width'] = int(m.group(1))
-                thumbnail['height'] = int(m.group(2))
-            thumbnails.append(thumbnail)
+        thumbnails = self._extract_thumbnails(teaser_images)
 
         return merge_dicts(info, {
             'id': content_id,
diff --git a/yt_dlp/extractor/zdf.py b/yt_dlp/extractor/zdf.py
index 4bef1017cc16..10be582a3321 100644
--- a/yt_dlp/extractor/zdf.py
+++ b/yt_dlp/extractor/zdf.py
@@ -1,331 +1,410 @@
+import itertools
+import json
 import re
+import time
 
 from .common import InfoExtractor
 from ..utils import (
-    NO_DEFAULT,
     ExtractorError,
     determine_ext,
+    filter_dict,
     float_or_none,
     int_or_none,
     join_nonempty,
-    merge_dicts,
+    make_archive_id,
     parse_codecs,
-    qualities,
-    traverse_obj,
-    try_get,
+    parse_iso8601,
+    parse_qs,
+    smuggle_url,
     unified_timestamp,
-    update_url_query,
+    unsmuggle_url,
     url_or_none,
     urljoin,
+    variadic,
 )
+from ..utils.traversal import require, traverse_obj
 
 
 class ZDFBaseIE(InfoExtractor):
     _GEO_COUNTRIES = ['DE']
-    _QUALITIES = ('auto', 'low', 'med', 'high', 'veryhigh', 'hd', 'fhd', 'uhd')
+    _TOKEN_CACHE_PARAMS = ('zdf', 'api-token')
+    _token_cache = {}
 
-    def _download_v2_doc(self, document_id):
-        return self._download_json(
-            f'https://zdf-prod-futura.zdf.de/mediathekV2/document/{document_id}',
-            document_id)
-
-    def _call_api(self, url, video_id, item, api_token=None, referrer=None):
-        headers = {}
-        if api_token:
-            headers['Api-Auth'] = f'Bearer {api_token}'
-        if referrer:
-            headers['Referer'] = referrer
+    def _get_api_token(self):
+        # As of 2025-03, this API is used by the Android app for getting tokens.
+        # An equivalent token could be extracted from the webpage should the API become unavailable.
+        # For now this allows the extractor to avoid dealing with Next.js hydration data.
+        if not self._token_cache:
+            self._token_cache.update(self.cache.load(*self._TOKEN_CACHE_PARAMS, default={}))
+
+        if traverse_obj(self._token_cache, ('expires', {int_or_none}), default=0) < int(time.time()):
+            self._token_cache.update(self._download_json(
+                'https://zdf-prod-futura.zdf.de/mediathekV2/token', None,
+                'Downloading API token', 'Failed to download API token'))
+            self.cache.store(*self._TOKEN_CACHE_PARAMS, self._token_cache)
+
+        return f'{self._token_cache["type"]} {self._token_cache["token"]}'
+
+    def _call_api(self, url, video_id, item, api_token=None):
         return self._download_json(
-            url, video_id, f'Downloading JSON {item}', headers=headers)
+            url, video_id, f'Downloading {item}', f'Failed to download {item}',
+            headers=filter_dict({'Api-Auth': api_token}))
+
+    def _parse_aspect_ratio(self, aspect_ratio):
+        if not aspect_ratio or not isinstance(aspect_ratio, str):
+            return None
+        mobj = re.match(r'(?P<width>\d+):(?P<height>\d+)', aspect_ratio)
+        return int(mobj.group('width')) / int(mobj.group('height')) if mobj else None
+
+    def _extract_chapters(self, data):
+        return traverse_obj(data, (lambda _, v: v['anchorOffset'], {
+            'start_time': ('anchorOffset', {float_or_none}),
+            'title': ('anchorLabel', {str}),
+        })) or None
 
     @staticmethod
     def _extract_subtitles(src):
+        seen_urls = set()
         subtitles = {}
-        for caption in try_get(src, lambda x: x['captions'], list) or []:
+        for caption in src:
             subtitle_url = url_or_none(caption.get('uri'))
-            if subtitle_url:
-                lang = caption.get('language', 'deu')
-                subtitles.setdefault(lang, []).append({
-                    'url': subtitle_url,
-                })
+            if not subtitle_url or subtitle_url in seen_urls:
+                continue
+            seen_urls.add(subtitle_url)
+            lang = caption.get('language') or 'deu'
+            subtitles.setdefault(lang, []).append({
+                'url': subtitle_url,
+            })
         return subtitles
 
-    def _extract_format(self, video_id, formats, format_urls, meta):
-        format_url = url_or_none(meta.get('url'))
-        if not format_url or format_url in format_urls:
-            return
-        format_urls.add(format_url)
-
-        mime_type, ext = meta.get('mimeType'), determine_ext(format_url)
-        if mime_type == 'application/x-mpegURL' or ext == 'm3u8':
-            new_formats = self._extract_m3u8_formats(
-                format_url, video_id, 'mp4', m3u8_id='hls',
-                entry_protocol='m3u8_native', fatal=False)
-        elif mime_type == 'application/f4m+xml' or ext == 'f4m':
-            new_formats = self._extract_f4m_formats(
-                update_url_query(format_url, {'hdcore': '3.7.0'}), video_id, f4m_id='hds', fatal=False)
-        elif ext == 'mpd':
-            new_formats = self._extract_mpd_formats(
-                format_url, video_id, mpd_id='dash', fatal=False)
-        else:
-            f = parse_codecs(meta.get('mimeCodec'))
-            if not f and meta.get('type'):
-                data = meta['type'].split('_')
-                if try_get(data, lambda x: x[2]) == ext:
-                    f = {'vcodec': data[0], 'acodec': data[1]}
-            f.update({
-                'url': format_url,
-                'format_id': join_nonempty('http', meta.get('type'), meta.get('quality')),
-                'tbr': int_or_none(self._search_regex(r'_(\d+)k_', format_url, 'tbr', default=None)),
-            })
-            new_formats = [f]
-        formats.extend(merge_dicts(f, {
-            'format_note': join_nonempty('quality', 'class', from_dict=meta, delim=', '),
-            'language': meta.get('language'),
-            'language_preference': 10 if meta.get('class') == 'main' else -10 if meta.get('class') == 'ad' else -1,
-            'quality': qualities(self._QUALITIES)(meta.get('quality')),
-        }) for f in new_formats)
-
-    def _extract_ptmd(self, ptmd_url, video_id, api_token, referrer):
-        ptmd = self._call_api(
-            ptmd_url, video_id, 'metadata', api_token, referrer)
-
-        content_id = ptmd.get('basename') or ptmd_url.split('/')[-1]
-
-        formats = []
-        track_uris = set()
-        for p in ptmd['priorityList']:
-            formitaeten = p.get('formitaeten')
-            if not isinstance(formitaeten, list):
-                continue
-            for f in formitaeten:
-                f_qualities = f.get('qualities')
-                if not isinstance(f_qualities, list):
-                    continue
-                for quality in f_qualities:
-                    tracks = try_get(quality, lambda x: x['audio']['tracks'], list)
-                    if not tracks:
-                        continue
-                    for track in tracks:
-                        self._extract_format(
-                            content_id, formats, track_uris, {
-                                'url': track.get('uri'),
-                                'type': f.get('type'),
-                                'mimeType': f.get('mimeType'),
-                                'quality': quality.get('quality'),
-                                'class': track.get('class'),
-                                'language': track.get('language'),
+    def _expand_ptmd_template(self, api_base_url, template):
+        return urljoin(api_base_url, template.replace('{playerId}', 'android_native_6'))
+
+    def _extract_ptmd(self, ptmd_urls, video_id, api_token=None, aspect_ratio=None):
+        content_id = None
+        duration = None
+        formats, src_captions = [], []
+        seen_urls = set()
+
+        for ptmd_url in variadic(ptmd_urls):
+            ptmd_url, smuggled_data = unsmuggle_url(ptmd_url, {})
+            # Is it a DGS variant? (*D*eutsche *G*ebärden*s*prache' / German Sign Language)
+            is_dgs = smuggled_data.get('vod_media_type') == 'DGS'
+            ptmd = self._call_api(ptmd_url, video_id, 'PTMD data', api_token)
+
+            basename = (
+                ptmd.get('basename')
+                # ptmd_url examples:
+                # https://api.zdf.de/tmd/2/android_native_6/vod/ptmd/mediathek/250328_sendung_hsh/3
+                # https://tmd.phoenix.de/tmd/2/android_native_6/vod/ptmd/phoenix/221215_phx_spitzbergen
+                or self._search_regex(r'/vod/ptmd/[^/?#]+/(\w+)', ptmd_url, 'content ID', default=None))
+            # If this is_dgs, then it's from ZDFIE and it only uses content_id for _old_archive_ids,
+            # and the old version of the extractor didn't extract DGS variants, so ignore basename
+            if not content_id and not is_dgs:
+                content_id = basename
+
+            if not duration:
+                duration = traverse_obj(ptmd, ('attributes', 'duration', 'value', {float_or_none(scale=1000)}))
+            src_captions += traverse_obj(ptmd, ('captions', ..., {dict}))
+
+            for stream in traverse_obj(ptmd, ('priorityList', ..., 'formitaeten', ..., {dict})):
+                for quality in traverse_obj(stream, ('qualities', ..., {dict})):
+                    for variant in traverse_obj(quality, ('audio', 'tracks', lambda _, v: url_or_none(v['uri']))):
+                        format_url = variant['uri']
+                        if format_url in seen_urls:
+                            continue
+                        seen_urls.add(format_url)
+                        ext = determine_ext(format_url)
+                        if ext == 'm3u8':
+                            fmts = self._extract_m3u8_formats(
+                                format_url, video_id, 'mp4', m3u8_id='hls', fatal=False)
+                        elif ext == 'mpd':
+                            fmts = self._extract_mpd_formats(
+                                format_url, video_id, mpd_id='dash', fatal=False)
+                        else:
+                            height = int_or_none(quality.get('highestVerticalResolution'))
+                            width = round(aspect_ratio * height) if aspect_ratio and height else None
+                            fmts = [{
+                                'url': format_url,
+                                **parse_codecs(quality.get('mimeCodec')),
+                                'height': height,
+                                'width': width,
+                                'format_id': join_nonempty('http', stream.get('type')),
+                                'tbr': int_or_none(self._search_regex(r'_(\d+)k_', format_url, 'tbr', default=None)),
+                            }]
+                        f_class = variant.get('class')
+                        for f in fmts:
+                            formats.append({
+                                **f,
+                                'format_id': join_nonempty(f.get('format_id'), is_dgs and 'dgs'),
+                                'format_note': join_nonempty(
+                                    f_class, is_dgs and 'German Sign Language', f.get('format_note'), delim=', '),
+                                'language': variant.get('language') or f.get('language'),
+                                'preference': -2 if is_dgs else -1,
+                                'language_preference': 10 if f_class == 'main' else -10 if f_class == 'ad' else -1,
                             })
 
-        duration = float_or_none(try_get(
-            ptmd, lambda x: x['attributes']['duration']['value']), scale=1000)
-
         return {
-            'extractor_key': ZDFIE.ie_key(),
-            'id': content_id,
+            'id': content_id or video_id,
             'duration': duration,
             'formats': formats,
-            'subtitles': self._extract_subtitles(ptmd),
-            '_format_sort_fields': ('tbr', 'res', 'quality', 'language_preference'),
+            'subtitles': self._extract_subtitles(src_captions),
         }
 
-    def _extract_player(self, webpage, video_id, fatal=True):
-        return self._parse_json(
-            self._search_regex(
-                r'(?s)data-zdfplayer-jsb=(["\'])(?P<json>{.+?})\1', webpage,
-                'player JSON', default='{}' if not fatal else NO_DEFAULT,
-                group='json'),
-            video_id)
-
-    def _extract_entry(self, url, player, content, video_id):
-        title = content.get('title') or content['teaserHeadline']
-
-        t = content['mainVideoContent']['http://zdf.de/rels/target']
-        ptmd_path = traverse_obj(t, (
-            (('streams', 'default'), None),
-            ('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
-        ), get_all=False)
-        if not ptmd_path:
-            raise ExtractorError('Could not extract ptmd_path')
-
-        info = self._extract_ptmd(
-            urljoin(url, ptmd_path.replace('{playerId}', 'android_native_5')), video_id, player['apiToken'], url)
-
-        thumbnails = []
-        layouts = try_get(
-            content, lambda x: x['teaserImageRef']['layouts'], dict)
-        if layouts:
-            for layout_key, layout_url in layouts.items():
-                layout_url = url_or_none(layout_url)
-                if not layout_url:
-                    continue
-                thumbnail = {
-                    'url': layout_url,
-                    'format_id': layout_key,
-                }
-                mobj = re.search(r'(?P<width>\d+)x(?P<height>\d+)', layout_key)
-                if mobj:
-                    thumbnail.update({
-                        'width': int(mobj.group('width')),
-                        'height': int(mobj.group('height')),
-                    })
-                thumbnails.append(thumbnail)
-
-        chapter_marks = t.get('streamAnchorTag') or []
-        chapter_marks.append({'anchorOffset': int_or_none(t.get('duration'))})
-        chapters = [{
-            'start_time': chap.get('anchorOffset'),
-            'end_time': next_chap.get('anchorOffset'),
-            'title': chap.get('anchorLabel'),
-        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])]
-
-        return merge_dicts(info, {
-            'title': title,
-            'description': content.get('leadParagraph') or content.get('teasertext'),
-            'duration': int_or_none(t.get('duration')),
-            'timestamp': unified_timestamp(content.get('editorialDate')),
-            'thumbnails': thumbnails,
-            'chapters': chapters or None,
-            'episode': title,
-            **traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
-                'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
-                'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
-                'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
-                'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
-                'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-                'episode_id': ('contentId', {str}),
-            })),
-        })
-
-    def _extract_regular(self, url, player, video_id, query=None):
-        player_url = player['content']
-
-        content = self._call_api(
-            update_url_query(player_url, query),
-            video_id, 'content', player['apiToken'], url)
-
-        return self._extract_entry(player_url, player, content, video_id)
-
-    def _extract_mobile(self, video_id):
-        video = self._download_v2_doc(video_id)
-
-        formats = []
-        formitaeten = try_get(video, lambda x: x['document']['formitaeten'], list)
-        document = formitaeten and video['document']
-        if formitaeten:
-            title = document['titel']
-            content_id = document['basename']
-
-            format_urls = set()
-            for f in formitaeten or []:
-                self._extract_format(content_id, formats, format_urls, f)
+    def _download_graphql(self, item_id, data_desc, query=None, body=None):
+        assert query or body, 'One of query or body is required'
 
-        thumbnails = []
-        teaser_bild = document.get('teaserBild')
-        if isinstance(teaser_bild, dict):
-            for thumbnail_key, thumbnail in teaser_bild.items():
-                thumbnail_url = try_get(
-                    thumbnail, lambda x: x['url'], str)
-                if thumbnail_url:
-                    thumbnails.append({
-                        'url': thumbnail_url,
-                        'id': thumbnail_key,
-                        'width': int_or_none(thumbnail.get('width')),
-                        'height': int_or_none(thumbnail.get('height')),
-                    })
+        return self._download_json(
+            'https://api.zdf.de/graphql', item_id,
+            f'Downloading {data_desc}', f'Failed to download {data_desc}',
+            query=query, data=json.dumps(body).encode() if body else None,
+            headers=filter_dict({
+                'Api-Auth': self._get_api_token(),
+                'Apollo-Require-Preflight': True,
+                'Content-Type': 'application/json' if body else None,
+            }))
 
-        return {
-            'id': content_id,
-            'title': title,
-            'description': document.get('beschreibung'),
-            'duration': int_or_none(document.get('length')),
-            'timestamp': unified_timestamp(document.get('date')) or unified_timestamp(
-                try_get(video, lambda x: x['meta']['editorialDate'], str)),
-            'thumbnails': thumbnails,
-            'subtitles': self._extract_subtitles(document),
-            'formats': formats,
-        }
+    @staticmethod
+    def _extract_thumbnails(source):
+        return [{
+            'id': str(format_id),
+            'url': url,
+            'preference': 1 if format_id == 'original' else 0,
+            **traverse_obj(re.search(r'(?P<width>\d+|auto)[Xx](?P<height>\d+|auto)', str(format_id)), {
+                'width': ('width', {int_or_none}),
+                'height': ('height', {int_or_none}),
+            }),
+        } for format_id, url in traverse_obj(source, ({dict.items}, lambda _, v: url_or_none(v[1])))]
 
 
 class ZDFIE(ZDFBaseIE):
-    _VALID_URL = r'https?://www\.zdf\.de/(?:[^/]+/)*(?P<id>[^/?#&]+)\.html'
+    _VALID_URL = [
+        r'https?://(?:www\.)?zdf\.de/(?:video|play)/(?:[^/?#]+/)*(?P<id>[^/?#]+)',
+        # /nachrichten/ sub-site URLs and legacy redirects from before the redesign in 2025-03
+        r'https?://(?:www\.)?zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#]+)\.html',
+    ]
+    IE_NAME = 'zdf'
     _TESTS = [{
-        # Same as https://www.phoenix.de/sendungen/ereignisse/corona-nachgehakt/wohin-fuehrt-der-protest-in-der-pandemie-a-2050630.html
-        'url': 'https://www.zdf.de/politik/phoenix-sendungen/wohin-fuehrt-der-protest-in-der-pandemie-100.html',
-        'md5': '34ec321e7eb34231fd88616c65c92db0',
+        # Standalone video (i.e. not part of a playlist), video URL
+        'url': 'https://www.zdf.de/video/dokus/sylt---deutschlands-edles-nordlicht-movie-100/sylt-deutschlands-edles-nordlicht-100',
         'info_dict': {
-            'id': '210222_phx_nachgehakt_corona_protest',
+            'id': 'sylt-deutschlands-edles-nordlicht-100',
             'ext': 'mp4',
-            'title': 'Wohin führt der Protest in der Pandemie?',
-            'description': 'md5:7d643fe7f565e53a24aac036b2122fbd',
-            'duration': 1691,
-            'timestamp': 1613948400,
-            'upload_date': '20210221',
+            'title': 'Sylt - Deutschlands edles Nordlicht',
+            'description': 'md5:35407b810c2e1e33efbe15ef6e4c06c3',
+            'duration': 810.0,
+            'thumbnail': 'https://www.zdf.de/assets/sylt-118~original?cb=1613992485011',
+            'series': 'Sylt - Deutschlands edles Nordlicht',
+            'series_id': 'sylt---deutschlands-edles-nordlicht-movie-100',
+            'timestamp': 1612462500,
+            'upload_date': '20210204',
+            '_old_archive_ids': ['zdf 210402_1915_sendung_dok'],
         },
-        'skip': 'No longer available: "Diese Seite wurde leider nicht gefunden"',
     }, {
-        # Same as https://www.3sat.de/film/ab-18/10-wochen-sommer-108.html
-        'url': 'https://www.zdf.de/dokumentation/ab-18/10-wochen-sommer-102.html',
-        'md5': '0aff3e7bc72c8813f5e0fae333316a1d',
+        # Standalone video (i.e. not part of a playlist), play URL
+        'url': 'https://www.zdf.de/play/dokus/sylt---deutschlands-edles-nordlicht-movie-100/sylt-deutschlands-edles-nordlicht-100',
         'info_dict': {
-            'id': '141007_ab18_10wochensommer_film',
+            'id': 'sylt-deutschlands-edles-nordlicht-100',
             'ext': 'mp4',
-            'title': 'Ab 18! - 10 Wochen Sommer',
-            'description': 'md5:8253f41dc99ce2c3ff892dac2d65fe26',
-            'duration': 2660,
-            'timestamp': 1608604200,
-            'upload_date': '20201222',
+            'title': 'Sylt - Deutschlands edles Nordlicht',
+            'description': 'md5:35407b810c2e1e33efbe15ef6e4c06c3',
+            'duration': 810.0,
+            'thumbnail': 'https://www.zdf.de/assets/sylt-118~original?cb=1613992485011',
+            'series': 'Sylt - Deutschlands edles Nordlicht',
+            'series_id': 'sylt---deutschlands-edles-nordlicht-movie-100',
+            'timestamp': 1612462500,
+            'upload_date': '20210204',
+            '_old_archive_ids': ['zdf 210402_1915_sendung_dok'],
         },
-        'skip': 'No longer available: "Diese Seite wurde leider nicht gefunden"',
+        'params': {'skip_download': True},
     }, {
-        'url': 'https://www.zdf.de/nachrichten/heute-journal/heute-journal-vom-30-12-2021-100.html',
+        # Standalone video (i.e. not part of a playlist), legacy URL before website redesign in 2025-03
+        'url': 'https://www.zdf.de/dokumentation/dokumentation-sonstige/sylt-deutschlands-edles-nordlicht-100.html',
         'info_dict': {
-            'id': '211230_sendung_hjo',
+            'id': 'sylt-deutschlands-edles-nordlicht-100',
             'ext': 'mp4',
-            'description': 'md5:47dff85977bde9fb8cba9e9c9b929839',
-            'duration': 1890.0,
-            'upload_date': '20211230',
-            'chapters': list,
-            'thumbnail': 'md5:e65f459f741be5455c952cd820eb188e',
-            'title': 'heute journal vom 30.12.2021',
-            'timestamp': 1640897100,
+            'title': 'Sylt - Deutschlands edles Nordlicht',
+            'description': 'md5:35407b810c2e1e33efbe15ef6e4c06c3',
+            'duration': 810.0,
+            'thumbnail': 'https://www.zdf.de/assets/sylt-118~original?cb=1613992485011',
+            'series': 'Sylt - Deutschlands edles Nordlicht',
+            'series_id': 'sylt---deutschlands-edles-nordlicht-movie-100',
+            'timestamp': 1612462500,
+            'upload_date': '20210204',
+            '_old_archive_ids': ['zdf 210402_1915_sendung_dok'],
         },
-        'skip': 'No longer available: "Diese Seite wurde leider nicht gefunden"',
+        'params': {'skip_download': True},
     }, {
-        'url': 'https://www.zdf.de/dokumentation/terra-x/die-magie-der-farben-von-koenigspurpur-und-jeansblau-100.html',
+        # Video belongs to a playlist, video URL
+        'url': 'https://www.zdf.de/video/dokus/die-magie-der-farben-116/die-magie-der-farben-von-koenigspurpur-und-jeansblau-100',
+        'md5': '1eda17eb40a9ead3046326e10b9c5973',
         'info_dict': {
-            'id': '151025_magie_farben2_tex',
+            'id': 'die-magie-der-farben-von-koenigspurpur-und-jeansblau-100',
             'ext': 'mp4',
+            'title': 'Von Königspurpur bis Jeansblau',
+            'description': 'md5:a89da10c928c6235401066b60a6d5c1a',
             'duration': 2615.0,
-            'title': 'Die Magie der Farben (2/2)',
+            'thumbnail': 'https://www.zdf.de/assets/koenigspurpur-bis-jeansblau-100~original?cb=1741857765971',
+            'series': 'Die Magie der Farben',
+            'series_id': 'die-magie-der-farben-116',
+            'season': 'Season 1',
+            'season_number': 1,
+            'episode': 'Episode 2',
+            'episode_number': 2,
+            'timestamp': 1445797800,
+            'upload_date': '20151025',
+            '_old_archive_ids': ['zdf 151025_magie_farben2_tex'],
+        },
+    }, {
+        # Video belongs to a playlist, play URL
+        'url': 'https://www.zdf.de/play/dokus/die-magie-der-farben-116/die-magie-der-farben-von-koenigspurpur-und-jeansblau-100',
+        'md5': '1eda17eb40a9ead3046326e10b9c5973',
+        'info_dict': {
+            'id': 'die-magie-der-farben-von-koenigspurpur-und-jeansblau-100',
+            'ext': 'mp4',
+            'title': 'Von Königspurpur bis Jeansblau',
             'description': 'md5:a89da10c928c6235401066b60a6d5c1a',
-            'timestamp': 1465021200,
-            'thumbnail': 'https://www.zdf.de/assets/mauve-im-labor-100~768x432?cb=1464909117806',
-            'upload_date': '20160604',
-            'episode': 'Die Magie der Farben (2/2)',
-            'episode_id': 'POS_954f4170-36a5-4a41-a6cf-78f1f3b1f127',
-            'season': 'Staffel 1',
+            'duration': 2615.0,
+            'thumbnail': 'https://www.zdf.de/assets/koenigspurpur-bis-jeansblau-100~original?cb=1741857765971',
             'series': 'Die Magie der Farben',
+            'series_id': 'die-magie-der-farben-116',
+            'season': 'Season 1',
             'season_number': 1,
-            'series_id': 'a39900dd-cdbd-4a6a-a413-44e8c6ae18bc',
-            'season_id': '5a92e619-8a0f-4410-a3d5-19c76fbebb37',
+            'episode': 'Episode 2',
             'episode_number': 2,
+            'timestamp': 1445797800,
+            'upload_date': '20151025',
+            '_old_archive_ids': ['zdf 151025_magie_farben2_tex'],
+        },
+        'params': {'skip_download': True},
+    }, {
+        # Video belongs to a playlist, legacy URL before website redesign in 2025-03
+        'url': 'https://www.zdf.de/dokumentation/terra-x/die-magie-der-farben-von-koenigspurpur-und-jeansblau-100.html',
+        'md5': '1eda17eb40a9ead3046326e10b9c5973',
+        'info_dict': {
+            'id': 'die-magie-der-farben-von-koenigspurpur-und-jeansblau-100',
+            'ext': 'mp4',
+            'title': 'Von Königspurpur bis Jeansblau',
+            'description': 'md5:a89da10c928c6235401066b60a6d5c1a',
+            'duration': 2615.0,
+            'thumbnail': 'https://www.zdf.de/assets/koenigspurpur-bis-jeansblau-100~original?cb=1741857765971',
+            'series': 'Die Magie der Farben',
+            'series_id': 'die-magie-der-farben-116',
+            'season': 'Season 1',
+            'season_number': 1,
+            'episode': 'Episode 2',
+            'episode_number': 2,
+            'timestamp': 1445797800,
+            'upload_date': '20151025',
+            '_old_archive_ids': ['zdf 151025_magie_farben2_tex'],
+        },
+        'params': {'skip_download': True},
+    }, {
+        # Video with chapters
+        # Also: video with sign-language variant
+        'url': 'https://www.zdf.de/video/magazine/heute-journal-104/heute-journal-vom-19-12-2021-100',
+        'md5': '6ada39465497a84fb98d48ffff69e7b7',
+        'info_dict': {
+            'id': 'heute-journal-vom-19-12-2021-100',
+            'ext': 'mp4',
+            'title': 'heute journal vom 19.12.2021',
+            'description': 'md5:02504cf3b03777ff32fcc927d260c5dd',
+            'duration': 1770.0,
+            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/273e5545-16e7-4ca3-898e-52fe9e06d964?layout=1920x1080',
+            'chapters': 'count:11',
+            'series': 'heute journal',
+            'series_id': 'heute-journal-104',
+            'season': 'Season 2021',
+            'season_number': 2021,
+            'episode': 'Episode 370',
+            'episode_number': 370,
+            'timestamp': 1639946700,
+            'upload_date': '20211219',
+            # Videos with sign language variants must not have a 'dgs' suffix on their old archive IDs.
+            '_old_archive_ids': ['zdf 211219_sendung_hjo'],
+        },
+    }, {
+        # Video that requires fallback extraction
+        'url': 'https://www.zdf.de/nachrichten/politik/deutschland/koalitionsverhandlungen-spd-cdu-csu-dobrindt-100.html',
+        'md5': 'c3a78514dd993a5781aa3afe50db51e2',
+        'info_dict': {
+            'id': 'koalitionsverhandlungen-spd-cdu-csu-dobrindt-100',
+            'ext': 'mp4',
+            'title': 'Dobrindt schließt Steuererhöhungen aus',
+            'description': 'md5:9a117646d7b8df6bc902eb543a9c9023',
+            'duration': 325,
+            'thumbnail': 'https://www.zdf.de/assets/dobrindt-csu-berlin-direkt-100~1920x1080?cb=1743357653736',
+            'timestamp': 1743374520,
+            'upload_date': '20250330',
+            '_old_archive_ids': ['zdf 250330_clip_2_bdi'],
         },
     }, {
         'url': 'https://www.zdf.de/funk/druck-11790/funk-alles-ist-verzaubert-102.html',
         'md5': '57af4423db0455a3975d2dc4578536bc',
         'info_dict': {
+            'id': 'funk-alles-ist-verzaubert-102',
             'ext': 'mp4',
-            'id': 'video_funk_1770473',
-            'duration': 1278.0,
             'title': 'Alles ist verzaubert',
             'description': 'Die Neue an der Schule verdreht Ismail den Kopf.',
+            'duration': 1278.0,
+            'thumbnail': 'https://www.zdf.de/assets/teaser-funk-alles-ist-verzaubert-102~original?cb=1663848412907',
+            'series': 'DRUCK',
+            'series_id': 'funk-collection-funk-11790-1590',
+            'season': 'Season 7',
+            'season_number': 7,
+            'episode': 'Episode 1',
+            'episode_number': 1,
             'timestamp': 1635520560,
-            'thumbnail': 'https://www.zdf.de/assets/teaser-funk-alles-ist-verzaubert-102~1920x1080?cb=1663848412907',
             'upload_date': '20211029',
-            'episode': 'Alles ist verzaubert',
+            '_old_archive_ids': ['zdf video_funk_1770473'],
+        },
+    }, {
+        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
+        'info_dict': {
+            'id': 'das-geld-anderer-leute-100',
+            'ext': 'mp4',
+            'title': 'Das Geld anderer Leute',
+            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
+            'duration': 2581.0,
+            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=1920x1080',
+            'series': 'SOKO Stuttgart',
+            'series_id': 'soko-stuttgart-104',
+            'season': 'Season 11',
+            'season_number': 11,
+            'episode': 'Episode 10',
+            'episode_number': 10,
+            'timestamp': 1728983700,
+            'upload_date': '20241015',
+            '_old_archive_ids': ['zdf 191205_1800_sendung_sok8'],
+        },
+    }, {
+        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
+        'info_dict': {
+            'id': 'begegnung-auf-der-bruecke-100',
+            'ext': 'webm',
+            'title': 'Begegnung auf der Brücke',
+            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
+            'duration': 3083.0,
+            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=1920x1080',
+            'series': 'Northern Lights',
+            'series_id': 'northern-lights-100',
+            'season': 'Season 1',
+            'season_number': 1,
+            'episode': 'Episode 1',
+            'episode_number': 1,
+            'timestamp': 1738546500,
+            'upload_date': '20250203',
+            '_old_archive_ids': ['zdf 240319_2310_sendung_not'],
         },
+        'params': {'skip_download': 'geo-restricted http format'},
+    }, {
+        # Same as https://www.phoenix.de/sendungen/ereignisse/corona-nachgehakt/wohin-fuehrt-der-protest-in-der-pandemie-a-2050630.html
+        'url': 'https://www.zdf.de/politik/phoenix-sendungen/wohin-fuehrt-der-protest-in-der-pandemie-100.html',
+        'only_matching': True,
+    }, {
+        # Same as https://www.3sat.de/film/ab-18/10-wochen-sommer-108.html
+        'url': 'https://www.zdf.de/dokumentation/ab-18/10-wochen-sommer-102.html',
+        'only_matching': True,
     }, {
         # Same as https://www.phoenix.de/sendungen/dokumentationen/gesten-der-maechtigen-i-a-89468.html?ref=suche
         'url': 'https://www.zdf.de/politik/phoenix-sendungen/die-gesten-der-maechtigen-100.html',
@@ -349,155 +428,323 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
+    def _extract_ptmd(self, *args, **kwargs):
+        ptmd_data = super()._extract_ptmd(*args, **kwargs)
+        # This was the video id before the graphql redesign, other extractors still use it as such
+        old_archive_id = ptmd_data.pop('id')
+        ptmd_data['_old_archive_ids'] = [make_archive_id(self, old_archive_id)]
+        return ptmd_data
+
+    # This fallback should generally only happen for pages under `zdf.de/nachrichten`.
+    # They are on a separate website for which GraphQL often doesn't return results.
+    # The API used here is no longer in use by official clients and likely deprecated.
+    # Long-term, news documents probably should use the API used by the mobile apps:
+    # https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+    def _extract_fallback(self, document_id):
+        video = self._download_json(
+            f'https://zdf-prod-futura.zdf.de/mediathekV2/document/{document_id}',
+            document_id, note='Downloading fallback metadata',
+            errnote='Failed to download fallback metadata')
+        document = video['document']
+
+        ptmd_url = traverse_obj(document, (
+            ('streamApiUrlAndroid', ('streams', 0, 'streamApiUrlAndroid')),
+            {url_or_none}, any, {require('PTMD URL')}))
+
+        thumbnails = []
+        for thumbnail_key, thumbnail in traverse_obj(document, ('teaserBild', {dict.items}, ...)):
+            thumbnail_url = traverse_obj(thumbnail, ('url', {url_or_none}))
+            if not thumbnail_url:
+                continue
+            thumbnails.append({
+                'url': thumbnail_url,
+                'id': thumbnail_key,
+                'width': int_or_none(thumbnail.get('width')),
+                'height': int_or_none(thumbnail.get('height')),
+            })
+
+        return {
+            'thumbnails': thumbnails,
+            **traverse_obj(video, {
+                'title': ('document', 'titel', {str}),
+                'description': ('document', 'beschreibung', {str}),
+                'timestamp': (
+                    (('document', 'date'), ('meta', 'editorialDate')),
+                    {unified_timestamp}, any),
+                'subtitles': ('document', 'captions', {self._extract_subtitles}),
+            }),
+            **self._extract_ptmd(ptmd_url, document_id, self._get_api_token()),
+            'id': document_id,
+        }
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })['data']['videoByCanonical']
+
+        if not video_data:
+            return self._extract_fallback(video_id)
+
+        aspect_ratio = None
+        ptmd_urls = []
+        for node in traverse_obj(video_data, ('currentMedia', 'nodes', lambda _, v: v['ptmdTemplate'])):
+            ptmd_url = self._expand_ptmd_template('https://api.zdf.de', node['ptmdTemplate'])
+            # Smuggle vod_media_type so that _extract_ptmd is aware of 'DGS' variants
+            if vod_media_type := node.get('vodMediaType'):
+                ptmd_url = smuggle_url(ptmd_url, {'vod_media_type': vod_media_type})
+            ptmd_urls.append(ptmd_url)
+            if not aspect_ratio:
+                aspect_ratio = self._parse_aspect_ratio(node.get('aspectRatio'))
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
-
-        return self._extract_mobile(video_id)
+        return {
+            **traverse_obj(video_data, {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, 'streamAnchorTags', 'nodes', {self._extract_chapters}),
+            }),
+            **self._extract_ptmd(ptmd_urls, video_id, self._get_api_token(), aspect_ratio),
+            'id': video_id,
+        }
 
 
 class ZDFChannelIE(ZDFBaseIE):
-    _VALID_URL = r'https?://www\.zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#&]+)'
+    _VALID_URL = r'https?://www\.zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#]+)'
+    IE_NAME = 'zdf:channel'
     _TESTS = [{
+        # Playlist, legacy URL before website redesign in 2025-03
         'url': 'https://www.zdf.de/sport/das-aktuelle-sportstudio',
         'info_dict': {
-            'id': 'das-aktuelle-sportstudio',
+            'id': 'das-aktuelle-sportstudio-220',
             'title': 'das aktuelle sportstudio',
+            'description': 'md5:e46c785324238a03edcf8b301c5fd5dc',
         },
-        'playlist_mincount': 18,
+        'playlist_mincount': 25,
     }, {
-        'url': 'https://www.zdf.de/dokumentation/planet-e',
+        # Playlist, current URL
+        'url': 'https://www.zdf.de/sport/das-aktuelle-sportstudio-220',
         'info_dict': {
-            'id': 'planet-e',
-            'title': 'planet e.',
-            'description': 'md5:87e3b9c66a63cf1407ee443d2c4eb88e',
+            'id': 'das-aktuelle-sportstudio-220',
+            'title': 'das aktuelle sportstudio',
+            'description': 'md5:e46c785324238a03edcf8b301c5fd5dc',
         },
-        'playlist_mincount': 50,
+        'playlist_mincount': 25,
+    }, {
+        # Standalone video (i.e. not part of a playlist), collection URL
+        'add_ie': [ZDFIE.ie_key()],
+        'url': 'https://www.zdf.de/dokus/sylt---deutschlands-edles-nordlicht-movie-100',
+        'info_dict': {
+            'id': 'sylt-deutschlands-edles-nordlicht-100',
+            'ext': 'mp4',
+            'title': 'Sylt - Deutschlands edles Nordlicht',
+            'description': 'md5:35407b810c2e1e33efbe15ef6e4c06c3',
+            'duration': 810.0,
+            'thumbnail': 'https://www.zdf.de/assets/sylt-118~original?cb=1613992485011',
+            'series': 'Sylt - Deutschlands edles Nordlicht',
+            'series_id': 'sylt---deutschlands-edles-nordlicht-movie-100',
+            'timestamp': 1612462500,
+            'upload_date': '20210204',
+            '_old_archive_ids': ['zdf 210402_1915_sendung_dok'],
+        },
+        'params': {'skip_download': True},
     }, {
         'url': 'https://www.zdf.de/gesellschaft/aktenzeichen-xy-ungeloest',
         'info_dict': {
-            'id': 'aktenzeichen-xy-ungeloest',
+            'id': 'aktenzeichen-xy-ungeloest-110',
             'title': 'Aktenzeichen XY... Ungelöst',
-            'description': 'md5:623ede5819c400c6d04943fa8100e6e7',
+            'description': 'md5:b79ac0d64b979e53cbe510c0ca9cb7be',
         },
         'playlist_mincount': 2,
     }, {
         'url': 'https://www.zdf.de/serien/taunuskrimi/',
-        'only_matching': True,
+        'info_dict': {
+            'id': 'taunuskrimi-100',
+            'title': 'Taunuskrimi',
+            'description': 'md5:ee7204e9c625c3b611d1274f9d0e3070',
+        },
+        'playlist_mincount': 8,
+    }, {
+        'url': 'https://www.zdf.de/serien/taunuskrimi/?staffel=1',
+        'info_dict': {
+            'id': 'taunuskrimi-100-s1',
+            'title': 'Taunuskrimi - Season 1',
+            'description': 'md5:ee7204e9c625c3b611d1274f9d0e3070',
+        },
+        'playlist_count': 7,
+    }, {
+        'url': 'https://www.zdf.de/magazine/heute-journal-104',
+        'info_dict': {
+            'id': 'heute-journal-104',
+            'title': 'heute journal',
+            'description': 'md5:6edad39189abf8431795d3d6d7f986b3',
+        },
+        'playlist_mincount': 500,
+    }, {
+        'url': 'https://www.zdf.de/magazine/heute-journal-104?staffel=2024',
+        'info_dict': {
+            'id': 'heute-journal-104-s2024',
+            'title': 'heute journal - Season 2024',
+            'description': 'md5:6edad39189abf8431795d3d6d7f986b3',
+        },
+        'playlist_count': 242,
     }]
 
+    _PAGE_SIZE = 24
+
     @classmethod
     def suitable(cls, url):
         return False if ZDFIE.suitable(url) else super().suitable(url)
 
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
+    def _fetch_page(self, playlist_id, canonical_id, season_idx, season_number, page_number, cursor=None):
+        return self._download_graphql(
+            playlist_id, f'season {season_number} page {page_number} JSON', query={
+                'operationName': 'seasonByCanonical',
+                'variables': json.dumps(filter_dict({
+                    'seasonIndex': season_idx,
+                    'canonical': canonical_id,
+                    'episodesPageSize': self._PAGE_SIZE,
+                    'episodesAfter': cursor,
+                })),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': '9412a0f4ac55dc37d46975d461ec64bfd14380d815df843a1492348f77b5c99a',
+                    },
+                }),
+            })['data']['smartCollectionByCanonical']
+
+    def _entries(self, playlist_id, canonical_id, season_numbers, requested_season_number):
+        for season_idx, season_number in enumerate(season_numbers):
+            if requested_season_number is not None and requested_season_number != season_number:
+                continue
 
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+            cursor = None
+            for page_number in itertools.count(1):
+                page = self._fetch_page(
+                    playlist_id, canonical_id, season_idx, season_number, page_number, cursor)
+
+                nodes = traverse_obj(page, ('seasons', 'nodes', ...))
+
+                for episode in traverse_obj(nodes, (
+                    ..., 'episodes', 'nodes', lambda _, v: url_or_none(v['sharingUrl']),
+                )):
+                    yield self.url_result(
+                        episode['sharingUrl'], ZDFIE,
+                        **traverse_obj(episode, {
+                            'id': ('canonical', {str}),
+                            'title': ('teaser', 'title', {str}),
+                            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                            'timestamp': ('editorialDate', {parse_iso8601}),
+                            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                        }))
+
+                page_info = traverse_obj(nodes, (-1, 'episodes', 'pageInfo', {dict})) or {}
+                if not page_info.get('hasNextPage') or not page_info.get('endCursor'):
+                    break
+                cursor = page_info['endCursor']
 
     def _real_extract(self, url):
-        channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
+        canonical_id = self._match_id(url)
+        # Make sure to get the correct ID in case of redirects
+        urlh = self._request_webpage(url, canonical_id)
+        canonical_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+        season_number = traverse_obj(parse_qs(url), ('staffel', -1, {int_or_none}))
+        playlist_id = join_nonempty(canonical_id, season_number and f's{season_number}')
+
+        collection_data = self._download_graphql(
+            playlist_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': canonical_id,
+                    'videoPageSize': 100,  # Use max page size to get episodes from all seasons
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })['data']['smartCollectionByCanonical']
+        video_data = traverse_obj(collection_data, ('video', {dict})) or {}
+        season_numbers = traverse_obj(collection_data, ('seasons', 'seasons', ..., 'number', {int_or_none}))
+
+        if not self._yes_playlist(
+            season_numbers and playlist_id,
+            url_or_none(video_data.get('sharingUrl')) and video_data.get('canonical'),
+        ):
+            return self.url_result(video_data['sharingUrl'], ZDFIE, video_data['canonical'])
+
+        if season_number is not None and season_number not in season_numbers:
+            raise ExtractorError(f'Season {season_number} was not found in the collection data')
 
         return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+            self._entries(playlist_id, canonical_id, season_numbers, season_number),
+            playlist_id, join_nonempty(
+                traverse_obj(collection_data, ('title', {str})),
+                season_number and f'Season {season_number}', delim=' - '),
+            traverse_obj(collection_data, ('infoText', {str})))


Reviews:
user: login: seproDev
body: Initial review
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-30T21:57:26Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T12:11:48Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T20:49:26Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T21:31:05Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T21:43:33Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T21:45:12Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T21:47:15Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T11:20:06Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T11:21:51Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T20:08:54Z
user: login: bashonly
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T09:53:22Z
user: login: InvalidUsernameException
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T11:19:09Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T18:37:43Z
user: login: bashonly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T18:38:13Z
user: login: bashonly
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T18:38:48Z
user: login: seproDev
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T21:57:18Z

Review comments:
diff_hunk: @@ -1,50 +1,31 @@
-import re
-
 from .youtube import YoutubeIE
 from .zdf import ZDFBaseIE
 from ..utils import (
     int_or_none,
     merge_dicts,
     try_get,
     unified_timestamp,
-    urljoin,
 )
 
 
 class PhoenixIE(ZDFBaseIE):
     IE_NAME = 'phoenix.de'
     _VALID_URL = r'https?://(?:www\.)?phoenix\.de/(?:[^/]+/)*[^/?#&]*-a-(?P<id>\d+)\.html'
path: yt_dlp/extractor/phoenix.py
user: login: seproDev
body: Might as well fix this
```suggestion
    _VALID_URL = r'https?://(?:www\.)?phoenix\.de/(?:[^/?#]+/)*[^/?#&]*-a-(?P<id>\d+)\.html'
```
created_at: 2025-03-30T19:49:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,13 +67,67 @@ class DreiSatIE(ZDFBaseIE):
         'only_matching': True,
     }]
 
+    def _extract_player(self, webpage, video_id, fatal=True):
+        return self._parse_json(
+            self._search_regex(
+                r'(?s)data-zdfplayer-jsb=(["\'])(?P<json>{.+?})\1', webpage,
+                'player JSON', default='{}' if not fatal else NO_DEFAULT,
+                group='json'),
+            video_id)
+
+    def _extract_regular(self, url, player, video_id):
+        player_url = player['content']
+        api_token = f'Bearer {player["apiToken"]}'
+
+        content = self._call_api(player_url, video_id, 'video metadata', api_token)
+        return self._extract_entry(player_url, api_token, content, video_id)
+
+    def _extract_entry(self, url, api_token, content, video_id):
+        title = content.get('title') or content['teaserHeadline']
+
+        t = content['mainVideoContent']['http://zdf.de/rels/target']
+        ptmd_path = traverse_obj(t, (
+            (('streams', 'default'), None),
+            ('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
+        ), get_all=False)
+        if not ptmd_path:
+            raise ExtractorError('Could not extract ptmd_path')
+
+        info = self._extract_ptmd(url, ptmd_path, video_id, api_token)
+        layouts = try_get(
+            content, lambda x: x['teaserImageRef']['layouts'], dict)
+        thumbnails = self._extract_thumbnails(layouts)
+
+        chapter_marks = t.get('streamAnchorTag') or []
+        chapter_marks.append({'anchorOffset': int_or_none(t.get('duration'))})
+        chapters = [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])]
+
+        return merge_dicts(info, {
+            'title': title,
+            'description': content.get('leadParagraph') or content.get('teasertext'),
+            'duration': int_or_none(t.get('duration')),
+            'timestamp': unified_timestamp(content.get('editorialDate')),
+            'thumbnails': thumbnails,
+            'chapters': chapters or None,
+            'episode': title,
+            **traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
+                'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
+                'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
+                'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
+                'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
+                'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
+                'episode_number': ('episodeNumber', {int_or_none}),
+                'episode_id': ('contentId', {str}),
+            })),
+        })
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id)
-
-        return self._extract_mobile(video_id)
+        webpage = self._download_webpage(url, video_id)
+        player = self._extract_player(webpage, url)
path: yt_dlp/extractor/dreisat.py
user: login: seproDev
body: I would just inline this:
```suggestion
        player = self._search_json(
            r'data-zdfplayer-jsb=(["\'])', webpage, 'player JSON', video_id)
```
created_at: 2025-03-30T19:58:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,13 +67,67 @@ class DreiSatIE(ZDFBaseIE):
         'only_matching': True,
path: yt_dlp/extractor/dreisat.py
user: login: seproDev
body: Please add a test which includes chapters: https://www.3sat.de/kultur/buchmesse/dein-buch-das-beste-von-der-leipziger-buchmesse-2025-teil-1-100.html
created_at: 2025-03-30T20:14:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,13 +67,67 @@ class DreiSatIE(ZDFBaseIE):
         'only_matching': True,
     }]
 
+    def _extract_player(self, webpage, video_id, fatal=True):
+        return self._parse_json(
+            self._search_regex(
+                r'(?s)data-zdfplayer-jsb=(["\'])(?P<json>{.+?})\1', webpage,
+                'player JSON', default='{}' if not fatal else NO_DEFAULT,
+                group='json'),
+            video_id)
+
+    def _extract_regular(self, url, player, video_id):
+        player_url = player['content']
+        api_token = f'Bearer {player["apiToken"]}'
+
+        content = self._call_api(player_url, video_id, 'video metadata', api_token)
+        return self._extract_entry(player_url, api_token, content, video_id)
+
+    def _extract_entry(self, url, api_token, content, video_id):
+        title = content.get('title') or content['teaserHeadline']
+
+        t = content['mainVideoContent']['http://zdf.de/rels/target']
+        ptmd_path = traverse_obj(t, (
+            (('streams', 'default'), None),
+            ('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
+        ), get_all=False)
+        if not ptmd_path:
+            raise ExtractorError('Could not extract ptmd_path')
+
+        info = self._extract_ptmd(url, ptmd_path, video_id, api_token)
+        layouts = try_get(
+            content, lambda x: x['teaserImageRef']['layouts'], dict)
+        thumbnails = self._extract_thumbnails(layouts)
+
+        chapter_marks = t.get('streamAnchorTag') or []
+        chapter_marks.append({'anchorOffset': int_or_none(t.get('duration'))})
+        chapters = [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])]
+
+        return merge_dicts(info, {
+            'title': title,
+            'description': content.get('leadParagraph') or content.get('teasertext'),
+            'duration': int_or_none(t.get('duration')),
+            'timestamp': unified_timestamp(content.get('editorialDate')),
+            'thumbnails': thumbnails,
+            'chapters': chapters or None,
+            'episode': title,
+            **traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
+                'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
+                'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
+                'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
+                'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
+                'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
+                'episode_number': ('episodeNumber', {int_or_none}),
+                'episode_id': ('contentId', {str}),
+            })),
+        })
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id)
-
-        return self._extract_mobile(video_id)
+        webpage = self._download_webpage(url, video_id)
+        player = self._extract_player(webpage, url)
+        return self._extract_regular(url, player, video_id)
path: yt_dlp/extractor/dreisat.py
user: login: seproDev
body: I would also inline the other part of the extraction since we don't re-use it. I've also simplified the extraction a bit:
```suggestion
        player_url = player['content']
        api_token = f'Bearer {player["apiToken"]}'

        content = self._call_api(player_url, video_id, 'video metadata', api_token)

        video_target = content['mainVideoContent']['http://zdf.de/rels/target']
        ptmd_path = traverse_obj(video_target, (
            (('streams', 'default'), None),
            ('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
            {str}, any, {require('ptmd path')}))
        info = self._extract_ptmd(player_url, ptmd_path, video_id, api_token)

        return merge_dicts(info, {
            **traverse_obj(content, {
                'title': (('title', 'teaserHeadline'), {str}, any),
                'episode': (('title', 'teaserHeadline'), {str}, any),
                'description': (('leadParagraph', 'teasertext'), {str}, any),
                'timestamp': ('editorialDate', {parse_iso8601}),
            }),
            **traverse_obj(video_target, {
                'duration': ('duration', {int_or_none}),
                'chapters': ('streamAnchorTag', lambda _, v: v['anchorOffset'], {
                    'start_time': ('anchorOffset', {float_or_none}),
                    'title': ('anchorLabel', {str}),
                }),
            }),
            'thumbnails': self._extract_thumbnails(traverse_obj(content, ('teaserImageRef', 'layouts', {dict}))),
            **traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
                'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
                'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
                'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
                'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
                'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
                'episode_number': ('episodeNumber', {int_or_none}),
                'episode_id': ('contentId', {str}),
            })),
        })
```
created_at: 2025-03-30T20:26:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,108 +410,160 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    @classmethod
+    def suitable(cls, url):
+        return False if ZDFHeuteIE.suitable(url) else super().suitable(url)
+
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })
+        # TODO: If there are multiple PTMD templates,
+        # usually one of them is a sign-language variant of the video.
+        # The format order works out fine as is and prefers the "normal" video,
+        # but this should probably be made more explicit.
+        ptmd_templates = traverse_obj(
+            video_data,
+            ('data', 'videoByCanonical', 'currentMedia', 'nodes', ..., 'ptmdTemplate'))
+        ptmd_data = self._extract_ptmd(
+            'https://api.zdf.de', ptmd_templates, video_id,
+            self._get_api_token(video_id))
+        # We can't use the ID from PTMD extraction as the video ID
+        # because it is not available during playlist extraction.
+        # We fix it here manually instead of inside the method
+        # because other extractors do rely on using it as their ID.
+        ptmd_data['_old_archive_ids'] = [ptmd_data['id']]
+        del ptmd_data['id']
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
+        return {
+            'id': video_id,
+            **ptmd_data,
+            **traverse_obj(video_data, ('data', 'videoByCanonical', {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, {self._extract_chapters}),
+            })),
+        }
 
-        return self._extract_mobile(video_id)
+    def _extract_chapters(self, data):
+        chapter_marks = traverse_obj(data, ('streamAnchorTags', 'nodes'))
+        chapter_marks.append({'anchorOffset': float_or_none(data.get('duration'))})
+        return [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])] or None
 
 
-class ZDFChannelIE(ZDFBaseIE):
-    _VALID_URL = r'https?://www\.zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#&]+)'
+class ZDFCollectionIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: seproDev
body: We should not change the class name. If you want to rename the extractor use `IE_NAME`.
created_at: 2025-03-30T20:28:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: seproDev
body: I'm not entirely sure why you added this. From my testing the `VideoByCanonical` query from ZDFIE work fine for news content.
created_at: 2025-03-30T20:39:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,246 +85,308 @@ def _extract_format(self, video_id, formats, format_urls, meta):
             'quality': qualities(self._QUALITIES)(meta.get('quality')),
         }) for f in new_formats)
 
-    def _extract_ptmd(self, ptmd_url, video_id, api_token, referrer):
-        ptmd = self._call_api(
-            ptmd_url, video_id, 'metadata', api_token, referrer)
-
-        content_id = ptmd.get('basename') or ptmd_url.split('/')[-1]
+    def _extract_ptmd(self, api_base_url, templates, video_id, api_token=None):
path: yt_dlp/extractor/zdf.py
user: login: seproDev
body: I would have probably combined this with the `_extract_formats` function like so:
```suggestion
    def _extract_ptmd(self, api_base_url, templates, video_id, api_token=None):
        templates = variadic(templates)
        src_captions = []

        content_id = None
        duration = None
        formats = []
        track_uris = set()

        for template in templates:
            ptmd_url = urljoin(api_base_url, template.replace(
                '{playerId}', 'android_native_6'))
            ptmd = self._call_api(ptmd_url, video_id, 'PTMD data', api_token)
            content_id = content_id or ptmd.get('basename') or ptmd_url.split('/')[-1]
            duration = (duration or traverse_obj(ptmd, ('attributes', 'duration', 'value', {float_or_none(scale=1000)})))
            src_captions += ptmd.get('captions') or []
            for stream in traverse_obj(ptmd, ('priorityList', ..., 'formitaeten', ..., {dict})):
                for quality in traverse_obj(stream, ('qualities', ..., {dict})):
                    for varient in traverse_obj(quality, ('audio', 'tracks', lambda _, v: url_or_none(v['uri']))):
                        format_url = varient['uri']
                        if format_url in track_uris:
                            continue
                        track_uris.add(format_url)
                        ext = determine_ext(format_url)
                        if ext == 'm3u8':
                            fmts = self._extract_m3u8_formats(
                                format_url, video_id, 'mp4', m3u8_id='hls', fatal=False)
                        elif ext == 'mpd':
                            fmts = self._extract_mpd_formats(
                                format_url, video_id, mpd_id='dash', fatal=False)
                        else:
                            fmts = [{
                                'url': format_url,
                                **parse_codecs(quality.get('mimeCodec')),
                                'height': int_or_none(quality.get('highestVerticalResolution')),
                                'format_id': join_nonempty('http', stream.get('type'), quality.get('quality')),
                                'tbr': int_or_none(self._search_regex(r'_(\d+)k_', format_url, 'tbr', default=None)),
                            }]

                        formats.extend(merge_dicts(f, {
                            'format_note': join_nonempty(quality.get('quality'), varient.get('class'), delim=', '),
                            'language': varient.get('language'),
                            'language_preference': 10 if varient.get('class') == 'main' else -10 if varient.get('class') == 'ad' else -1,
                            'quality': qualities(self._QUALITIES)(quality.get('quality')),
                        }) for f in fmts)

        return {
            'extractor_key': ZDFIE.ie_key(),
            'id': content_id,
            'duration': duration,
            'formats': formats,
            'subtitles': self._extract_subtitles(src_captions),
            '_format_sort_fields': ('tbr', 'res', 'quality', 'language_preference'),
        }
```
created_at: 2025-03-30T21:42:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,108 +410,160 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    @classmethod
+    def suitable(cls, url):
+        return False if ZDFHeuteIE.suitable(url) else super().suitable(url)
+
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })
+        # TODO: If there are multiple PTMD templates,
+        # usually one of them is a sign-language variant of the video.
+        # The format order works out fine as is and prefers the "normal" video,
+        # but this should probably be made more explicit.
+        ptmd_templates = traverse_obj(
+            video_data,
+            ('data', 'videoByCanonical', 'currentMedia', 'nodes', ..., 'ptmdTemplate'))
+        ptmd_data = self._extract_ptmd(
+            'https://api.zdf.de', ptmd_templates, video_id,
+            self._get_api_token(video_id))
+        # We can't use the ID from PTMD extraction as the video ID
+        # because it is not available during playlist extraction.
+        # We fix it here manually instead of inside the method
+        # because other extractors do rely on using it as their ID.
+        ptmd_data['_old_archive_ids'] = [ptmd_data['id']]
+        del ptmd_data['id']
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
+        return {
+            'id': video_id,
+            **ptmd_data,
+            **traverse_obj(video_data, ('data', 'videoByCanonical', {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, {self._extract_chapters}),
+            })),
+        }
 
-        return self._extract_mobile(video_id)
+    def _extract_chapters(self, data):
+        chapter_marks = traverse_obj(data, ('streamAnchorTags', 'nodes'))
+        chapter_marks.append({'anchorOffset': float_or_none(data.get('duration'))})
+        return [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])] or None
path: yt_dlp/extractor/zdf.py
user: login: seproDev
body: This function is identical to the one in 3Sat?
We can probably make both use the same function from the base class. This was the traversal I used in the 3Sat extractor:
```py
            **traverse_obj(video_target, {
                'duration': ('duration', {int_or_none}),
                'chapters': ('streamAnchorTag', lambda _, v: v['anchorOffset'], {
                    'start_time': ('anchorOffset', {float_or_none}),
                    'title': ('anchorLabel', {str}),
                }),
            }),
```
we don't need to write the end time as yt-dlp core will take care of that.
created_at: 2025-03-30T21:56:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: Looks like I chose a poor test case here because I didn't double-check that it fails without the extractor. For example the following URLs extract on `master` but fail on the PR without this:
* https://www.zdf.de/nachrichten/politik/deutschland/koalitionsverhandlungen-spd-cdu-csu-dobrindt-100.html
* https://www.zdf.de/nachrichten/heute-sendungen/putin-vorschlag-un-verwaltung-ukraine-neuwahl-video-100.html

Looks like the `_extract_mobile()` goes [back to a commit from 2017](https://github.com/ytdl-org/youtube-dl/commit/fb47cb5b235badeb40afb2de1bc56d98982e40fb) (+ related PR), that introduced it as a fallback for extraction of videos from the main site. It happened to also make it possible to download some of the videos from the news sub-site, but it does not necessarily look like that was intentional.

This change obviously breaks any archive files should people have used them on news downloads, because the URLs are moved in a separate extractor. Maintaining this proper inside `ZDFIE` IMHO seems like a bad choice long term though. A proper implementation of the extractor should also be able to handle more things like extracting multiple videos on a single page (like in the first example). And that would be completely different from what `ZDFIE` does.

So this seemed like a compromise to keep at least the basic functionality in place. What do you prefer here? I can:
* Change the tests to make it clear what functionality is being provided.
* Merge this back into `ZDFIE` to keep archives working.
* Remove the extractor entirely and wait for a bug report about it not working before implementing it.
created_at: 2025-03-31T12:11:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,13 +67,67 @@ class DreiSatIE(ZDFBaseIE):
         'only_matching': True,
path: yt_dlp/extractor/dreisat.py
user: login: InvalidUsernameException
body: Good point, should have one for 3sat as well.
created_at: 2025-03-31T20:39:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,13 +67,67 @@ class DreiSatIE(ZDFBaseIE):
         'only_matching': True,
     }]
 
+    def _extract_player(self, webpage, video_id, fatal=True):
+        return self._parse_json(
+            self._search_regex(
+                r'(?s)data-zdfplayer-jsb=(["\'])(?P<json>{.+?})\1', webpage,
+                'player JSON', default='{}' if not fatal else NO_DEFAULT,
+                group='json'),
+            video_id)
+
+    def _extract_regular(self, url, player, video_id):
+        player_url = player['content']
+        api_token = f'Bearer {player["apiToken"]}'
+
+        content = self._call_api(player_url, video_id, 'video metadata', api_token)
+        return self._extract_entry(player_url, api_token, content, video_id)
+
+    def _extract_entry(self, url, api_token, content, video_id):
+        title = content.get('title') or content['teaserHeadline']
+
+        t = content['mainVideoContent']['http://zdf.de/rels/target']
+        ptmd_path = traverse_obj(t, (
+            (('streams', 'default'), None),
+            ('http://zdf.de/rels/streams/ptmd', 'http://zdf.de/rels/streams/ptmd-template'),
+        ), get_all=False)
+        if not ptmd_path:
+            raise ExtractorError('Could not extract ptmd_path')
+
+        info = self._extract_ptmd(url, ptmd_path, video_id, api_token)
+        layouts = try_get(
+            content, lambda x: x['teaserImageRef']['layouts'], dict)
+        thumbnails = self._extract_thumbnails(layouts)
+
+        chapter_marks = t.get('streamAnchorTag') or []
+        chapter_marks.append({'anchorOffset': int_or_none(t.get('duration'))})
+        chapters = [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])]
+
+        return merge_dicts(info, {
+            'title': title,
+            'description': content.get('leadParagraph') or content.get('teasertext'),
+            'duration': int_or_none(t.get('duration')),
+            'timestamp': unified_timestamp(content.get('editorialDate')),
+            'thumbnails': thumbnails,
+            'chapters': chapters or None,
+            'episode': title,
+            **traverse_obj(content, ('programmeItem', 0, 'http://zdf.de/rels/target', {
+                'series_id': ('http://zdf.de/rels/cmdm/series', 'seriesUuid', {str}),
+                'series': ('http://zdf.de/rels/cmdm/series', 'seriesTitle', {str}),
+                'season': ('http://zdf.de/rels/cmdm/season', 'seasonTitle', {str}),
+                'season_number': ('http://zdf.de/rels/cmdm/season', 'seasonNumber', {int_or_none}),
+                'season_id': ('http://zdf.de/rels/cmdm/season', 'seasonUuid', {str}),
+                'episode_number': ('episodeNumber', {int_or_none}),
+                'episode_id': ('contentId', {str}),
+            })),
+        })
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id)
-
-        return self._extract_mobile(video_id)
+        webpage = self._download_webpage(url, video_id)
+        player = self._extract_player(webpage, url)
+        return self._extract_regular(url, player, video_id)
path: yt_dlp/extractor/dreisat.py
user: login: InvalidUsernameException
body: I originally didn't inline them to avoid introducing more changes, but yeah, it makes sense here.
created_at: 2025-03-31T20:42:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,246 +85,308 @@ def _extract_format(self, video_id, formats, format_urls, meta):
             'quality': qualities(self._QUALITIES)(meta.get('quality')),
         }) for f in new_formats)
 
-    def _extract_ptmd(self, ptmd_url, video_id, api_token, referrer):
-        ptmd = self._call_api(
-            ptmd_url, video_id, 'metadata', api_token, referrer)
-
-        content_id = ptmd.get('basename') or ptmd_url.split('/')[-1]
+    def _extract_ptmd(self, api_base_url, templates, video_id, api_token=None):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: I'll leave this as is until the question about `ZDFHeuteIE` is resolved. At the moment `_extract_format()` is called from there as well.
created_at: 2025-03-31T20:45:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,108 +410,160 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    @classmethod
+    def suitable(cls, url):
+        return False if ZDFHeuteIE.suitable(url) else super().suitable(url)
+
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })
+        # TODO: If there are multiple PTMD templates,
+        # usually one of them is a sign-language variant of the video.
+        # The format order works out fine as is and prefers the "normal" video,
+        # but this should probably be made more explicit.
+        ptmd_templates = traverse_obj(
+            video_data,
+            ('data', 'videoByCanonical', 'currentMedia', 'nodes', ..., 'ptmdTemplate'))
+        ptmd_data = self._extract_ptmd(
+            'https://api.zdf.de', ptmd_templates, video_id,
+            self._get_api_token(video_id))
+        # We can't use the ID from PTMD extraction as the video ID
+        # because it is not available during playlist extraction.
+        # We fix it here manually instead of inside the method
+        # because other extractors do rely on using it as their ID.
+        ptmd_data['_old_archive_ids'] = [ptmd_data['id']]
+        del ptmd_data['id']
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
+        return {
+            'id': video_id,
+            **ptmd_data,
+            **traverse_obj(video_data, ('data', 'videoByCanonical', {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, {self._extract_chapters}),
+            })),
+        }
 
-        return self._extract_mobile(video_id)
+    def _extract_chapters(self, data):
+        chapter_marks = traverse_obj(data, ('streamAnchorTags', 'nodes'))
+        chapter_marks.append({'anchorOffset': float_or_none(data.get('duration'))})
+        return [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])] or None
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: I've unified them into a single function.

> we don't need to write the end time as yt-dlp core will take care of that.

That is very useful. I went by the documentation which made it seem like `end_time` is required. Maybe that's something that can be updated in one of those "Misc" PRs.
created_at: 2025-03-31T20:48:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,108 +410,160 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    @classmethod
+    def suitable(cls, url):
+        return False if ZDFHeuteIE.suitable(url) else super().suitable(url)
+
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })
+        # TODO: If there are multiple PTMD templates,
+        # usually one of them is a sign-language variant of the video.
+        # The format order works out fine as is and prefers the "normal" video,
+        # but this should probably be made more explicit.
+        ptmd_templates = traverse_obj(
+            video_data,
+            ('data', 'videoByCanonical', 'currentMedia', 'nodes', ..., 'ptmdTemplate'))
+        ptmd_data = self._extract_ptmd(
+            'https://api.zdf.de', ptmd_templates, video_id,
+            self._get_api_token(video_id))
+        # We can't use the ID from PTMD extraction as the video ID
+        # because it is not available during playlist extraction.
+        # We fix it here manually instead of inside the method
+        # because other extractors do rely on using it as their ID.
+        ptmd_data['_old_archive_ids'] = [ptmd_data['id']]
+        del ptmd_data['id']
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
+        return {
+            'id': video_id,
+            **ptmd_data,
+            **traverse_obj(video_data, ('data', 'videoByCanonical', {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, {self._extract_chapters}),
+            })),
+        }
 
-        return self._extract_mobile(video_id)
+    def _extract_chapters(self, data):
+        chapter_marks = traverse_obj(data, ('streamAnchorTags', 'nodes'))
+        chapter_marks.append({'anchorOffset': float_or_none(data.get('duration'))})
+        return [{
+            'start_time': chap.get('anchorOffset'),
+            'end_time': next_chap.get('anchorOffset'),
+            'title': chap.get('anchorLabel'),
+        } for chap, next_chap in zip(chapter_marks, chapter_marks[1:])] or None
 
 
-class ZDFChannelIE(ZDFBaseIE):
-    _VALID_URL = r'https?://www\.zdf\.de/(?:[^/?#]+/)*(?P<id>[^/?#&]+)'
+class ZDFCollectionIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: I thought it would be fine because it is only the playlist extractor. But is is not that important, so I've reverted it.
created_at: 2025-03-31T20:48:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: bashonly
body: > This change obviously breaks any archive files 

you can prevent breakage by returning `_old_archive_ids` in the info dict

> Maintaining this proper inside `ZDFIE` IMHO seems like a bad choice long term though

What makes less sense to me, though, is adding a new extractor class for a deprecated page format

`_download_v2_doc` and `_extract_mobile` can be inlined in `_real_extract` so that we have a single method for extraction, and then we could move that single method into `ZDFIE` and rename it `_extract_legacy` or something
created_at: 2025-03-31T21:31:04Z
author_association: MEMBER
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: > you can prevent breakage by returning _old_archive_ids in the info dict

Yeah, if it is back in `ZDFIE`. I was referring to it being part of a separate extractor. Or is there a way to keep compatibility when moving it to a different extractor?

> What makes less sense to me, though, is adding a new extractor class for a deprecated page format

I don't think the page format is deprecated though. It is just a separate page hosted under the same domain that seems to share some of the CMS and CDN infrastructure, just like 3sat or Phoenix do. I don't think there is any way of knowing whether they have plans to change/update that sub-site at all.

What likely is deprecated though is the API behind `_download_v2_doc` and `_extract_mobile`, which is no longer used by any official clients as far as I can tell. As the code comment above the extractor hints, this should probably be replaced by a different implementation using the API currently in use. I didn't do that yet, because the old method still happens to work for now. Though I suspect that has always just been a happy coincidence.
created_at: 2025-03-31T21:43:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: bashonly
body: > Yeah, if it is back in ZDFIE. I was referring to it being part of a separate extractor. Or is there a way to keep compatibility when moving it to a different extractor?

Yes

```py
'_old_archive_ids': [make_archive_id(ZDFIE, video_id)],
```
created_at: 2025-03-31T21:45:11Z
author_association: MEMBER
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: bashonly
body: > What likely is deprecated though is the API behind _download_v2_doc and _extract_mobile, which is no longer used by any official clients as far as I can tell. 

This is enough reason to not delegate all `/nachrichten/` links to the "new" extractor. IMO  this should be a single legacy method in `ZDFIE` that is called as a fallback when the new extraction fails
created_at: 2025-03-31T21:47:15Z
author_association: MEMBER
diff_hunk: @@ -460,44 +573,140 @@ class ZDFChannelIE(ZDFBaseIE):
 
     @classmethod
     def suitable(cls, url):
-        return False if ZDFIE.suitable(url) else super().suitable(url)
-
-    def _extract_entry(self, entry):
-        return self.url_result(
-            entry['sharingUrl'], ZDFIE, **traverse_obj(entry, {
-                'id': ('basename', {str}),
-                'title': ('titel', {str}),
-                'description': ('beschreibung', {str}),
-                'duration': ('length', {float_or_none}),
-                'season_number': ('seasonNumber', {int_or_none}),
-                'episode_number': ('episodeNumber', {int_or_none}),
-            }))
-
-    def _entries(self, data, document_id):
-        for entry in traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaser',
-            # If 'brandId' differs, it is a 'You might also like' video. Filter these out
-            'teaser', lambda _, v: v['type'] == 'video' and v['brandId'] == document_id and v['sharingUrl'],
-        )):
-            yield self._extract_entry(entry)
+        return False if ZDFIE.suitable(url) or ZDFHeuteIE.suitable(url) else super().suitable(url)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
-        webpage = self._download_webpage(url, channel_id)
-        document_id = self._search_regex(
-            r'docId\s*:\s*(["\'])(?P<doc_id>(?:(?!\1).)+)\1', webpage, 'document id', group='doc_id')
-        data = self._download_v2_doc(document_id)
-
-        main_video = traverse_obj(data, (
-            'cluster', lambda _, v: v['type'] == 'teaserContent',
-            'teaser', lambda _, v: v['type'] == 'video' and v['basename'] and v['sharingUrl'], any)) or {}
-
-        if not self._yes_playlist(channel_id, main_video.get('basename')):
-            return self._extract_entry(main_video)
-
-        return self.playlist_result(
-            self._entries(data, document_id), channel_id,
-            re.split(r'\s+[-|]\s+ZDF(?:mediathek)?$', self._og_search_title(webpage) or '')[0] or None,
-            join_nonempty(
-                'headline', 'text', delim='\n\n',
-                from_dict=traverse_obj(data, ('shortText', {dict}), default={})) or None)
+        webpage, urlh = self._download_webpage_handle(url, channel_id)
+        # Make sure to get the correct ID in case of redirects
+        channel_id = self._search_regex(self._VALID_URL, urlh.url, 'channel id', group='id')
+
+        collection_data = self._download_graphql(
+            channel_id, 'smart collection data', query={
+                'operationName': 'GetSmartCollectionByCanonical',
+                'variables': json.dumps({
+                    'canonical': channel_id,
+                    'videoPageSize': 100,
+                }),
+                'extensions': json.dumps({
+                    'persistedQuery': {
+                        'version': 1,
+                        'sha256Hash': 'cb49420e133bd668ad895a8cea0e65cba6aa11ac1cacb02341ff5cf32a17cd02',
+                    },
+                }),
+            })
+
+        video_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'video'))
+        season_data = traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'seasons'))
+
+        if not self._yes_playlist(
+                channel_id if season_data else None,
+                traverse_obj(video_data, ('canonical', {str}))):
+            return self.url_result(video_data['sharingUrl'], ie=ZDFIE)
+
+        title = (traverse_obj(collection_data, ('data', 'smartCollectionByCanonical', 'title'))
+                 or self._html_search_meta(
+            ['og:title', 'title', 'twitter:title'], webpage, 'title', fatal=False))
+
+        needs_pagination = traverse_obj(season_data, (
+            'seasons', ..., 'episodes', 'pageInfo', 'hasNextPage',
+            lambda _, v: v is True, any), default=False)
+        if needs_pagination:
+            # TODO: Implement pagination for collections with long seasons
+            # e.g. https://www.zdf.de/magazine/heute-journal-104
+            self.report_warning('This collections contains seasons with more than 100 episodes, some episodes are missing from the result.')
+
+        videos = traverse_obj(season_data, ('seasons', ..., 'episodes', 'videos', ...))
+        season_id = parse_qs(url).get('staffel', [None])[-1]
+        videos = [v for v in videos
+                  if not season_id or season_id == str(traverse_obj(v, ('episodeInfo', 'seasonNumber')))]
+        entries = [self.url_result(video['sharingUrl'], ZDFIE, **traverse_obj(video, {
+            'id': ('canonical', {str}),
+            'title': ('teaser', 'title', {str}),
+            'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+            'timestamp': ('editorialDate', {parse_iso8601}),
+            'thumbnails': ('teaser', 'imageWithoutLogo', 'layouts', {self._extract_thumbnails}),
+            'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+            'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+            'series_id': {lambda _: channel_id},
+            'series': {lambda _: title},
+        })) for video in videos or [] if video.get('currentMediaType') != 'NOVIDEO']
+
+        return self.playlist_result(entries, channel_id, title, traverse_obj(
+            collection_data, ('data', 'smartCollectionByCanonical', 'infoText', {str})))
+
+
+# TODO: This extractor is a minimal effort implementation and incomplete.
+# It only does what is necessary to get back the functionality that was present
+# before the redesign of the ZDF website in 2025-03.
+# It uses an API that is no longer used by offical clients,
+# and likely never was at all for the purpase the extractor uses it for.
+# A proper implementation should likely use the API of the mobile app instead:
+# https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+class ZDFHeuteIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: I've merged it back into the main extractor again.
created_at: 2025-04-01T11:10:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,246 +85,308 @@ def _extract_format(self, video_id, formats, format_urls, meta):
             'quality': qualities(self._QUALITIES)(meta.get('quality')),
         }) for f in new_formats)
 
-    def _extract_ptmd(self, ptmd_url, video_id, api_token, referrer):
-        ptmd = self._call_api(
-            ptmd_url, video_id, 'metadata', api_token, referrer)
-
-        content_id = ptmd.get('basename') or ptmd_url.split('/')[-1]
+    def _extract_ptmd(self, api_base_url, templates, video_id, api_token=None):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: As per the other comment, the code from `ZDFHeuteIE` has been merged into the main `ZDFIE`. So it looks like we'll have two callers for this function long term. I don't like the weird way the data is passed, but I guess it will stay a separate function then.
created_at: 2025-04-01T11:19:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,246 +85,308 @@ def _extract_format(self, video_id, formats, format_urls, meta):
             'quality': qualities(self._QUALITIES)(meta.get('quality')),
         }) for f in new_formats)
 
-    def _extract_ptmd(self, ptmd_url, video_id, api_token, referrer):
-        ptmd = self._call_api(
-            ptmd_url, video_id, 'metadata', api_token, referrer)
-
-        content_id = ptmd.get('basename') or ptmd_url.split('/')[-1]
+    def _extract_ptmd(self, api_base_url, templates, video_id, api_token=None):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: Well, I guess I can apply the `traverse_obj` changes still.
created_at: 2025-04-01T11:21:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -86,246 +85,308 @@ def _extract_format(self, video_id, formats, format_urls, meta):
             'quality': qualities(self._QUALITIES)(meta.get('quality')),
         }) for f in new_formats)
 
-    def _extract_ptmd(self, ptmd_url, video_id, api_token, referrer):
-        ptmd = self._call_api(
-            ptmd_url, video_id, 'metadata', api_token, referrer)
-
-        content_id = ptmd.get('basename') or ptmd_url.split('/')[-1]
+    def _extract_ptmd(self, api_base_url, templates, video_id, api_token=None):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: I think I've taken over the general improvements from your suggestion now. Some of the logic for codecs had to stay though for the `mediathekV2` API.
created_at: 2025-04-01T20:08:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,108 +425,194 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
+    def _extract_ptmd(self, *args, **kwargs):
+        ptmd_data = super()._extract_ptmd(*args, **kwargs)
+        # This was the video id before the graphql redesign, other extractors still use it as such
+        old_archive_id = ptmd_data.pop('id')
+        ptmd_data['_old_archive_ids'] = [make_archive_id(self, old_archive_id)]
+        return ptmd_data
+
+    # This fallback should generally only happen for pages under `zdf.de/nachrichten`.
+    # They are on a separate website for which GraphQL often doesn't return results.
+    # The API used here is no longer in use by official clients and likely deprecated.
+    # Long-term, news documents probably should use the API used by the mobile apps:
+    # https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+    def _extract_fallback(self, document_id):
+        video = self._download_json(
+            f'https://zdf-prod-futura.zdf.de/mediathekV2/document/{document_id}',
+            document_id, note='Downloading fallback metadata',
+            errnote='Failed to download fallback metadata')
+        document = video['document']
+
+        ptmd_url = traverse_obj(document, (
+            ('streamApiUrlAndroid', ('streams', 0, 'streamApiUrlAndroid')),
+            {url_or_none}, any, {require('PTMD URL')}))
+
+        thumbnails = []
+        for thumbnail_key, thumbnail in traverse_obj(document, ('teaserBild', {dict.items}, ...)):
+            thumbnail_url = traverse_obj(thumbnail, ('url', {url_or_none}))
+            if not thumbnail_url:
+                continue
+            thumbnails.append({
+                'url': thumbnail_url,
+                'id': thumbnail_key,
+                'width': int_or_none(thumbnail.get('width')),
+                'height': int_or_none(thumbnail.get('height')),
+            })
+
+        return {
+            'thumbnails': thumbnails,
+            **traverse_obj(video, {
+                'title': ('document', 'titel', {str}),
+                'description': ('document', 'beschreibung', {str}),
+                'timestamp': (
+                    (('document', 'date'), ('meta', 'editorialDate')),
+                    {unified_timestamp}, any),
+                'subtitles': ('document', 'captions', {self._extract_subtitles}),
+            }),
+            **self._extract_ptmd(ptmd_url, document_id, self._get_api_token()),
+            'id': document_id,
+        }
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })['data']['videoByCanonical']
+
+        if not video_data:
+            return self._extract_fallback(video_id)
+
+        aspect_ratio = None
+        ptmd_urls = []
+        for node in traverse_obj(video_data, ('currentMedia', 'nodes', lambda _, v: v['ptmdTemplate'])):
+            ptmd_url = self._expand_ptmd_template('https://api.zdf.de', node['ptmdTemplate'])
+            # Smuggle vod_media_type so that _extract_ptmd is aware of 'DGS' variants
+            if vod_media_type := node.get('vodMediaType'):
+                ptmd_url = smuggle_url(ptmd_url, {'vod_media_type': vod_media_type})
+            ptmd_urls.append(ptmd_url)
+            if not aspect_ratio:
+                aspect_ratio = self._parse_aspect_ratio(node.get('aspectRatio'))
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
-
-        return self._extract_mobile(video_id)
+        return {
+            **traverse_obj(video_data, {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, 'streamAnchorTags', 'nodes', {self._extract_chapters}),
+            }),
+            **self._extract_ptmd(ptmd_urls, video_id, self._get_api_token(), aspect_ratio),
+            'id': video_id,
+        }
 
 
 class ZDFChannelIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: bashonly
body: Let's do this for the time being; we don't have pagination, and the site doesn't use the `GetSmartCollectionByCanonical` operation for all types of playlists, e.g. `seasonByCanonical` is used for `serien` URLs. This can wait for another PR, I'd like to merge what we've got
```suggestion
class ZDFChannelIE(ZDFBaseIE):
    _WORKING = False
```
created_at: 2025-04-30T09:53:15Z
author_association: MEMBER
diff_hunk: @@ -393,6 +393,7 @@ class ZDFIE(ZDFBaseIE):
             'upload_date': '20250203',
             '_old_archive_ids': ['zdf 240319_2310_sendung_not'],
         },
+        'params': {'skip_download': 'geo-restricted m3u8'},
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: This must actually be a geo-restricted HTTP download. The m3u8 formats do not enforce geo-restrictions. Not entirely sure why the HTTP download is preferred here, on the command line it prefers the m3u8 format. Do tests have their own format preference?
created_at: 2025-04-30T11:04:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,108 +425,194 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
+    def _extract_ptmd(self, *args, **kwargs):
+        ptmd_data = super()._extract_ptmd(*args, **kwargs)
+        # This was the video id before the graphql redesign, other extractors still use it as such
+        old_archive_id = ptmd_data.pop('id')
+        ptmd_data['_old_archive_ids'] = [make_archive_id(self, old_archive_id)]
+        return ptmd_data
+
+    # This fallback should generally only happen for pages under `zdf.de/nachrichten`.
+    # They are on a separate website for which GraphQL often doesn't return results.
+    # The API used here is no longer in use by official clients and likely deprecated.
+    # Long-term, news documents probably should use the API used by the mobile apps:
+    # https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+    def _extract_fallback(self, document_id):
+        video = self._download_json(
+            f'https://zdf-prod-futura.zdf.de/mediathekV2/document/{document_id}',
+            document_id, note='Downloading fallback metadata',
+            errnote='Failed to download fallback metadata')
+        document = video['document']
+
+        ptmd_url = traverse_obj(document, (
+            ('streamApiUrlAndroid', ('streams', 0, 'streamApiUrlAndroid')),
+            {url_or_none}, any, {require('PTMD URL')}))
+
+        thumbnails = []
+        for thumbnail_key, thumbnail in traverse_obj(document, ('teaserBild', {dict.items}, ...)):
+            thumbnail_url = traverse_obj(thumbnail, ('url', {url_or_none}))
+            if not thumbnail_url:
+                continue
+            thumbnails.append({
+                'url': thumbnail_url,
+                'id': thumbnail_key,
+                'width': int_or_none(thumbnail.get('width')),
+                'height': int_or_none(thumbnail.get('height')),
+            })
+
+        return {
+            'thumbnails': thumbnails,
+            **traverse_obj(video, {
+                'title': ('document', 'titel', {str}),
+                'description': ('document', 'beschreibung', {str}),
+                'timestamp': (
+                    (('document', 'date'), ('meta', 'editorialDate')),
+                    {unified_timestamp}, any),
+                'subtitles': ('document', 'captions', {self._extract_subtitles}),
+            }),
+            **self._extract_ptmd(ptmd_url, document_id, self._get_api_token()),
+            'id': document_id,
+        }
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })['data']['videoByCanonical']
+
+        if not video_data:
+            return self._extract_fallback(video_id)
+
+        aspect_ratio = None
+        ptmd_urls = []
+        for node in traverse_obj(video_data, ('currentMedia', 'nodes', lambda _, v: v['ptmdTemplate'])):
+            ptmd_url = self._expand_ptmd_template('https://api.zdf.de', node['ptmdTemplate'])
+            # Smuggle vod_media_type so that _extract_ptmd is aware of 'DGS' variants
+            if vod_media_type := node.get('vodMediaType'):
+                ptmd_url = smuggle_url(ptmd_url, {'vod_media_type': vod_media_type})
+            ptmd_urls.append(ptmd_url)
+            if not aspect_ratio:
+                aspect_ratio = self._parse_aspect_ratio(node.get('aspectRatio'))
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
-
-        return self._extract_mobile(video_id)
+        return {
+            **traverse_obj(video_data, {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, 'streamAnchorTags', 'nodes', {self._extract_chapters}),
+            }),
+            **self._extract_ptmd(ptmd_urls, video_id, self._get_api_token(), aspect_ratio),
+            'id': video_id,
+        }
 
 
 class ZDFChannelIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: InvalidUsernameException
body: The endpoint is used by the mobile app and then additional entries are paginated in via a different endpoint if the list is long enough or if you switch seasons. I suspect the webpage may do the same to get the information that is hidden in the hydration data. But if so, it obviously does it server-side where we can't see it.

So I assumed it is fine to use the `GetSmartCollectionByCanonical` and I just cranked up the limit from 24 (used in the app) to 100 (server-enforced limit). This already gets you everything there is for 95%+ of cases I would guess.

So I though it would be enough to have a warning for the edge case for the time being and the extractor would not need to be marked as broken. Is there any real user-facing consequence of marking it as broken? I know tests don't run and it is marked as broken in the list of supported sites. But those are relatively low impact.
created_at: 2025-04-30T11:05:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -393,6 +393,7 @@ class ZDFIE(ZDFBaseIE):
             'upload_date': '20250203',
             '_old_archive_ids': ['zdf 240319_2310_sendung_not'],
         },
+        'params': {'skip_download': 'geo-restricted m3u8'},
path: yt_dlp/extractor/zdf.py
user: login: bashonly
body: You're right, it was an http format.

> Do tests have their own format preference?

Yeah, but I can't recall where in the code this happens and it's not *really* relevant to this PR so I can't be bothered to find it, sorry
created_at: 2025-04-30T18:37:43Z
author_association: MEMBER
diff_hunk: @@ -349,108 +425,194 @@ class ZDFIE(ZDFBaseIE):
         'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/arte/todliche-flucht/page-video-artede-toedliche-flucht-16-100.html',
-        'info_dict': {
-            'id': 'video_artede_083871-001-A',
-            'ext': 'mp4',
-            'title': 'Tödliche Flucht (1/6)',
-            'description': 'md5:e34f96a9a5f8abd839ccfcebad3d5315',
-            'duration': 3193.0,
-            'timestamp': 1641355200,
-            'upload_date': '20220105',
-        },
-        'skip': 'No longer available "Diese Seite wurde leider nicht gefunden"',
-    }, {
-        'url': 'https://www.zdf.de/serien/soko-stuttgart/das-geld-anderer-leute-100.html',
-        'info_dict': {
-            'id': '191205_1800_sendung_sok8',
-            'ext': 'mp4',
-            'title': 'Das Geld anderer Leute',
-            'description': 'md5:cb6f660850dc5eb7d1ab776ea094959d',
-            'duration': 2581.0,
-            'timestamp': 1728983700,
-            'upload_date': '20241015',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/e2d7e55a-09f0-424e-ac73-6cac4dd65f35?layout=2400x1350',
-            'series': 'SOKO Stuttgart',
-            'series_id': 'f862ce9a-6dd1-4388-a698-22b36ac4c9e9',
-            'season': 'Staffel 11',
-            'season_number': 11,
-            'season_id': 'ae1b4990-6d87-4970-a571-caccf1ba2879',
-            'episode': 'Das Geld anderer Leute',
-            'episode_number': 10,
-            'episode_id': 'POS_7f367934-f2f0-45cb-9081-736781ff2d23',
-        },
+        'only_matching': True,
     }, {
         'url': 'https://www.zdf.de/dokumentation/terra-x/unser-gruener-planet-wuesten-doku-100.html',
-        'info_dict': {
-            'id': '220525_green_planet_makingof_1_tropen_tex',
-            'ext': 'mp4',
-            'title': 'Making-of Unser grüner Planet - Tropen',
-            'description': 'md5:d7c6949dc7c75c73c4ad51c785fb0b79',
-            'duration': 435.0,
-            'timestamp': 1653811200,
-            'upload_date': '20220529',
-            'format_note': 'hd, main',
-            'thumbnail': 'https://www.zdf.de/assets/unser-gruener-planet-making-of-1-tropen-100~3840x2160?cb=1653493335577',
-            'episode': 'Making-of Unser grüner Planet - Tropen',
-        },
-        'skip': 'No longer available: "Leider kein Video verfügbar"',
-    }, {
-        'url': 'https://www.zdf.de/serien/northern-lights/begegnung-auf-der-bruecke-100.html',
-        'info_dict': {
-            'id': '240319_2310_sendung_not',
-            'ext': 'mp4',
-            'title': 'Begegnung auf der Brücke',
-            'description': 'md5:e53a555da87447f7f1207f10353f8e45',
-            'thumbnail': 'https://epg-image.zdf.de/fotobase-webdelivery/images/c5ff1d1f-f5c8-4468-86ac-1b2f1dbecc76?layout=2400x1350',
-            'upload_date': '20250203',
-            'duration': 3083.0,
-            'timestamp': 1738546500,
-            'series_id': '1d7a1879-01ee-4468-8237-c6b4ecd633c7',
-            'series': 'Northern Lights',
-            'season': 'Staffel 1',
-            'season_number': 1,
-            'season_id': '22ac26a2-4ea2-4055-ac0b-98b755cdf718',
-            'episode': 'Begegnung auf der Brücke',
-            'episode_number': 1,
-            'episode_id': 'POS_71049438-024b-471f-b472-4fe2e490d1fb',
-        },
+        'only_matching': True,
     }]
 
+    _GRAPHQL_QUERY = '''
+query VideoByCanonical($canonical: String!) {
+  videoByCanonical(canonical: $canonical) {
+    canonical
+    title
+    leadParagraph
+    editorialDate
+    teaser {
+      description
+      image {
+        list
+      }
+    }
+    episodeInfo {
+      episodeNumber
+      seasonNumber
+    }
+    smartCollection {
+      canonical
+      title
+    }
+    currentMedia {
+      nodes {
+        ptmdTemplate
+        ... on VodMedia {
+          duration
+          aspectRatio
+          streamAnchorTags {
+            nodes {
+              anchorOffset
+              anchorLabel
+            }
+          }
+          vodMediaType
+          label
+        }
+        ... on LiveMedia {
+          start
+          stop
+          encryption
+          liveMediaType
+          label
+        }
+        id
+      }
+    }
+  }
+}
+    '''
+
+    def _extract_ptmd(self, *args, **kwargs):
+        ptmd_data = super()._extract_ptmd(*args, **kwargs)
+        # This was the video id before the graphql redesign, other extractors still use it as such
+        old_archive_id = ptmd_data.pop('id')
+        ptmd_data['_old_archive_ids'] = [make_archive_id(self, old_archive_id)]
+        return ptmd_data
+
+    # This fallback should generally only happen for pages under `zdf.de/nachrichten`.
+    # They are on a separate website for which GraphQL often doesn't return results.
+    # The API used here is no longer in use by official clients and likely deprecated.
+    # Long-term, news documents probably should use the API used by the mobile apps:
+    # https://zdf-prod-futura.zdf.de/news/documents/ (note 'news' vs 'mediathekV2')
+    def _extract_fallback(self, document_id):
+        video = self._download_json(
+            f'https://zdf-prod-futura.zdf.de/mediathekV2/document/{document_id}',
+            document_id, note='Downloading fallback metadata',
+            errnote='Failed to download fallback metadata')
+        document = video['document']
+
+        ptmd_url = traverse_obj(document, (
+            ('streamApiUrlAndroid', ('streams', 0, 'streamApiUrlAndroid')),
+            {url_or_none}, any, {require('PTMD URL')}))
+
+        thumbnails = []
+        for thumbnail_key, thumbnail in traverse_obj(document, ('teaserBild', {dict.items}, ...)):
+            thumbnail_url = traverse_obj(thumbnail, ('url', {url_or_none}))
+            if not thumbnail_url:
+                continue
+            thumbnails.append({
+                'url': thumbnail_url,
+                'id': thumbnail_key,
+                'width': int_or_none(thumbnail.get('width')),
+                'height': int_or_none(thumbnail.get('height')),
+            })
+
+        return {
+            'thumbnails': thumbnails,
+            **traverse_obj(video, {
+                'title': ('document', 'titel', {str}),
+                'description': ('document', 'beschreibung', {str}),
+                'timestamp': (
+                    (('document', 'date'), ('meta', 'editorialDate')),
+                    {unified_timestamp}, any),
+                'subtitles': ('document', 'captions', {self._extract_subtitles}),
+            }),
+            **self._extract_ptmd(ptmd_url, document_id, self._get_api_token()),
+            'id': document_id,
+        }
+
     def _real_extract(self, url):
         video_id = self._match_id(url)
+        video_data = self._download_graphql(video_id, 'video metadata', body={
+            'operationName': 'VideoByCanonical',
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'canonical': video_id},
+        })['data']['videoByCanonical']
+
+        if not video_data:
+            return self._extract_fallback(video_id)
+
+        aspect_ratio = None
+        ptmd_urls = []
+        for node in traverse_obj(video_data, ('currentMedia', 'nodes', lambda _, v: v['ptmdTemplate'])):
+            ptmd_url = self._expand_ptmd_template('https://api.zdf.de', node['ptmdTemplate'])
+            # Smuggle vod_media_type so that _extract_ptmd is aware of 'DGS' variants
+            if vod_media_type := node.get('vodMediaType'):
+                ptmd_url = smuggle_url(ptmd_url, {'vod_media_type': vod_media_type})
+            ptmd_urls.append(ptmd_url)
+            if not aspect_ratio:
+                aspect_ratio = self._parse_aspect_ratio(node.get('aspectRatio'))
 
-        webpage = self._download_webpage(url, video_id, fatal=False)
-        if webpage:
-            player = self._extract_player(webpage, url, fatal=False)
-            if player:
-                return self._extract_regular(url, player, video_id, query={'profile': 'player-3'})
-
-        return self._extract_mobile(video_id)
+        return {
+            **traverse_obj(video_data, {
+                'title': ('title', {str}),
+                'description': (('leadParagraph', ('teaser', 'description')), any, {str}),
+                'timestamp': ('editorialDate', {parse_iso8601}),
+                'thumbnails': ('teaser', 'image', 'list', {self._extract_thumbnails}),
+                'episode_number': ('episodeInfo', 'episodeNumber', {int_or_none}),
+                'season_number': ('episodeInfo', 'seasonNumber', {int_or_none}),
+                'series': ('smartCollection', 'title', {str}),
+                'series_id': ('smartCollection', 'canonical', {str}),
+                'chapters': ('currentMedia', 'nodes', 0, 'streamAnchorTags', 'nodes', {self._extract_chapters}),
+            }),
+            **self._extract_ptmd(ptmd_urls, video_id, self._get_api_token(), aspect_ratio),
+            'id': video_id,
+        }
 
 
 class ZDFChannelIE(ZDFBaseIE):
path: yt_dlp/extractor/zdf.py
user: login: bashonly
body: Nevermind, I fixed it with pagination and all. We're now using a combination of the mobile app endpoint and the website endpoint
created_at: 2025-04-30T18:38:13Z
author_association: MEMBER

PR comments:
user: login: StefanLobbenmeier
created_at: 2025-03-29T21:59:33Z
body: You also changed phoenix and 3sat, did you mean to include those changes in the PR?
author_association: CONTRIBUTOR
user: login: bashonly
created_at: 2025-03-29T22:07:28Z
body: > You also changed phoenix and 3sat, did you mean to include those changes in the PR?

Looks like it was because they both are subclassed from `ZDFBaseIE`, so they needed to be adjusted to the changes made to the baseclass. This is acceptable
author_association: MEMBER
user: login: InvalidUsernameException
created_at: 2025-03-29T22:07:39Z
body: > You also changed phoenix and 3sat, did you mean to include those changes in the PR?

That was intentional yes.

I have unified thumbnail extraction, which was duplicated a couple times. The other change in Phoenix is due to a change in shared API, so I had to add that. The changes in 3sat are because some functions that used to be shared are now 3sat-only and I've moved them accordingly.

On top of that I've fixed/updated tests for those extractors so that I can verify more easily if the changes to `ZDFBaseIE` have broken something in the dependent extractors.

Behavior-wise 3sat and Phoenix should not have changed, I believe.
author_association: CONTRIBUTOR
user: login: piene
created_at: 2025-03-30T13:35:41Z
body: For me it's not working with:
https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-03-30T14:06:25Z
body: > For me it's not working with: https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104

That URL works for me. Can you rerun the command with `-v` appended and post the output as text here?
author_association: CONTRIBUTOR
user: login: adrgru
created_at: 2025-03-30T14:19:43Z
body: Seems to be working fine?

```
[debug] Command-line config: ['https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104', '-vU']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2025.03.27 from yt-dlp/yt-dlp [48be862b3] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: 2ad9fd8d8
[debug] Python 3.11.9 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 3.0.13 30 Jan 2024)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.17, brotli-1.1.0, certifi-2024.07.04, curl_cffi-0.10.0, mutagen-1.47.0, requests-2.32.3, sqlite3-3.45.1, urllib3-2.2.2, websockets-13.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Plugin directories: none
[debug] Loaded 1850 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2025.03.27 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2025.03.27 from yt-dlp/yt-dlp)
[debug] Using fake IP 53.116.162.146 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104
[debug] Loading zdf.api-token from cache
[ZDF] mord-mit-rendite-nach-motiven-von-caroline-graham-104: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] mord-mit-rendite-nach-motiven-von-caroline-graham-104: Downloading PTMD data
[ZDF] 160502_0435_sendung_iby: Downloading m3u8 information
[ZDF] 160502_0435_sendung_iby: Downloading m3u8 information
[ZDF] 160502_0435_sendung_iby: Downloading m3u8 information
[ZDF] 160502_0435_sendung_iby: Downloading m3u8 information
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[debug] Default format spec: bestvideo*+bestaudio/best
[info] mord-mit-rendite-nach-motiven-von-caroline-graham-104: Downloading 1 format(s): http-h264_aac_mp4_http_na_na-hd
[debug] Invoking http downloader on "https://nrodlzdf-a.akamaihd.net/de/zdf/16/05/160502_0435_sendung_iby/5/160502_0435_sendung_iby_3328k_p15v15.mp4"
[debug] File locking is not supported. Proceeding without locking
[download] Destination: Mord mit Rendite [mord-mit-rendite-nach-motiven-von-caroline-graham-104].mp4
[download]   1.3% of    1.84GiB at    5.52MiB/s ETA 05:35
ERROR: Interrupted by user
```

---

I have another issue, although I'm not sure whether this was introduced in this version or had existed previously. When I try to run it on a playlist like this:

```
python -m yt_dlp https://www.zdf.de/serien/the-tourist-110?staffel=2 -f "bestvideo+bestaudio[language=en]"
```

It successfully downloads the episode 1 (format id "-5"), then throws an error on episode 2, successfully downloads episode 3 (not retrieving id "-5" but format id "-4"), throws another error on episode 4, then downloads episode 5 (not retrieving id "-5" but format id "-4") and then throws an error on episode 6. Very strange and I wonder if there is some kind of rate limit or if the key somehow expires.

If I run it again, using --playlist-items to select only the problematic ones, the same pattern happens: The first one downloads fine, the second one throws an error and so on. Can someone reproduce this?

```
[debug] Command-line config: ['-vU', 'https://www.zdf.de/serien/the-tourist-110?staffel=2', '-P', 'temp:C:\\temp', '--ignore-errors', '--add-metadata', '-f', 'bestvideo+bestaudio[language=en]', '--merge-output-format', 'mp4', '-o', 'E%(playlist_index)s.%(ext)s', '--compat-option', 'filename-sanitization']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2025.03.27 from yt-dlp/yt-dlp [48be862b3] (source)
[debug] Lazy loading extractors is disabled
[debug] Compatibility options: filename-sanitization
[debug] Git HEAD: 2ad9fd8d8
[debug] Python 3.11.9 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 3.0.13 30 Jan 2024)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.17, brotli-1.1.0, certifi-2024.07.04, curl_cffi-0.10.0, mutagen-1.47.0, requests-2.32.3, sqlite3-3.45.1, urllib3-2.2.2, websockets-13.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Plugin directories: none
[debug] Loaded 1850 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2025.03.27 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2025.03.27 from yt-dlp/yt-dlp)
[debug] Using fake IP 53.204.177.235 (DE) as X-Forwarded-For
[ZDFCollection] Extracting URL: https://www.zdf.de/serien/the-tourist-110?staffel=2
[ZDFCollection] the-tourist-110: Downloading webpage
[debug] Loading zdf.api-token from cache
[ZDFCollection] the-tourist-110: Downloading smart collection data
[download] Downloading playlist: The Tourist
[ZDFCollection] Playlist The Tourist: Downloading 6 items of 6
[download] Downloading item 1 of 6
[debug] Using fake IP 53.147.185.101 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100
[debug] Loading zdf.api-token from cache
[ZDF] iren-sind-menschlich-100: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] iren-sind-menschlich-100: Downloading PTMD data
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[info] iren-sind-menschlich-100: Downloading 1 format(s): hls-2624-5+hls-audio0-Originalton_englisch-11
WARNING: --paths is ignored since an absolute path is given in output template
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/audiotrack=0:deu:TV%20Ton,1:eng:Originalton%20englisch,2:deu:Audiodeskription,/mp4/dach/zdf/24/06/240603_2215_sendung_dia/5/240603_2215_sendung_dia,_a1a2_1628k_p13,_a3a4_1628k_p13,_a5a6_1628k_p13,_a1a2_3328k_p15,_a1a2_6628k_p61,v17.mp4.csmil/index-f4-v1-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 447
[download] Destination: E1.fhls-2624-5.mp4
[debug] File locking is not supported. Proceeding without locking
[download] 100% of    1.15GiB in 00:04:42 at 4.16MiB/s
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/audiotrack=0:deu:TV%20Ton,1:eng:Originalton%20englisch,2:deu:Audiodeskription,/mp4/dach/zdf/24/06/240603_2215_sendung_dia/5/240603_2215_sendung_dia,_a1a2_1628k_p13,_a3a4_1628k_p13,_a5a6_1628k_p13,_a1a2_3328k_p15,_a1a2_6628k_p61,v17.mp4.csmil/index-f7-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 447
[download] Destination: E1.fhls-audio0-Originalton_englisch-11.mp4
[download] 100% of   57.50MiB in 00:01:03 at 920.38KiB/s
[debug] ffmpeg command line: ffprobe -show_streams "file:E1.fhls-audio0-Originalton_englisch-11.mp4"
[Merger] Merging formats into "E1.mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel repeat+info -i "file:E1.fhls-2624-5.mp4" -i "file:E1.fhls-audio0-Originalton_englisch-11.mp4" -c copy -map 0:v:0 -map 1:a:0 -bsf:a:0 aac_adtstoasc -movflags +faststart "file:E1.temp.mp4"
Deleting original file E1.fhls-2624-5.mp4 (pass -k to keep)
Deleting original file E1.fhls-audio0-Originalton_englisch-11.mp4 (pass -k to keep)
[Metadata] Adding metadata to "E1.mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel repeat+info -i "file:E1.mp4" -map 0 -dn -ignore_unknown -c copy -write_id3v1 1 -metadata "title=Iren sind menschlich" -metadata "date=20240603" -metadata "description=Helen und Elliot reisen nach Irland, in der Hoffnung, mehr über die Vergangenheit Elliots zu erfahren. Schnell stellt sich heraus, dass das wohl nicht die brillanteste Idee der beiden war." -metadata "synopsis=Helen und Elliot reisen nach Irland, in der Hoffnung, mehr über die Vergangenheit Elliots zu erfahren. Schnell stellt sich heraus, dass das wohl nicht die brillanteste Idee der beiden war." -metadata "purl=https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100" -metadata "comment=https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100" -metadata "show=The Tourist" -metadata "season_number=2" -metadata "episode_id=Episode 1" -metadata "episode_sort=1" -metadata:s:0 "language=deu" -metadata:s:1 "language=eng" -movflags +faststart "file:E1.temp.mp4"
[download] Downloading item 2 of 6
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/der-gang-der-dinge-102
[debug] Loading zdf.api-token from cache
[ZDF] der-gang-der-dinge-102: Downloading video metadata
ERROR: [ZDF] der-gang-der-dinge-102: Failed to download video metadata: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) (caused by TransportError("('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))"))
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 748, in extract
    ie_result = self._real_extract(url)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\zdf.py", line 475, in _real_extract
    video_data = self._download_graphql(video_id, 'video metadata', body={
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\zdf.py", line 159, in _download_graphql
    return self._download_json(
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 1145, in download_content
    res = getattr(self, download_handle.__name__)(url_or_request, video_id, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 1103, in download_handle
    res = self._download_webpage_handle(
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 968, in _download_webpage_handle
    urlh = self._request_webpage(url_or_request, video_id, note, errnote, fatal, data=data,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 917, in _request_webpage
    raise ExtractorError(errmsg, cause=err)

  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 789, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 536, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connection.py", line 464, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 1395, in getresponse
    response.begin()
  File "C:\Python311\Lib\http\client.py", line 325, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 294, in _read_status
    raise RemoteDisconnected("Remote end closed connection without"
http.client.RemoteDisconnected: Remote end closed connection without response

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Python311\Lib\site-packages\requests\adapters.py", line 667, in send
    resp = conn.urlopen(
           ^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 843, in urlopen
    retries = retries.increment(
              ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\util\retry.py", line 449, in increment
    raise reraise(type(error), error, _stacktrace)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\util\util.py", line 38, in reraise
    raise value.with_traceback(tb)
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 789, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 536, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connection.py", line 464, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 1395, in getresponse
    response.begin()
  File "C:\Python311\Lib\http\client.py", line 325, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 294, in _read_status
    raise RemoteDisconnected("Remote end closed connection without"
urllib3.exceptions.ProtocolError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_requests.py", line 332, in _send
    requests_res = session.request(
                   ^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\sessions.py", line 703, in send
    r = adapter.send(request, **kwargs)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\adapters.py", line 682, in send
    raise ConnectionError(err, request=request)
requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 904, in _request_webpage
    return self._downloader.urlopen(self._create_request(url_or_request, data, headers, query, extensions))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\YoutubeDL.py", line 4186, in urlopen
    return self._request_director.send(req)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\common.py", line 117, in send
    response = handler.send(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_helper.py", line 208, in wrapper
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\common.py", line 359, in send
    return self._send(request)
           ^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_requests.py", line 356, in _send
    raise TransportError(cause=e) from e
yt_dlp.networking.exceptions.TransportError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

[download] Downloading item 3 of 6
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/wurst-und-musik-100
[debug] Loading zdf.api-token from cache
[ZDF] wurst-und-musik-100: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] wurst-und-musik-100: Downloading PTMD data
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
WARNING: [ZDF] Failed to download m3u8 information: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[ZDF] 240610_2300_sendung_dia: Downloading m3u8 information
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[info] wurst-und-musik-100: Downloading 1 format(s): hls-2717-4+hls-audio0-Originalton_englisch-10
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/audiotrack=0:deu:TV%20Ton,1:eng:Originalton%20englisch,2:deu:Audiodeskription,/mp4/dach/zdf/24/06/240610_2300_sendung_dia/6/240610_2300_sendung_dia,_a1a2_1628k_p13,_a3a4_1628k_p13,_a5a6_1628k_p13,_a1a2_3328k_p15,_a1a2_6628k_p61,v17.mp4.csmil/index-f4-v1-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 438
[download] Destination: E3.fhls-2717-4.mp4
[download] 100% of    1.16GiB in 00:04:20 at 4.56MiB/s
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/audiotrack=0:deu:TV%20Ton,1:eng:Originalton%20englisch,2:deu:Audiodeskription,/mp4/dach/zdf/24/06/240610_2300_sendung_dia/6/240610_2300_sendung_dia,_a1a2_1628k_p13,_a3a4_1628k_p13,_a5a6_1628k_p13,_a1a2_3328k_p15,_a1a2_6628k_p61,v17.mp4.csmil/index-f7-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 438
[download] Destination: E3.fhls-audio0-Originalton_englisch-10.mp4
[download] 100% of   56.30MiB in 00:01:28 at 654.71KiB/s
[debug] ffmpeg command line: ffprobe -show_streams "file:E3.fhls-audio0-Originalton_englisch-10.mp4"
[Merger] Merging formats into "E3.mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel repeat+info -i "file:E3.fhls-2717-4.mp4" -i "file:E3.fhls-audio0-Originalton_englisch-10.mp4" -c copy -map 0:v:0 -map 1:a:0 -bsf:a:0 aac_adtstoasc -movflags +faststart "file:E3.temp.mp4"
Deleting original file E3.fhls-2717-4.mp4 (pass -k to keep)
Deleting original file E3.fhls-audio0-Originalton_englisch-10.mp4 (pass -k to keep)
[Metadata] Adding metadata to "E3.mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel repeat+info -i "file:E3.mp4" -map 0 -dn -ignore_unknown -c copy -write_id3v1 1 -metadata "title=Wurst und Musik" -metadata "date=20240611" -metadata "description=Es ist zum Verrücktwerden: Eben noch hat es Elliot geschafft, sich aus den Klauen seiner Entführer zu befreien – und jetzt ist plötzlich Helen spurlos verschwunden." -metadata "synopsis=Es ist zum Verrücktwerden: Eben noch hat es Elliot geschafft, sich aus den Klauen seiner Entführer zu befreien – und jetzt ist plötzlich Helen spurlos verschwunden." -metadata "purl=https://www.zdf.de/video/serien/the-tourist-110/wurst-und-musik-100" -metadata "comment=https://www.zdf.de/video/serien/the-tourist-110/wurst-und-musik-100" -metadata "show=The Tourist" -metadata "season_number=2" -metadata "episode_id=Episode 3" -metadata "episode_sort=3" -metadata:s:0 "language=deu" -metadata:s:1 "language=eng" -movflags +faststart "file:E3.temp.mp4"
[download] Downloading item 4 of 6
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/die-kinder-von-lir-100
[debug] Loading zdf.api-token from cache
[ZDF] die-kinder-von-lir-100: Downloading video metadata
ERROR: [ZDF] die-kinder-von-lir-100: Failed to download video metadata: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) (caused by TransportError("('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))"))
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 748, in extract
    ie_result = self._real_extract(url)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\zdf.py", line 475, in _real_extract
    video_data = self._download_graphql(video_id, 'video metadata', body={
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\zdf.py", line 159, in _download_graphql
    return self._download_json(
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 1145, in download_content
    res = getattr(self, download_handle.__name__)(url_or_request, video_id, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 1103, in download_handle
    res = self._download_webpage_handle(
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 968, in _download_webpage_handle
    urlh = self._request_webpage(url_or_request, video_id, note, errnote, fatal, data=data,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 917, in _request_webpage
    raise ExtractorError(errmsg, cause=err)

  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 789, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 536, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connection.py", line 464, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 1395, in getresponse
    response.begin()
  File "C:\Python311\Lib\http\client.py", line 325, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 294, in _read_status
    raise RemoteDisconnected("Remote end closed connection without"
http.client.RemoteDisconnected: Remote end closed connection without response

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Python311\Lib\site-packages\requests\adapters.py", line 667, in send
    resp = conn.urlopen(
           ^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 843, in urlopen
    retries = retries.increment(
              ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\util\retry.py", line 449, in increment
    raise reraise(type(error), error, _stacktrace)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\util\util.py", line 38, in reraise
    raise value.with_traceback(tb)
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 789, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 536, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connection.py", line 464, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 1395, in getresponse
    response.begin()
  File "C:\Python311\Lib\http\client.py", line 325, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 294, in _read_status
    raise RemoteDisconnected("Remote end closed connection without"
urllib3.exceptions.ProtocolError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_requests.py", line 332, in _send
    requests_res = session.request(
                   ^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\sessions.py", line 703, in send
    r = adapter.send(request, **kwargs)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\adapters.py", line 682, in send
    raise ConnectionError(err, request=request)
requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 904, in _request_webpage
    return self._downloader.urlopen(self._create_request(url_or_request, data, headers, query, extensions))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\YoutubeDL.py", line 4186, in urlopen
    return self._request_director.send(req)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\common.py", line 117, in send
    response = handler.send(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_helper.py", line 208, in wrapper
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\common.py", line 359, in send
    return self._send(request)
           ^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_requests.py", line 356, in _send
    raise TransportError(cause=e) from e
yt_dlp.networking.exceptions.TransportError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

[download] Downloading item 5 of 6
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/in-morpheus-faengen-100
[debug] Loading zdf.api-token from cache
[ZDF] in-morpheus-faengen-100: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] in-morpheus-faengen-100: Downloading PTMD data
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
WARNING: [ZDF] Failed to download m3u8 information: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[ZDF] 240617_2345_sendung_dia: Downloading m3u8 information
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[info] in-morpheus-faengen-100: Downloading 1 format(s): hls-2697-4+hls-audio0-Originalton_englisch-10
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/audiotrack=0:deu:TV%20Ton,1:eng:Originalton%20englisch,2:deu:Audiodeskription,/mp4/dach/zdf/24/06/240617_2345_sendung_dia/7/240617_2345_sendung_dia,_a1a2_1628k_p13,_a3a4_1628k_p13,_a5a6_1628k_p13,_a1a2_3328k_p15,_a1a2_6628k_p61,v17.mp4.csmil/index-f4-v1-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 438
[download] Destination: E5.fhls-2697-4.mp4
[download] 100% of    1.15GiB in 00:04:06 at 4.79MiB/s
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/audiotrack=0:deu:TV%20Ton,1:eng:Originalton%20englisch,2:deu:Audiodeskription,/mp4/dach/zdf/24/06/240617_2345_sendung_dia/7/240617_2345_sendung_dia,_a1a2_1628k_p13,_a3a4_1628k_p13,_a5a6_1628k_p13,_a1a2_3328k_p15,_a1a2_6628k_p61,v17.mp4.csmil/index-f7-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 438
[download] Destination: E5.fhls-audio0-Originalton_englisch-10.mp4
[download] 100% of   56.30MiB in 00:01:05 at 875.35KiB/s
[debug] ffmpeg command line: ffprobe -show_streams "file:E5.fhls-audio0-Originalton_englisch-10.mp4"
[Merger] Merging formats into "E5.mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel repeat+info -i "file:E5.fhls-2697-4.mp4" -i "file:E5.fhls-audio0-Originalton_englisch-10.mp4" -c copy -map 0:v:0 -map 1:a:0 -bsf:a:0 aac_adtstoasc -movflags +faststart "file:E5.temp.mp4"
Deleting original file E5.fhls-2697-4.mp4 (pass -k to keep)
Deleting original file E5.fhls-audio0-Originalton_englisch-10.mp4 (pass -k to keep)
[Metadata] Adding metadata to "E5.mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel repeat+info -i "file:E5.mp4" -map 0 -dn -ignore_unknown -c copy -write_id3v1 1 -metadata "title=In Morpheus' Fängen" -metadata "date=20240617" -metadata "description=Helen wird schwer verwundet in ein Krankenhaus gebracht, sie hat viel Blut verloren. Die Ärzte kämpfen verzweifelt um das Leben der Australierin und versetzen sie in ein künstliches Koma." -metadata "synopsis=Helen wird schwer verwundet in ein Krankenhaus gebracht, sie hat viel Blut verloren. Die Ärzte kämpfen verzweifelt um das Leben der Australierin und versetzen sie in ein künstliches Koma." -metadata "purl=https://www.zdf.de/video/serien/the-tourist-110/in-morpheus-faengen-100" -metadata "comment=https://www.zdf.de/video/serien/the-tourist-110/in-morpheus-faengen-100" -metadata "show=The Tourist" -metadata "season_number=2" -metadata "episode_id=Episode 5" -metadata "episode_sort=5" -metadata:s:0 "language=deu" -metadata:s:1 "language=eng" -movflags +faststart "file:E5.temp.mp4"
[download] Downloading item 6 of 6
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/der-tantalidische-fluch-100
[debug] Loading zdf.api-token from cache
[ZDF] der-tantalidische-fluch-100: Downloading video metadata
ERROR: [ZDF] der-tantalidische-fluch-100: Failed to download video metadata: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) (caused by TransportError("('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))"))
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 748, in extract
    ie_result = self._real_extract(url)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\zdf.py", line 475, in _real_extract
    video_data = self._download_graphql(video_id, 'video metadata', body={
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\zdf.py", line 159, in _download_graphql
    return self._download_json(
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 1145, in download_content
    res = getattr(self, download_handle.__name__)(url_or_request, video_id, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 1103, in download_handle
    res = self._download_webpage_handle(
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 968, in _download_webpage_handle
    urlh = self._request_webpage(url_or_request, video_id, note, errnote, fatal, data=data,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 917, in _request_webpage
    raise ExtractorError(errmsg, cause=err)

  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 789, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 536, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connection.py", line 464, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 1395, in getresponse
    response.begin()
  File "C:\Python311\Lib\http\client.py", line 325, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 294, in _read_status
    raise RemoteDisconnected("Remote end closed connection without"
http.client.RemoteDisconnected: Remote end closed connection without response

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Python311\Lib\site-packages\requests\adapters.py", line 667, in send
    resp = conn.urlopen(
           ^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 843, in urlopen
    retries = retries.increment(
              ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\util\retry.py", line 449, in increment
    raise reraise(type(error), error, _stacktrace)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\util\util.py", line 38, in reraise
    raise value.with_traceback(tb)
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 789, in urlopen
    response = self._make_request(
               ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connectionpool.py", line 536, in _make_request
    response = conn.getresponse()
               ^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\urllib3\connection.py", line 464, in getresponse
    httplib_response = super().getresponse()
                       ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 1395, in getresponse
    response.begin()
  File "C:\Python311\Lib\http\client.py", line 325, in begin
    version, status, reason = self._read_status()
                              ^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\http\client.py", line 294, in _read_status
    raise RemoteDisconnected("Remote end closed connection without"
urllib3.exceptions.ProtocolError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_requests.py", line 332, in _send
    requests_res = session.request(
                   ^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\sessions.py", line 589, in request
    resp = self.send(prep, **send_kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\sessions.py", line 703, in send
    r = adapter.send(request, **kwargs)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Python311\Lib\site-packages\requests\adapters.py", line 682, in send
    raise ConnectionError(err, request=request)
requests.exceptions.ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "C:\Users\{username}\yt-dlp\yt_dlp\extractor\common.py", line 904, in _request_webpage
    return self._downloader.urlopen(self._create_request(url_or_request, data, headers, query, extensions))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\YoutubeDL.py", line 4186, in urlopen
    return self._request_director.send(req)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\common.py", line 117, in send
    response = handler.send(request)
               ^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_helper.py", line 208, in wrapper
    return func(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\common.py", line 359, in send
    return self._send(request)
           ^^^^^^^^^^^^^^^^^^^
  File "C:\Users\{username}\yt-dlp\yt_dlp\networking\_requests.py", line 356, in _send
    raise TransportError(cause=e) from e
yt_dlp.networking.exceptions.TransportError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))

[download] Finished downloading playlist: The Tourist
```
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-03-30T15:55:55Z
body: > The first one downloads fine, the second one throws an error and so on. Can someone reproduce this?

No, that does not reproduce for me. I very rarely get `[download] Got error: HTTP Error 504: Gateway Time-out. Retrying (1/10)...` on individual fragment downloads, but those are handled by the automatic retries. But your errors are on JSON and m3u8 files, not the actual download. I can go through a large playlist with `--skip-download` without any failures, which should act as a stress test for rate-limits on those requests. Also `Connection aborted` generally seems more like a network error rather than a rate-limit. 

> not retrieving id "-5" but format id "-4"

As mentioned, some of the m3u8 downloads are also failing for you (see the lines with `WARNING: [ZDF] Failed to download m3u8 information`). I suspect they would normally provide the `hls-XXXX-5` format, but cant since they failed.

Overall the most noticeable errors are on the new requests to the GraphQL endpoint. It would be unfortunate if that is not reliable. But I can't see any evidence of that on my end.

Are you behind a VPN, system-wide proxy or anything else that might interfere with network requests?
author_association: CONTRIBUTOR
user: login: adrgru
created_at: 2025-03-30T17:22:54Z
body: Hm, weird. I'm not on a VPN. My Wi-Fi is unreliable at times, but its consistency for me seemed so surprising. If I start downloading the playlist again into a new folder, the same pattern happens (I tested this at least 3 times). The same thing happens with Killing Eve, which also has 45-minute episodes – running the command once only downloads episodes 1, 3, 5, and 7. Maybe it's because of the length and thus the download duration? I had no problems downloading Suspect (25-min episodes). Unfortunately, I couldn't really identify where this originates, and if this isn't reproduceable, I guess I'll keep going with `--playlist-items` to retry those that failed.

Thanks for looking into this, though. Also, thanks for rewriting the extractor.
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-03-30T19:52:57Z
body: I mean, so far the behavior I'm seeing is only one data point as well. And the API is new, so problems with it would not have shown up before the rewrite. Might be that we are hitting different datacenters or that our different internet speeds matter. More data points from different people (in either direction) would be helpful to gauge if this is a general problem or not. If it is, it might be worth adding retries to that request.

What happens if you run it on a playlist with `--skip-download`? Does that have the same behavior? Also, do you see any errors when using the website or the mobile app (i.e. start video, stop video, start next video in quick succession)?
author_association: CONTRIBUTOR
user: login: piene
created_at: 2025-03-30T21:10:58Z
body: ****

> > For me it's not working with: https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104
> 
> That URL works for me. Can you rerun the command with `-v` appended and post the output as text here?

Now it works for me. Don´t know what was going wrong in the first try.
Thank you for spending your time to fix this issue!
author_association: NONE
user: login: adrgru
created_at: 2025-03-31T13:00:17Z
body: > I mean, so far the behavior I'm seeing is only one data point as well. And the API is new, so problems with it would not have shown up before the rewrite. Might be that we are hitting different datacenters or that our different internet speeds matter. More data points from different people (in either direction) would be helpful to gauge if this is a general problem or not. If it is, it might be worth adding retries to that request.
> 
> What happens if you run it on a playlist with `--skip-download`? Does that have the same behavior? Also, do you see any errors when using the website or the mobile app (i.e. start video, stop video, start next video in quick succession)?

Then there aren't any error messages. No, I haven't seen any errors on the website or app.

It wasn't just a temporary glitch: It still consistently happens for me (in the Windows terminal). However, I just ran it in Windows Subsystem for Linux and didn't get any errors at all, not even `Failed to download m3u8 information`.
author_association: NONE
user: login: bashonly
created_at: 2025-03-31T14:19:57Z
body: Seems like an issue specific to your system then
author_association: MEMBER
user: login: sosyco
created_at: 2025-04-02T03:15:46Z
body: all my checks failed downloading a video:

**./yt-dlp.sh -v -f "bv*" --audio-multistreams --all-subs --no-playlist https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104**
`...
[debug] Command-line config: ['-v', '-f', 'bv*', '--audio-multistreams', '--all-subs', '--no-playlist', 'https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104']
[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@2024.08.06 from yt-dlp/yt-dlp [4d9231208] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: 4a9bc8c36
[debug] Python 3.13.2 (CPython x86_64 64bit) - Linux-6.13.8-arch1-1-x86_64-with-glibc2.41 (OpenSSL 3.4.1 11 Feb 2025, glibc 2.41)
[debug] exe versions: ffmpeg 7.1 (setts), ffprobe 7.1
[debug] Optional libraries: certifi-2025.01.31, requests-2.32.3, sqlite3-3.49.1, urllib3-2.3.0, websockets-12.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets
[debug] Loaded 1835 extractors
[debug] Using fake IP 53.43.214.25 (DE) as X-Forwarded-For
[ZDFChannel] Extracting URL: https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104
[ZDFChannel] mord-mit-rendite-nach-motiven-von-caroline-graham-104: Downloading webpage
[ZDFChannel] Downloading just the main video because of --no-playlist
[download] Downloading playlist: Mord mit Rendite
[ZDFChannel] Playlist Mord mit Rendite: Downloading 0 items
[download] Finished downloading playlist: Mord mit Rendite
`

**./yt-dlp.sh -vc -f "bv*" --audio-multistreams --all-subs https://www.zdf.de/serien/the-tourist-110?staffel=2**
`...
[debug] Command-line config: ['-vc', '-f', 'bv*', '--audio-multistreams', '--all-subs', 'https://www.zdf.de/serien/the-tourist-110?staffel=2']
[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@2024.08.06 from yt-dlp/yt-dlp [4d9231208] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: 4a9bc8c36
[debug] Python 3.13.2 (CPython x86_64 64bit) - Linux-6.13.8-arch1-1-x86_64-with-glibc2.41 (OpenSSL 3.4.1 11 Feb 2025, glibc 2.41)
[debug] exe versions: ffmpeg 7.1 (setts), ffprobe 7.1
[debug] Optional libraries: certifi-2025.01.31, requests-2.32.3, sqlite3-3.49.1, urllib3-2.3.0, websockets-12.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets
[debug] Loaded 1835 extractors
[debug] Using fake IP 53.175.116.19 (DE) as X-Forwarded-For
[ZDFChannel] Extracting URL: https://www.zdf.de/serien/the-tourist-110?staffel=2
[ZDFChannel] the-tourist-110: Downloading webpage
[ZDFChannel] Downloading playlist the-tourist-110 - add --no-playlist to download just the main video
[download] Downloading playlist: The Tourist | groteske Thriller-Serie
[ZDFChannel] Playlist The Tourist | groteske Thriller-Serie: Downloading 0 items
[download] Finished downloading playlist: The Tourist | groteske Thriller-Serie
`
author_association: NONE
user: login: bashonly
created_at: 2025-04-02T04:20:29Z
body: > ```
> [debug] yt-dlp version stable@2024.08.06 from yt-dlp/yt-dlp
> [4d9231208] (source) [debug] Lazy loading extractors is disabled
> [debug] Git HEAD: 4a9bc8c36 [debug]
> ```

@sosyco :point_up: this looks like the wrong commit/branch is checked out. Fetch the fork again and make sure the PR branch is checked out.
author_association: MEMBER
user: login: sosyco
created_at: 2025-04-02T06:26:14Z
body: > __./yt-dlp.sh -v -f "bv_" --audio-multistreams --all-subs --no-playlist https://www.zdf.de/video/serien/inspector-barnaby-104/mord-mit-rendite-nach-motiven-von-caroline-graham-104_

sorry, forgot simple `git checkout remotes/origin/fix-zdf-after-website-rewrite`:

Tests are ok.



author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-04-06T20:22:46Z
body: @seproDev I updated the one test you missed.
author_association: CONTRIBUTOR
user: login: ithod
created_at: 2025-04-07T06:52:18Z
body: I am not into the code, but `--force-generic` seems to work. So would it be possible to remove parts which are zdf specific?
author_association: NONE
user: login: seproDev
created_at: 2025-04-07T07:10:29Z
body: In theory yes, in practice no. Major sites should have a dedicated extractor to ensure consistent IDs, that all formats are extracted, that metadata is extracted, that playlist are supported, etc.
author_association: CONTRIBUTOR
user: login: WhitePeter
created_at: 2025-04-09T06:00:34Z
body: @adrgru and whomever else it may concern:
> ```
> python -m yt_dlp https://www.zdf.de/serien/the-tourist-110?staffel=2 -f "bestvideo+bestaudio[language=en]"
> ```

FYI and FWIW, this is sub-optimal for this extractor/site because they don't provide the opus streams as separate HLS and the AAC files one gets with `bestaudio` are constant bitrate (128 kbits/s) which is just wrong(tm). ;-)

The better alternative:
```
# mind the 'eng' instead of 'en' in the language selector
python -m yt-dlp https://www.zdf.de/serien/the-tourist-110?staffel=2 -S 'acodec:opus' -f 'b[language=eng]'
```
which should fetch the pre-merged webm files that contain a proper variable bitrate opus stream. Also, the VP9 video codec used in these files should provide similar quality to the H.264/AVC codec used in `bestvideo` at a lower bitrate or better quality at the same bitrate. But, given that they saw fit to provide constant bitrate audio streams in this day and age, I am not quite sure if they didn't cut corners and just transcoded the VP9/Opus files from their AVC/AAC counterparts. :-/
So, let's say, *nominally* the pre-merged WebM files comprised of VP9/Opus streams *should* be the better quality option.

@InvalidUsernameException, I have only just begun testing your PR/branch but so far I haven't run into any issues. It seems to work as intended. Thank you so much for your efforts.
author_association: NONE
user: login: Djfe
created_at: 2025-04-14T08:33:33Z
body: I also noticed that default prefers the most compatible file format
`http-h264_aac_mp4_http_na_na-fhd-2`
but `-S 'acodec:opus' -f 'b[language=eng]'` isn't ideal either since it prefers Full HD VP9/Opus (`http-vp9_opus_webm_http_na_na-fhd-1`) over HEVC UHD (HDR)/AAC (`http-h265_aac_mp4_http_na_na-uhd-1`)

example:
https://www.zdf.de/serien/the-tourist-110?staffel=2
<details>
  <summary> .\yt-dlp.cmd -F</summary>

```
PS C:\YouTube\source\yt-dlp> .\yt-dlp.cmd -F https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100 --verbose -s -S 'acodec:opus' -f 'b[language=eng]'
[debug] Command-line config: ['-F', 'https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100', '--verbose', '-s', '-S', 'acodec:opus', '-f', 'b[language=eng]']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2025.03.27 from yt-dlp/yt-dlp [48be862b3] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: c2a576c07
[debug] Python 3.12.9 (CPython AMD64 64bit) - Windows-11-10.0.26100-SP0 (OpenSSL 3.0.15 3 Sep 2024)
[debug] exe versions: ffmpeg 7.1.1-full_build-www.gyan.dev (setts), ffprobe 7.1.1-full_build-www.gyan.dev
[debug] Optional libraries: certifi-2024.07.04, requests-2.32.3, sqlite3-3.45.3, urllib3-2.2.2
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests
[debug] Plugin directories: none
[debug] Loaded 1849 extractors
[debug] Using fake IP 53.130.198.45 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100
[debug] Loading zdf.api-token from cache
[ZDF] iren-sind-menschlich-100: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] iren-sind-menschlich-100: Downloading PTMD data
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[ZDF] 240603_2215_sendung_dia: Downloading m3u8 information
[debug] Sort order given by user: acodec:opus
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, acodec:opus(12), tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, size, br, asr, proto, vext, aext, hasaud, source, id
[info] Available formats for iren-sind-menschlich-100:
ID                                       EXT  RESOLUTION FPS │   FILESIZE   TBR PROTO │ VCODEC          VBR ACODEC     MORE INFO
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
hls-audio0-Originalton_englisch-0        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-0                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Audiodeskription-0            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Audiodeskription-1            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-TV_Ton-1                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-1        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-Audiodeskription-2            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Originalton_englisch-2        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-2                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-3        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-3                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Audiodeskription-3            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Audiodeskription-4            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-TV_Ton-4                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-4        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-Audiodeskription-5            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Originalton_englisch-5        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-5                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-6        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-6                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Audiodeskription-6            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Audiodeskription-7            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-TV_Ton-7                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-7        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-Audiodeskription-8            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Originalton_englisch-8        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-8                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-9        mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-9                      mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Audiodeskription-9            mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Audiodeskription-10           mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-TV_Ton-10                     mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-audio0-Originalton_englisch-10       mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-Audiodeskription-11           mp4  audio only     │                  m3u8  │ audio only          unknown    [de] Audiodeskription
hls-audio0-Originalton_englisch-11       mp4  audio only     │                  m3u8  │ audio only          unknown    [en] Originalton englisch
hls-audio0-TV_Ton-11                     mp4  audio only     │                  m3u8  │ audio only          unknown    [de] TV Ton
hls-321-0                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] auto, ad
hls-321-1                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] auto, ad
hls-321-2                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] auto, ad
hls-321-3                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] auto, ot
hls-321-4                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] auto, ot
hls-321-5                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] auto, ot
hls-321-6                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] auto, main
hls-321-7                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] auto, main
hls-321-8                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] auto, main
hls-321-9                                mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] low, ad
hls-321-10                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] low, ad
hls-321-11                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] low, ad
hls-321-12                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] low, ot
hls-321-13                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] low, ot
hls-321-14                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] low, ot
hls-321-15                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] low, main
hls-321-16                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] low, main
hls-321-17                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] low, main
hls-321-18                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] med, ad
hls-321-19                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] med, ad
hls-321-20                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] med, ad
hls-321-21                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] med, ot
hls-321-22                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] med, ot
hls-321-23                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [eng] med, ot
hls-321-24                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] med, main
hls-321-25                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] med, main
hls-321-26                               mp4  480x270     25 │ ~136.05MiB  321k m3u8  │ avc1.4d401f    321k video only [deu] med, main
hls-559-0                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [deu] auto, ad
hls-559-1                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [eng] auto, ot
hls-559-2                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [deu] auto, main
hls-559-3                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [deu] low, ad
hls-559-4                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [eng] low, ot
hls-559-5                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [deu] low, main
hls-559-6                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [deu] med, ad
hls-559-7                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [eng] med, ot
hls-559-8                                mp4  640x360     25 │ ~237.09MiB  560k m3u8  │ avc1.4d401f    560k video only [deu] med, main
hls-1223-0                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] auto, ad
hls-1223-1                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [eng] auto, ot
hls-1223-2                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] auto, main
hls-1223-3                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] med, ad
hls-1223-4                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [eng] med, ot
hls-1223-5                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] med, main
hls-1223-6                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] veryhigh, ad
hls-1223-7                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] veryhigh, ad
hls-1223-8                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] veryhigh, ad
hls-1223-9                               mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [eng] veryhigh, ot
hls-1223-10                              mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [eng] veryhigh, ot
hls-1223-11                              mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [eng] veryhigh, ot
hls-1223-12                              mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] veryhigh, main
hls-1223-13                              mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] veryhigh, main
hls-1223-14                              mp4  960x540     25 │ ~518.19MiB 1224k m3u8  │ avc1.4d401f   1224k video only [deu] veryhigh, main
hls-2624-0                               mp4  1280x720    50 │ ~  1.09GiB 2624k m3u8  │ avc1.640028   2624k video only [deu] auto, ad
hls-2624-1                               mp4  1280x720    50 │ ~  1.09GiB 2624k m3u8  │ avc1.640028   2624k video only [eng] auto, ot
hls-2624-2                               mp4  1280x720    50 │ ~  1.09GiB 2624k m3u8  │ avc1.640028   2624k video only [deu] auto, main
hls-2624-3                               mp4  1280x720    50 │ ~  1.09GiB 2624k m3u8  │ avc1.640028   2624k video only [deu] veryhigh, ad
hls-2624-4                               mp4  1280x720    50 │ ~  1.09GiB 2624k m3u8  │ avc1.640028   2624k video only [eng] veryhigh, ot
hls-2624-5                               mp4  1280x720    50 │ ~  1.09GiB 2624k m3u8  │ avc1.640028   2624k video only [deu] veryhigh, main
hls-5332-0                               mp4  1920x1080   50 │ ~  2.21GiB 5333k m3u8  │ avc1.64002a   5333k video only [deu] auto, ad
hls-5332-1                               mp4  1920x1080   50 │ ~  2.21GiB 5333k m3u8  │ avc1.64002a   5333k video only [eng] auto, ot
hls-5332-2                               mp4  1920x1080   50 │ ~  2.21GiB 5333k m3u8  │ avc1.64002a   5333k video only [deu] auto, main
hls-5332-3                               mp4  1920x1080   50 │ ~  2.21GiB 5333k m3u8  │ avc1.64002a   5333k video only [deu] veryhigh, ad
hls-5332-4                               mp4  1920x1080   50 │ ~  2.21GiB 5333k m3u8  │ avc1.64002a   5333k video only [eng] veryhigh, ot
hls-5332-5                               mp4  1920x1080   50 │ ~  2.21GiB 5333k m3u8  │ avc1.64002a   5333k video only [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-low-0       mp4  unknown        │ ≈215.10MiB  508k https │ avc1.4d401f         mp4a.40.2  [deu] low, ad
http-h264_aac_mp4_http_na_na-low-1       mp4  unknown        │ ≈215.10MiB  508k https │ avc1.4d401f         mp4a.40.2  [eng] low, ot
http-h264_aac_mp4_http_na_na-low-2       mp4  unknown        │ ≈215.10MiB  508k https │ avc1.4d401f         mp4a.40.2  [deu] low, main
http-h264_aac_mp4_http_na_na-high-0      mp4  unknown        │ ≈342.13MiB  808k https │ avc1.4d401f         mp4a.40.2  [deu] high, ad
http-h264_aac_mp4_http_na_na-high-1      mp4  unknown        │ ≈342.13MiB  808k https │ avc1.4d401f         mp4a.40.2  [deu] high, ad
http-h264_aac_mp4_http_na_na-high-2      mp4  unknown        │ ≈342.13MiB  808k https │ avc1.4d401f         mp4a.40.2  [eng] high, ot
http-h264_aac_mp4_http_na_na-high-3      mp4  unknown        │ ≈342.13MiB  808k https │ avc1.4d401f         mp4a.40.2  [eng] high, ot
http-h264_aac_mp4_http_na_na-high-4      mp4  unknown        │ ≈342.13MiB  808k https │ avc1.4d401f         mp4a.40.2  [deu] high, main
http-h264_aac_mp4_http_na_na-high-5      mp4  unknown        │ ≈342.13MiB  808k https │ avc1.4d401f         mp4a.40.2  [deu] high, main
http-h264_aac_mp4_http_na_na-veryhigh-0  mp4  unknown        │ ≈689.35MiB 1628k https │ avc1.4d401f         mp4a.40.2  [deu] veryhigh, ad
http-h264_aac_mp4_http_na_na-veryhigh-1  mp4  unknown        │ ≈689.35MiB 1628k https │ avc1.4d401f         mp4a.40.2  [deu] veryhigh, ad
http-h264_aac_mp4_http_na_na-veryhigh-2  mp4  unknown        │ ≈689.35MiB 1628k https │ avc1.4d401f         mp4a.40.2  [eng] veryhigh, ot
http-h264_aac_mp4_http_na_na-veryhigh-3  mp4  unknown        │ ≈689.35MiB 1628k https │ avc1.4d401f         mp4a.40.2  [eng] veryhigh, ot
http-h264_aac_mp4_http_na_na-veryhigh-4  mp4  unknown        │ ≈689.35MiB 1628k https │ avc1.4d401f         mp4a.40.2  [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-veryhigh-5  mp4  unknown        │ ≈689.35MiB 1628k https │ avc1.4d401f         mp4a.40.2  [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-hd-0        mp4  unknown        │ ≈  1.38GiB 3328k https │ avc1.640028         mp4a.40.2  [deu] hd, ad
http-h264_aac_mp4_http_na_na-hd-1        mp4  unknown        │ ≈  1.38GiB 3328k https │ avc1.640028         mp4a.40.2  [eng] hd, ot
http-h264_aac_mp4_http_na_na-hd-2        mp4  unknown        │ ≈  1.38GiB 3328k https │ avc1.640028         mp4a.40.2  [deu] hd, main
http-h265_aac_mp4_http_na_na-uhd-0       mp4  unknown        │ ≈  1.94GiB 4692k https │ hvc1.1.7.L120       mp4a.40.2  [deu] uhd, ad
http-h265_aac_mp4_http_na_na-uhd-1       mp4  unknown        │ ≈  1.94GiB 4692k https │ hvc1.1.7.L120       mp4a.40.2  [eng] uhd, ot
http-h265_aac_mp4_http_na_na-uhd-2       mp4  unknown        │ ≈  1.94GiB 4692k https │ hvc1.1.7.L120       mp4a.40.2  [deu] uhd, main
http-h264_aac_mp4_http_na_na-fhd-0       mp4  unknown        │ ≈  2.74GiB 6628k https │ avc1.64002a         mp4a.40.2  [deu] fhd, ad
http-h264_aac_mp4_http_na_na-fhd-1       mp4  unknown        │ ≈  2.74GiB 6628k https │ avc1.64002a         mp4a.40.2  [eng] fhd, ot
http-h264_aac_mp4_http_na_na-fhd-2       mp4  unknown        │ ≈  2.74GiB 6628k https │ avc1.64002a         mp4a.40.2  [deu] fhd, main
http-vp9_opus_webm_http_na_na-high-0     webm unknown        │ ≈155.82MiB  368k https │ vp9                 opus       [deu] high, ad
http-vp9_opus_webm_http_na_na-high-1     webm unknown        │ ≈155.82MiB  368k https │ vp9                 opus       [eng] high, ot
http-vp9_opus_webm_http_na_na-high-2     webm unknown        │ ≈155.82MiB  368k https │ vp9                 opus       [deu] high, main
http-vp9_opus_webm_http_na_na-veryhigh-0 webm unknown        │ ≈477.63MiB 1128k https │ vp9                 opus       [deu] veryhigh, ad
http-vp9_opus_webm_http_na_na-veryhigh-1 webm unknown        │ ≈477.63MiB 1128k https │ vp9                 opus       [eng] veryhigh, ot
http-vp9_opus_webm_http_na_na-veryhigh-2 webm unknown        │ ≈477.63MiB 1128k https │ vp9                 opus       [deu] veryhigh, main
http-vp9_opus_webm_http_na_na-hd-0       webm unknown        │ ≈901.06MiB 2128k https │ vp9                 opus       [deu] hd, ad
http-vp9_opus_webm_http_na_na-hd-1       webm unknown        │ ≈901.06MiB 2128k https │ vp9                 opus       [eng] hd, ot
http-vp9_opus_webm_http_na_na-hd-2       webm unknown        │ ≈901.06MiB 2128k https │ vp9                 opus       [deu] hd, main
http-vp9_opus_webm_http_na_na-fhd-0      webm unknown        │ ≈  1.79GiB 4328k https │ vp9                 opus       [deu] fhd, ad
http-vp9_opus_webm_http_na_na-fhd-1      webm unknown        │ ≈  1.79GiB 4328k https │ vp9                 opus       [eng] fhd, ot
http-vp9_opus_webm_http_na_na-fhd-2      webm unknown        │ ≈  1.79GiB 4328k https │ vp9                 opus       [deu] fhd, main
[info] iren-sind-menschlich-100: Downloading 1 format(s): http-vp9_opus_webm_http_na_na-fhd-1
```
</details>

EDIT:
But everything is working fine with this PR, so no complaints from my side :)
This is just a side note and can be fixed at any later stage in another PR or so

Is the information on "Resolution/FPS" available somewhere for all http files? (maybe that's whats missing here since yt-dlp see's the codec but not the Resolution to compare UHD against FHD (or should the extractor parse this from the stream name?)

The AAC of the UHD stream is encoded as 192kbit/s btw.
```
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '.\Iren sind menschlich [iren-sind-menschlich-100].mp4':
  Metadata:
    major_brand     : mp42
    minor_version   : 1
    compatible_brands: isommp42
    creation_time   : 2024-05-24T09:04:29.000000Z
  Duration: 00:59:12.96, start: 0.000000, bitrate: 4394 kb/s
  Stream #0:0[0x1](deu): Video: hevc (Main 10) (hvc1 / 0x31637668), yuv420p10le(tv, bt2020nc/bt2020/arib-std-b67), 3840x2160 [SAR 1:1 DAR 16:9], 4193 kb/s, 50 fps, 50 tbr, 1k tbn (default)
      Metadata:
        creation_time   : 2024-05-24T09:04:29.000000Z
        handler_name    : GPAC ISO Video Handler
        vendor_id       : [0][0][0][0]
  Stream #0:1[0x2](deu): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 192 kb/s (default)
      Metadata:
        creation_time   : 2024-05-24T09:04:29.000000Z
        handler_name    : GPAC ISO Audio Handler
        vendor_id       : [0][0][0][0]
```

edit2:
interesting, maybe I found an issue after all:
Here's another UHD example where streams are correctly sorted by resolution despite HLS/HTTP differences
https://www.zdf.de/play/serien/das-traumschiff-104/coco-island-100?q=coco+island

Here it downloads HEVC by default.
Is the difference `sendung_trs` (order is correct) vs `sendung_dia` (not sorted by resolution but by ID column only)?
author_association: NONE
user: login: WhitePeter
created_at: 2025-04-14T15:10:30Z
body: > I also noticed that default prefers the most compatible file format `http-h264_aac_mp4_http_na_na-fhd-2` but `-S 'acodec:opus' -f 'b[language=eng]'` isn't ideal either since it prefers Full HD VP9/Opus (`http-vp9_opus_webm_http_na_na-fhd-1`) over HEVC UHD (HDR)/AAC (`http-h265_aac_mp4_http_na_na-uhd-1`)

Oh, that's new. Or at least I hadn't noticed until just now, because I cap resolution to 1080p, since that's the resolution of my display, which also does not support HDR.

> EDIT: But everything is working fine with this PR, so no complaints from my side :) This is just a side note and can be fixed at any later stage in another PR or so

I believe that this is not an error, it's just that `yt-dlp` has limits when it comes to choosing the best format. That, combined with ZDF's, and 3sat's for that matter, odd choice of formats makes it difficult to find the correct setting. BTW, anything with a resolution >540p is 50 fps with duplicate frames inserted for no apparent reason whatsoever. m(
Which is why I am content with 540p VP9/opus streams which are kept at the original frame rate (25 fps); the video fidelity is good enough IMHO.

But enough of my ranting, for now. Try this then?
```
yt-dlp -S 'acodec:opus' -f '(b[vcodec^=hvc]/b)[language=eng]' https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100 https://www.zdf.de/video/serien/the-rookie-100/die-letzte-schicht-104
```
EDIT: Alternatively you can look for a `format_note` starting with `uhd`:
```
yt-dlp -S 'acodec:opus' -f '(b[format_note^=uhd]/b)[language=eng]' https://www.zdf.de/video/serien/the-tourist-110/iren-sind-menschlich-100 https://www.zdf.de/video/serien/the-rookie-100/die-letzte-schicht-104
```
That should give you the HEVC/UHD/HDR stream, if one is available (e.g. The Tourist), and otherwise (e.g. The Rookie) select the best one with an opus audio stream. This is also not ideal, since they chose to stick with constant bitrate for the AAC stream in the UHD file, but at least they increased it to 192 kbps. So now it's a waste of bandwidth, but at least it should be good quality audio.

> Is the information on "Resolution/FPS" available somewhere for all http files? (maybe that's whats missing here since yt-dlp see's the codec but not the Resolution to compare UHD against FHD (or should the extractor parse this from the stream name?)

I believe that information is not available to `yt-dlp`. At least I have never seen and https stream with known resolution. But I am by no means an expert.

> The AAC of the UHD stream is encoded as 192kbit/s btw.

Still constant bitrate though. But they shifted from capping quality to wasting bandwidth. m(
> edit2: interesting, maybe I found an issue after all: Here's another UHD example where streams are correctly sorted by resolution despite HLS/HTTP differences https://www.zdf.de/play/serien/das-traumschiff-104/coco-island-100?q=coco+island
> 
> Here it downloads HEVC by default. Is the difference `sendung_trs` (order is correct) vs `sendung_dia` (not sorted by resolution but by ID column only)?

I am pretty certain that's because the `tbr` for UHD is greater than the FHD stream in this case. And this extractor puts `tbr` first in the sort order:
```
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
```
Oddly enough the episode of The Tourist above has a smaller UHD bitrate than FHD.

----
EDIT: @InvalidUsernameException, I have yet to find an issue with this PR. Works great!
author_association: NONE
user: login: adrgru
created_at: 2025-04-14T19:21:39Z
body: At least for heute-show, this version downloads the sign language version instead of the version without it.

URL: https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100

The latest nightly (with the ".html" hack) downloads 250328_sendung_hsh, while this fork downloads 250328_sendung_hsh_dgs.

Latest nightly:
```
[debug] Command-line config: ['https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100.html', '--skip-download', '--verbose']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version nightly@2025.04.06.232826 from yt-dlp/yt-dlp-nightly-builds [74e90dd9b] (win_exe)
[debug] Python 3.10.11 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 1.1.1t  7 Feb 2023)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.22.0, brotli-1.1.0, certifi-2025.01.31, curl_cffi-0.10.0, 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 1856 extractors
[debug] Using fake IP 53.205.184.43 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100.html
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading webpage
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading JSON metadata
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[debug] Formats sorted by: hasvid, ie_pref, lang, quality, res, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[debug] Default format spec: bestvideo*+bestaudio/best
[info] 250328_sendung_hsh: Downloading 1 format(s): http-h264_aac_mp4_http_na_na-fhd
```

Using this fork:
```
[debug] Command-line config: ['https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100', '--skip-download', '--verbose']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2025.03.27 from yt-dlp/yt-dlp [48be862b3] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: 2ad9fd8d8
[debug] Python 3.11.9 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 3.0.13 30 Jan 2024)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.17, brotli-1.1.0, certifi-2024.07.04, curl_cffi-0.10.0, mutagen-1.47.0, requests-2.32.3, sqlite3-3.45.1, urllib3-2.2.2, websockets-13.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Plugin directories: none
[debug] Loaded 1850 extractors
[debug] Using fake IP 53.40.43.171 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100
[debug] Loading zdf.api-token from cache
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[debug] Default format spec: bestvideo*+bestaudio/best
[info] heute-show-vom-28-maerz-2025-100: Downloading 1 format(s): http-h264_aac_mp4_http_na_na-fhd-1
```
author_association: NONE
user: login: hermannch
created_at: 2025-04-14T20:34:10Z
body: i just rebased this (c2a576c07ea343c2a447d4ea4cb8457f8fd00e49) onto current master  (74e90dd9b) . it works!
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-04-14T21:24:21Z
body: @Djfe, @WhitePeter Yeah, the default format sort for this extractor isn't great. I noticed that as well during my testing. I hadn't noticed the particular case where UHD gets prioritized lower than FHD, but it all falls in the same general area, I think. However, the behavior should be the same as it used to be with the old website and implementation on master. I intentionally left format sorting/prioritization alone to keep the PR scope more focussed. But i would say that is definitely an area of improvement for the future.

> Is the information on "Resolution/FPS" available somewhere for all http files? (maybe that's whats missing here since yt-dlp see's the codec but not the Resolution to compare UHD against FHD (or should the extractor parse this from the stream name?)

Having resolutions for all formats would certainly help with better default sorting. As noted in a comment in the PR we do have an aspect ratio and a vertical resolution available for HTTPS format. So we could calculate a resolution from that. Or we could use the quality labels (`hd`, `fhd`, `uhd` et al.) and match them to a resolution. But same as above with the general format sorting: I considered this out of scope for the PR since this was the same before the extractor broke.


author_association: CONTRIBUTOR
user: login: InvalidUsernameException
created_at: 2025-04-14T21:31:29Z
body: > At least for heute-show, this version downloads the sign language version instead of the version without it.
> 
> URL: https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100

@adrgru Do you actually get a sign language if you watch it? For me it only showed that on the command line message. That however was a mistake and caused the extractor to generate the wrong entry in `_old_archive_ids`. I have pushed a fix for that. With that I now get the following output:

```
yt-dlp -F https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100
[ZDF] Extracting URL: https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading video metadata
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[info] Available formats for heute-show-vom-28-maerz-2025-100:
ID                                       EXT  RESOLUTION FPS │   FILESIZE   TBR PROTO │ VCODEC      ACODEC    MORE INFO
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
hls-323-0                                mp4  480x270     25 │ ~ 81.18MiB  323k m3u8  │ avc1.4d401f mp4a.40.2 [deu] auto, main
hls-323-1                                mp4  480x270     25 │ ~ 81.18MiB  323k m3u8  │ avc1.4d401f mp4a.40.2 [deu] low, main
hls-323-2                                mp4  480x270     25 │ ~ 81.18MiB  323k m3u8  │ avc1.4d401f mp4a.40.2 [deu] med, main
hls-340-0                                mp4  480x270     25 │ ~ 85.52MiB  340k m3u8  │ avc1.4d401f mp4a.40.2 [deu] auto, main
hls-340-1                                mp4  480x270     25 │ ~ 85.52MiB  340k m3u8  │ avc1.4d401f mp4a.40.2 [deu] low, main
hls-340-2                                mp4  480x270     25 │ ~ 85.52MiB  340k m3u8  │ avc1.4d401f mp4a.40.2 [deu] med, main
http-vp9_opus_webm_http_na_na-high-0     webm unknown        │ ≈ 92.48MiB  368k https │ vp9         opus      [deu] high, main
http-vp9_opus_webm_http_na_na-high-1     webm unknown        │ ≈ 92.48MiB  368k https │ vp9         opus      [deu] high, main
http-h264_aac_mp4_http_na_na-low-0       mp4  unknown        │ ≈127.66MiB  508k https │ avc1.4d401f mp4a.40.2 [deu] low, main
http-h264_aac_mp4_http_na_na-low-1       mp4  unknown        │ ≈127.66MiB  508k https │ avc1.4d401f mp4a.40.2 [deu] low, main
hls-565-0                                mp4  640x360     25 │ ~142.03MiB  565k m3u8  │ avc1.4d401f mp4a.40.2 [deu] auto, main
hls-565-1                                mp4  640x360     25 │ ~142.03MiB  565k m3u8  │ avc1.4d401f mp4a.40.2 [deu] low, main
hls-565-2                                mp4  640x360     25 │ ~142.03MiB  565k m3u8  │ avc1.4d401f mp4a.40.2 [deu] med, main
hls-589-0                                mp4  640x360     25 │ ~148.03MiB  589k m3u8  │ avc1.4d401f mp4a.40.2 [deu] auto, main
hls-589-1                                mp4  640x360     25 │ ~148.03MiB  589k m3u8  │ avc1.4d401f mp4a.40.2 [deu] low, main
hls-589-2                                mp4  640x360     25 │ ~148.03MiB  589k m3u8  │ avc1.4d401f mp4a.40.2 [deu] med, main
http-h264_aac_mp4_http_na_na-high-0      mp4  unknown        │ ≈203.04MiB  808k https │ avc1.4d401f mp4a.40.2 [deu] high, main
http-h264_aac_mp4_http_na_na-high-1      mp4  unknown        │ ≈203.04MiB  808k https │ avc1.4d401f mp4a.40.2 [deu] high, main
http-h264_aac_mp4_http_na_na-high-2      mp4  unknown        │ ≈203.04MiB  808k https │ avc1.4d401f mp4a.40.2 [deu] high, main
http-h264_aac_mp4_http_na_na-high-3      mp4  unknown        │ ≈203.04MiB  808k https │ avc1.4d401f mp4a.40.2 [deu] high, main
http-vp9_opus_webm_http_na_na-veryhigh-0 webm unknown        │ ≈283.46MiB 1128k https │ vp9         opus      [deu] veryhigh, main
http-vp9_opus_webm_http_na_na-veryhigh-1 webm unknown        │ ≈283.46MiB 1128k https │ vp9         opus      [deu] veryhigh, main
hls-1229-0                               mp4  960x540     25 │ ~309.05MiB 1230k m3u8  │ avc1.4d401f mp4a.40.2 [deu] auto, main
hls-1229-1                               mp4  960x540     25 │ ~309.05MiB 1230k m3u8  │ avc1.4d401f mp4a.40.2 [deu] med, main
hls-1229-2                               mp4  960x540     25 │ ~309.05MiB 1230k m3u8  │ avc1.4d401f mp4a.40.2 [deu] veryhigh, main
hls-1271-0                               mp4  960x540     25 │ ~319.46MiB 1271k m3u8  │ avc1.4d401f mp4a.40.2 [deu] auto, main
hls-1271-1                               mp4  960x540     25 │ ~319.46MiB 1271k m3u8  │ avc1.4d401f mp4a.40.2 [deu] med, main
hls-1271-2                               mp4  960x540     25 │ ~319.46MiB 1271k m3u8  │ avc1.4d401f mp4a.40.2 [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-veryhigh-0  mp4  unknown        │ ≈409.11MiB 1628k https │ avc1.4d401f mp4a.40.2 [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-veryhigh-1  mp4  unknown        │ ≈409.11MiB 1628k https │ avc1.4d401f mp4a.40.2 [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-veryhigh-2  mp4  unknown        │ ≈409.11MiB 1628k https │ avc1.4d401f mp4a.40.2 [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-veryhigh-3  mp4  unknown        │ ≈409.11MiB 1628k https │ avc1.4d401f mp4a.40.2 [deu] veryhigh, main
http-vp9_opus_webm_http_na_na-hd-0       webm unknown        │ ≈534.75MiB 2128k https │ vp9         opus      [deu] hd, main
http-vp9_opus_webm_http_na_na-hd-1       webm unknown        │ ≈534.75MiB 2128k https │ vp9         opus      [deu] hd, main
hls-2628-0                               mp4  1280x720    50 │ ~660.41MiB 2628k m3u8  │ avc1.640028 mp4a.40.2 [deu] auto, main
hls-2628-1                               mp4  1280x720    50 │ ~660.41MiB 2628k m3u8  │ avc1.640028 mp4a.40.2 [deu] veryhigh, main
hls-2729-0                               mp4  1280x720    50 │ ~685.86MiB 2729k m3u8  │ avc1.640028 mp4a.40.2 [deu] auto, main
hls-2729-1                               mp4  1280x720    50 │ ~685.86MiB 2729k m3u8  │ avc1.640028 mp4a.40.2 [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-hd-0        mp4  unknown        │ ≈836.30MiB 3328k https │ avc1.640028 mp4a.40.2 [deu] hd, main
http-h264_aac_mp4_http_na_na-hd-1        mp4  unknown        │ ≈836.30MiB 3328k https │ avc1.640028 mp4a.40.2 [deu] hd, main
http-vp9_opus_webm_http_na_na-fhd-0      webm unknown        │ ≈  1.06GiB 4328k https │ vp9         opus      [deu] fhd, main
http-vp9_opus_webm_http_na_na-fhd-1      webm unknown        │ ≈  1.06GiB 4328k https │ vp9         opus      [deu] fhd, main
hls-5302-0                               mp4  1920x1080   50 │ ~  1.30GiB 5303k m3u8  │ avc1.64002a mp4a.40.2 [deu] auto, main
hls-5302-1                               mp4  1920x1080   50 │ ~  1.30GiB 5303k m3u8  │ avc1.64002a mp4a.40.2 [deu] veryhigh, main
hls-5496-0                               mp4  1920x1080   50 │ ~  1.35GiB 5496k m3u8  │ avc1.64002a mp4a.40.2 [deu] auto, main
hls-5496-1                               mp4  1920x1080   50 │ ~  1.35GiB 5496k m3u8  │ avc1.64002a mp4a.40.2 [deu] veryhigh, main
http-h264_aac_mp4_http_na_na-fhd-0       mp4  unknown        │ ≈  1.63GiB 6628k https │ avc1.64002a mp4a.40.2 [deu] fhd, main
http-h264_aac_mp4_http_na_na-fhd-1       mp4  unknown        │ ≈  1.63GiB 6628k https │ avc1.64002a mp4a.40.2 [deu] fhd, main
```
`http-h264_aac_mp4_http_na_na-fhd-0` is the sign-language variant, `http-h264_aac_mp4_http_na_na-fhd-1` the "regular" one and gets preferred.
author_association: CONTRIBUTOR
user: login: adrgru
created_at: 2025-04-14T21:58:00Z
body: I was running an outdated version of your branch. I just ran it again with the latest version of your "fix-zdf-after-website-rewrite" branch, and while the display error is fixed, it still downloads the sign language version with my code.

If I run it without any parameters, it downloads the bare version. However, I have it set to download the highest quality version at resolution 720p or lower and then it does download the sign language version.

Seems the https ones get discarded immediately since they don't have dimensions listed. That leaves these four:

```
hls-2628-0                               mp4  1280x720    50 │ ~660.41MiB 2628k m3u8  │ avc1.640028 mp4a.40.2 [deu] auto, main
hls-2628-1                               mp4  1280x720    50 │ ~660.41MiB 2628k m3u8  │ avc1.640028 mp4a.40.2 [deu] veryhigh, main
hls-2729-0                               mp4  1280x720    50 │ ~685.86MiB 2729k m3u8  │ avc1.640028 mp4a.40.2 [deu] auto, main
hls-2729-1                               mp4  1280x720    50 │ ~685.86MiB 2729k m3u8  │ avc1.640028 mp4a.40.2 [deu] veryhigh, main
```

hls-2628-* is the bare video, while hls-2729-* contains the sign language translation. `best[height<=720]` grabs `hls-2729-1`.

Here's my output:

```
[debug] Command-line config: ['https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100', '-f', 'bestvideo[height<=720]+bestaudio/best[height<=720]', '--verbose']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2025.03.27 from yt-dlp/yt-dlp [48be862b3] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: 3bb3aa467
[debug] Python 3.11.9 (CPython AMD64 64bit) - Windows-10-10.0.26100-SP0 (OpenSSL 3.0.13 30 Jan 2024)
[debug] exe versions: ffmpeg 7.1-essentials_build-www.gyan.dev (setts), ffprobe 7.1-essentials_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.17, brotli-1.1.0, certifi-2024.07.04, curl_cffi-0.10.0, mutagen-1.47.0, requests-2.32.3, sqlite3-3.45.1, urllib3-2.2.2, websockets-13.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Plugin directories: none
[debug] Loaded 1849 extractors
[debug] Using fake IP 53.27.206.242 (DE) as X-Forwarded-For
[ZDF] Extracting URL: https://www.zdf.de/video/shows/heute-show-104/heute-show-vom-28-maerz-2025-100
[debug] Loading zdf.api-token from cache
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading video metadata
[debug] Loading zdf.api-token from cache
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
[ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[ZDF] 250328_sendung_hsh: Downloading m3u8 information
[debug] Sort order given by extractor: tbr, res, quality, language_preference
[debug] Formats sorted by: hasvid, ie_pref, tbr, res, quality, lang, fps, hdr:12(7), vcodec, channels, acodec, size, br, asr, proto, vext, aext, hasaud, source, id
[info] heute-show-vom-28-maerz-2025-100: Downloading 1 format(s): hls-2729-1
[debug] Invoking hlsnative downloader on "https://zdfvod-rwrtr.akamaized.net/i/,/mp4/none/zdf/25/03/250328_sendung_hsh_dgs/1/250328_sendung_hsh_dgs,_1628k_p13,_3328k_p15,_6628k_p61,v17.mp4.csmil/index-f2-v1-a1.m3u8"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 266
[download] Destination: heute-show vom 28. März 2025 [heute-show-vom-28-maerz-2025-100].mp4
[debug] File locking is not supported. Proceeding without locking
[download]  12.8% of ~ 684.65MiB at    4.27MiB/s ETA 02:17 (frag 34/266)
ERROR: Interrupted by user
```
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-04-14T22:12:21Z
body: > hls-2628-* is the bare video, while hls-2729-* contains the sign language translation. best[height<=720] grabs hls-2729-1

Ah, yeah. The bitrates for the `hls-*` variants differ between regular video and sign-language (kind of obvious thinking about it). And it's going to be somewhat random which one is going to have a higher bitrate, so there is no consistent way to make that distinction currently.

I think that is a new problem, and something that needs to be fixed in eh scope of this PR then.
author_association: CONTRIBUTOR
user: login: bashonly
created_at: 2025-04-18T18:34:50Z
body: > ```
> [ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
> [ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
> [ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
> [ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
> [ZDF] 250328_sendung_hsh_dgs: Downloading m3u8 information
> [ZDF] heute-show-vom-28-maerz-2025-100: Downloading PTMD data
> [ZDF] 250328_sendung_hsh: Downloading m3u8 information
> [ZDF] 250328_sendung_hsh: Downloading m3u8 information
> [ZDF] 250328_sendung_hsh: Downloading m3u8 information
> [ZDF] 250328_sendung_hsh: Downloading m3u8 information
> ```

Why are we extracting formats from two separate video IDs for a single video?
author_association: MEMBER
user: login: InvalidUsernameException
created_at: 2025-04-18T19:26:36Z
body: > Why are we extracting formats from two separate video IDs for a single video?

The `*_dgs` videos are formats where the video has an overlay of a person doing sign-language. As discussed with adrgru, those will need to be marked and prioritized as such to not break format selection.


author_association: CONTRIBUTOR
user: login: Djfe
created_at: 2025-04-20T17:44:10Z
body: unexpected, but great. Thank you ❤️
Will test this later, looking forward for this to be merged
author_association: NONE
user: login: InvalidUsernameException
created_at: 2025-04-20T17:52:24Z
body: I did a bunch more changes to resolve some of the problems discussed in the last couple of comments. (Yes, I said some of that was out of scope, but it turned out to all be connected.)

Summary:
* All formats (including the HTTPS ones) on ZDF (main site) and 3sat now provide full resolution information. On Phoenix and ZDFheute (https://zdf.de/nachrichten), HTTPS videos now provide at least a height.
* VP9 formats are now also available for ZDFheute
* Sign-language variants of the video are now marked with `dgs` in the format-note and deprioritized by default.
* Default format sorting has been improved and should make much more sense now. There might still be an open question about prioritizing VP9 vs. H264, but that should be less important and can be influenced more easily via command line. Also, the sorting for audio-only formats looks weird to me, but that is not even extractor-code to begin with.

@&#8288;maintainers: I am uncertain about `preference` vs. `source_preference` vs. `language_preference` and what each one is supposed to be used for. So you may want to take a closer look at that.
author_association: CONTRIBUTOR
user: login: bashonly
created_at: 2025-04-30T22:31:20Z
body: @InvalidUsernameException Thank you for your work on this. Your GraphQL solution saved us from Next.js hell.

Sorry that I ended up pushing changes directly to the branch rather than giving it a final review; I find that when the diff is this large it's hard to reason with (or even use) the Github web UI for code review. I tried to write atomic commits with descriptive messages, but if you have any questions about any of the changes I made, I would be happy to answer them
author_association: MEMBER
user: login: InvalidUsernameException
created_at: 2025-04-30T22:53:45Z
body: @bashonly Thanks, I'll give it a proper look later. One thing already seems a bit weird to me, but it's nothing that affects functionality, so it can wait a bit.

> Sorry that I ended up pushing changes directly to the branch rather than giving it a final review; I find that when the diff is this large it's hard to reason with (or even use) the Github web UI for code review.

Don't worry about it. The nature of this project causes an abundant amount of work to be generated all the time. So, if it reduces effort on maintainer side, that is a win in my book. And like you said, I can look through the commits.
author_association: CONTRIBUTOR