Repository: yt-dlp/yt-dlp (python)

Issue:
ARD age verification
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I *intentionally* remove or skip any mandatory\* field

### Checklist

- [X] I'm requesting a site-specific feature
- [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 searched [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766) and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [ ] 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

### Example URLs

https://www.ardmediathek.de/video/oderbruch/folge-3-blindgaenger-s01-e03/das-erste/Y3JpZDovL2Rhc2Vyc3RlLmRlL29kZXJicnVjaC8yMDI0LTAxLTIwXzAwLTAwLU1FWg

### Provide a description that is worded well enough to be understood

To play that video in the browser, you need to sign in and share your passport to verify your age. Afterwards you can play any video with FSK at any time. There is also a PIN verification, it might be a client side verification only from what I see.

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [ ] If using API, add `'verbose': True` to `YoutubeDL` params instead
- [X] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[debug] Command-line config: ['-vU', 'https://www.ardmediathek.de/video/oderbruch/folge-3-blindgaenger-s01-e03/das-erste/Y3JpZDovL2Rhc2Vyc3RlLmRlL29kZXJicnVjaC8yMDI0LTAxLTIwXzAwLTAwLU1FWg', '--cookies-from-browser', 'brave']
Extracting cookies from brave
[debug] Extracting cookies from: "/Users/myusername/Library/Application Support/BraveSoftware/Brave-Browser/Default/Cookies"
[debug] using find-generic-password to obtain password from OSX keychain
Extracted 838 cookies from brave
[debug] cookie version breakdown: {'v10': 827, 'other': 0, 'unencrypted': 11}
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8 (No ANSI), error utf-8 (No ANSI), screen utf-8 (No ANSI)
[debug] yt-dlp version stable@2023.12.30 from yt-dlp/yt-dlp [f10589e34] (source)
[debug] Git HEAD: f24e44e8c
[debug] Python 3.9.6 (CPython arm64 64bit) - macOS-14.2.1-arm64-arm-64bit (LibreSSL 2.8.3)
[debug] exe versions: ffmpeg 6.1.1 (setts), ffprobe 6.1.1
[debug] Optional libraries: Cryptodome-3.19.0, brotli-1.1.0, certifi-2023.07.22, mutagen-1.47.0, requests-2.31.0, sqlite3-3.43.2, urllib3-2.1.0, websockets-12.0
[debug] Proxy map: {}
[debug] Request Handlers: urllib, requests, websockets
[debug] Loaded 1886 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2023.12.30 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2023.12.30 from yt-dlp/yt-dlp)
[ARDMediathek] Extracting URL: https://www.ardmediathek.de/video/oderbruch/folge-3-blindgaenger-s01-e03/das-erste/Y3JpZDovL2Rhc2Vyc3RlLmRlL29kZXJicnVjaC8yMDI0LTAxLTIwXzAwLTAwLU1FWg
[ARDMediathek] Y3JpZDovL2Rhc2Vyc3RlLmRlL29kZXJicnVjaC8yMDI0LTAxLTIwXzAwLTAwLU1FWg: Downloading JSON metadata
[debug] Using fake IP 53.146.162.25 (DE) as X-Forwarded-For
ERROR: [ARDMediathek] Y3JpZDovL2Rhc2Vyc3RlLmRlL29kZXJicnVjaC8yMDI0LTAxLTIwXzAwLTAwLU1FWg: This video is only available after 22:00
  File "/projects/yt-dlp/yt_dlp/extractor/common.py", line 718, in extract
    ie_result = self._real_extract(url)
  File "/projects/yt-dlp/yt_dlp/extractor/ard.py", line 386, in _real_extract
    self.raise_no_formats('This video is only available after 22:00', expected=True)
  File "/projects/yt-dlp/yt_dlp/extractor/common.py", line 1196, in raise_no_formats
    raise ExtractorError(msg, expected=expected, video_id=video_id)
```


Pull request description:
[ie/ARDBetaMediathekIE] Add age verification
**IMPORTANT**: PRs without the template will be CLOSED

### Description of your *pull request* and other information

<!--

Explanation of your *pull request* in arbitrary form goes here. Please **make sure the description explains the purpose and effect** of your *pull request* and is worded well enough to be understood. Provide as much **context and examples** as possible

-->

See writeup in the issue. We can get a token using browser cookies to authenticate and this enables downloading videos with fsk protection before 22:00

Fixes #9035


<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 how 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
- [x] Checked the code with [flake8](https://pypi.python.org/pypi/flake8) and [ran relevant tests](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#developer-instructions)

### 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 all of the following options that apply:
- [x] I am the original author of this code and I am willing to release it under [Unlicense](http://unlicense.org/)
- [ ] I am not the original author of this code but it is in public domain or released under [Unlicense](http://unlicense.org/) (provide reliable evidence)

### What is the purpose of your *pull request*?
- [x] Fix or improvement to an extractor (Make sure to add/update tests)
- [ ] New extractor ([Piracy websites will not be accepted](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy))
- [ ] Core bug fix/improvement
- [ ] New feature (It is strongly [recommended to open an issue first](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#adding-new-feature-or-making-overarching-changes))

</details>


Patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 3d5c3eb60..a273ae0d9 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -1111,6 +1111,7 @@
     MotherlessIE,
     MotherlessGroupIE,
     MotherlessGalleryIE,
+    MotherlessUploaderIE,
 )
 from .motorsport import MotorsportIE
 from .moviepilot import MoviepilotIE
@@ -1137,6 +1138,11 @@
     MusicdexArtistIE,
     MusicdexPlaylistIE,
 )
+from .mx3 import (
+    Mx3IE,
+    Mx3NeoIE,
+    Mx3VolksmusikIE,
+)
 from .mxplayer import (
     MxplayerIE,
     MxplayerShowIE,
@@ -1593,6 +1599,7 @@
     RedBullIE,
 )
 from .reddit import RedditIE
+from .redge import RedCDNLivxIE
 from .redgifs import (
     RedGifsIE,
     RedGifsSearchIE,
@@ -1727,6 +1734,7 @@
 )
 from .scrolller import ScrolllerIE
 from .seeker import SeekerIE
+from .sejmpl import SejmIE
 from .senalcolombia import SenalColombiaLiveIE
 from .senategov import SenateISVPIE, SenateGovIE
 from .sendtonews import SendtoNewsIE
diff --git a/yt_dlp/extractor/ard.py b/yt_dlp/extractor/ard.py
index f4b1cd075..46e68d61e 100644
--- a/yt_dlp/extractor/ard.py
+++ b/yt_dlp/extractor/ard.py
@@ -8,6 +8,7 @@
     determine_ext,
     int_or_none,
     join_nonempty,
+    jwt_decode_hs256,
     make_archive_id,
     parse_duration,
     parse_iso8601,
@@ -238,6 +239,7 @@ class ARDBetaMediathekIE(InfoExtractor):
         (?P<id>[a-zA-Z0-9]+)
         /?(?:[?#]|$)'''
     _GEO_COUNTRIES = ['DE']
+    _TOKEN_URL = 'https://sso.ardmediathek.de/sso/token'
 
     _TESTS = [{
         'url': 'https://www.ardmediathek.de/video/filme-im-mdr/liebe-auf-vier-pfoten/mdr-fernsehen/Y3JpZDovL21kci5kZS9zZW5kdW5nLzI4MjA0MC80MjIwOTEtNDAyNTM0',
@@ -359,12 +361,27 @@ def _extract_episode_info(self, title):
 
     def _real_extract(self, url):
         display_id = self._match_id(url)
+        query = {'embedded': 'false', 'mcV6': 'true'}
+        headers = {}
+
+        if self._get_cookies(self._TOKEN_URL).get('ams'):
+            token = self._download_json(
+                self._TOKEN_URL, display_id, 'Fetching token for age verification',
+                'Unable to fetch age verification token', fatal=False)
+            id_token = traverse_obj(token, ('idToken', {str}))
+            decoded_token = traverse_obj(id_token, ({jwt_decode_hs256}, {dict}))
+            user_id = traverse_obj(decoded_token, (('user_id', 'sub'), {str}), get_all=False)
+            if not user_id:
+                self.report_warning('Unable to extract token, continuing without authentication')
+            else:
+                headers['x-authorization'] = f'Bearer {id_token}'
+                query['userId'] = user_id
+                if decoded_token.get('age_rating') != 18:
+                    self.report_warning('Account is not verified as 18+; video may be unavailable')
 
         page_data = self._download_json(
-            f'https://api.ardmediathek.de/page-gateway/pages/ard/item/{display_id}', display_id, query={
-                'embedded': 'false',
-                'mcV6': 'true',
-            })
+            f'https://api.ardmediathek.de/page-gateway/pages/ard/item/{display_id}',
+            display_id, query=query, headers=headers)
 
         # For user convenience we use the old contentId instead of the longer crid
         # Ref: https://github.com/yt-dlp/yt-dlp/issues/8731#issuecomment-1874398283
@@ -383,7 +400,7 @@ def _real_extract(self, url):
         media_data = traverse_obj(player_data, ('mediaCollection', 'embedded', {dict}))
 
         if player_data.get('blockedByFsk'):
-            self.raise_no_formats('This video is only available after 22:00', expected=True)
+            self.raise_login_required('This video is only available for age verified users or after 22:00')
 
         formats = []
         subtitles = {}
diff --git a/yt_dlp/extractor/motherless.py b/yt_dlp/extractor/motherless.py
index e359c44e9..160150a7b 100644
--- a/yt_dlp/extractor/motherless.py
+++ b/yt_dlp/extractor/motherless.py
@@ -177,6 +177,7 @@ def _real_extract(self, url):
 
 
 class MotherlessPaginatedIE(InfoExtractor):
+    _EXTRA_QUERY = {}
     _PAGE_SIZE = 60
 
     def _correct_path(self, url, item_id):
@@ -199,7 +200,7 @@ def _real_extract(self, url):
         def get_page(idx):
             page = idx + 1
             current_page = webpage if not idx else self._download_webpage(
-                real_url, item_id, note=f'Downloading page {page}', query={'page': page})
+                real_url, item_id, note=f'Downloading page {page}', query={'page': page, **self._EXTRA_QUERY})
             yield from self._extract_entries(current_page, real_url)
 
         return self.playlist_result(
@@ -213,7 +214,7 @@ class MotherlessGroupIE(MotherlessPaginatedIE):
         'url': 'http://motherless.com/gv/movie_scenes',
         'info_dict': {
             'id': 'movie_scenes',
-            'title': 'Movie Scenes',
+            'title': 'Movie Scenes - Videos - Hot and sexy scenes from "regular" movies... Beautiful actresses fully',
         },
         'playlist_mincount': 540,
     }, {
@@ -244,7 +245,7 @@ class MotherlessGalleryIE(MotherlessPaginatedIE):
             'id': '338999F',
             'title': 'Random',
         },
-        'playlist_mincount': 190,
+        'playlist_mincount': 171,
     }, {
         'url': 'https://motherless.com/GVABD6213',
         'info_dict': {
@@ -270,3 +271,27 @@ class MotherlessGalleryIE(MotherlessPaginatedIE):
 
     def _correct_path(self, url, item_id):
         return urllib.parse.urljoin(url, f'/GV{item_id}')
+
+
+class MotherlessUploaderIE(MotherlessPaginatedIE):
+    _VALID_URL = r'https?://(?:www\.)?motherless\.com/u/(?P<id>\w+)/?(?:$|[?#])'
+    _TESTS = [{
+        'url': 'https://motherless.com/u/Mrgo4hrs2023',
+        'info_dict': {
+            'id': 'Mrgo4hrs2023',
+            'title': "Mrgo4hrs2023's Uploads - Videos",
+        },
+        'playlist_mincount': 32,
+    }, {
+        'url': 'https://motherless.com/u/Happy_couple?t=v',
+        'info_dict': {
+            'id': 'Happy_couple',
+            'title': "Happy_couple's Uploads - Videos",
+        },
+        'playlist_mincount': 8,
+    }]
+
+    _EXTRA_QUERY = {'t': 'v'}
+
+    def _correct_path(self, url, item_id):
+        return urllib.parse.urljoin(url, f'/u/{item_id}?t=v')
diff --git a/yt_dlp/extractor/mx3.py b/yt_dlp/extractor/mx3.py
new file mode 100644
index 000000000..cb9f50e0c
--- /dev/null
+++ b/yt_dlp/extractor/mx3.py
@@ -0,0 +1,171 @@
+import re
+
+from .common import InfoExtractor
+from ..networking import HEADRequest
+from ..utils import (
+    get_element_by_class,
+    int_or_none,
+    try_call,
+    url_or_none,
+    urlhandle_detect_ext,
+)
+from ..utils.traversal import traverse_obj
+
+
+class Mx3BaseIE(InfoExtractor):
+    _VALID_URL_TMPL = r'https?://(?:www\.)?%s/t/(?P<id>\w+)'
+    _FORMATS = [{
+        'url': 'player_asset',
+        'format_id': 'default',
+        'quality': 0,
+    }, {
+        'url': 'player_asset?quality=hd',
+        'format_id': 'hd',
+        'quality': 1,
+    }, {
+        'url': 'download',
+        'format_id': 'download',
+        'quality': 2,
+    }, {
+        'url': 'player_asset?quality=source',
+        'format_id': 'source',
+        'quality': 2,
+    }]
+
+    def _extract_formats(self, track_id):
+        formats = []
+        for fmt in self._FORMATS:
+            format_url = f'https://{self._DOMAIN}/tracks/{track_id}/{fmt["url"]}'
+            urlh = self._request_webpage(
+                HEADRequest(format_url), track_id, fatal=False, expected_status=404,
+                note=f'Checking for format {fmt["format_id"]}')
+            if urlh and urlh.status == 200:
+                formats.append({
+                    **fmt,
+                    'url': format_url,
+                    'ext': urlhandle_detect_ext(urlh),
+                    'filesize': int_or_none(urlh.headers.get('Content-Length')),
+                })
+        return formats
+
+    def _real_extract(self, url):
+        track_id = self._match_id(url)
+        webpage = self._download_webpage(url, track_id)
+        more_info = get_element_by_class('single-more-info', webpage)
+        data = self._download_json(f'https://{self._DOMAIN}/t/{track_id}.json', track_id, fatal=False)
+
+        def get_info_field(name):
+            return self._html_search_regex(
+                rf'<dt[^>]*>\s*{name}\s*</dt>\s*<dd[^>]*>(.*?)</dd>',
+                more_info, name, default=None, flags=re.DOTALL)
+
+        return {
+            'id': track_id,
+            'formats': self._extract_formats(track_id),
+            'genre': self._html_search_regex(
+                r'<div\b[^>]+class="single-band-genre"[^>]*>([^<]+)</div>', webpage, 'genre', default=None),
+            'release_year': int_or_none(get_info_field('Year of creation')),
+            'description': get_info_field('Description'),
+            'tags': try_call(lambda: get_info_field('Tag').split(', '), list),
+            **traverse_obj(data, {
+                'title': ('title', {str}),
+                'artist': (('performer_name', 'artist'), {str}),
+                'album_artist': ('artist', {str}),
+                'composer': ('composer_name', {str}),
+                'thumbnail': (('picture_url_xlarge', 'picture_url'), {url_or_none}),
+            }, get_all=False),
+        }
+
+
+class Mx3IE(Mx3BaseIE):
+    _DOMAIN = 'mx3.ch'
+    _VALID_URL = Mx3BaseIE._VALID_URL_TMPL % re.escape(_DOMAIN)
+    _TESTS = [{
+        'url': 'https://mx3.ch/t/1Cru',
+        'md5': '7ba09e9826b4447d4e1ce9d69e0e295f',
+        'info_dict': {
+            'id': '1Cru',
+            'ext': 'wav',
+            'artist': 'Godina',
+            'album_artist': 'Tortue Tortue',
+            'composer': 'Olivier Godinat',
+            'genre': 'Rock',
+            'thumbnail': 'https://mx3.ch/pictures/mx3/file/0101/4643/square_xlarge/1-s-envoler-1.jpg?1630272813',
+            'title': "S'envoler",
+            'release_year': 2021,
+            'tags': [],
+        }
+    }, {
+        'url': 'https://mx3.ch/t/1LIY',
+        'md5': '48293cb908342547827f963a5a2e9118',
+        'info_dict': {
+            'id': '1LIY',
+            'ext': 'mov',
+            'artist': 'Tania Kimfumu',
+            'album_artist': 'The Broots',
+            'composer': 'Emmanuel Diserens',
+            'genre': 'Electro',
+            'thumbnail': 'https://mx3.ch/pictures/mx3/file/0110/0003/video_xlarge/frame_0000.png?1686963670',
+            'title': 'The Broots-Larytta remix "Begging For Help"',
+            'release_year': 2023,
+            'tags': ['the broots', 'cassata records', 'larytta'],
+            'description': '"Begging for Help" Larytta Remix Official Video\nRealized By Kali Donkilie in 2023',
+        }
+    }, {
+        'url': 'https://mx3.ch/t/1C6E',
+        'md5': '1afcd578493ddb8e5008e94bb6d97e25',
+        'info_dict': {
+            'id': '1C6E',
+            'ext': 'wav',
+            'artist': 'Alien Bubblegum',
+            'album_artist': 'Alien Bubblegum',
+            'composer': 'Alien Bubblegum',
+            'genre': 'Punk',
+            'thumbnail': 'https://mx3.ch/pictures/mx3/file/0101/1551/square_xlarge/pandora-s-box-cover-with-title.png?1627054733',
+            'title': 'Wide Awake',
+            'release_year': 2021,
+            'tags': ['alien bubblegum', 'bubblegum', 'alien', 'pop punk', 'poppunk'],
+        }
+    }]
+
+
+class Mx3NeoIE(Mx3BaseIE):
+    _DOMAIN = 'neo.mx3.ch'
+    _VALID_URL = Mx3BaseIE._VALID_URL_TMPL % re.escape(_DOMAIN)
+    _TESTS = [{
+        'url': 'https://neo.mx3.ch/t/1hpd',
+        'md5': '6d9986bbae5cac3296ec8813bf965eb2',
+        'info_dict': {
+            'id': '1hpd',
+            'ext': 'wav',
+            'artist': 'Baptiste Lopez',
+            'album_artist': 'Kammerorchester Basel',
+            'composer': 'Jannik Giger',
+            'genre': 'Composition, Orchestra',
+            'title': 'Troisième œil. Für Kammerorchester (2023)',
+            'thumbnail': 'https://neo.mx3.ch/pictures/neo/file/0000/0241/square_xlarge/kammerorchester-basel-group-photo-2_c_-lukasz-rajchert.jpg?1560341252',
+            'release_year': 2023,
+            'tags': [],
+        }
+    }]
+
+
+class Mx3VolksmusikIE(Mx3BaseIE):
+    _DOMAIN = 'volksmusik.mx3.ch'
+    _VALID_URL = Mx3BaseIE._VALID_URL_TMPL % re.escape(_DOMAIN)
+    _TESTS = [{
+        'url': 'https://volksmusik.mx3.ch/t/Zx',
+        'md5': 'dd967a7b0c1ef898f3e072cf9c2eae3c',
+        'info_dict': {
+            'id': 'Zx',
+            'ext': 'mp3',
+            'artist': 'Ländlerkapelle GrischArt',
+            'album_artist': 'Ländlerkapelle GrischArt',
+            'composer': 'Urs Glauser',
+            'genre': 'Instrumental, Graubünden',
+            'title': 'Chämilouf',
+            'thumbnail': 'https://volksmusik.mx3.ch/pictures/vxm/file/0000/3815/square_xlarge/grischart1.jpg?1450530120',
+            'release_year': 2012,
+            'tags': [],
+        }
+    }]
diff --git a/yt_dlp/extractor/newgrounds.py b/yt_dlp/extractor/newgrounds.py
index 9e3286dfe..9601cd10e 100644
--- a/yt_dlp/extractor/newgrounds.py
+++ b/yt_dlp/extractor/newgrounds.py
@@ -3,15 +3,15 @@
 
 from .common import InfoExtractor
 from ..utils import (
+    OnDemandPagedList,
     clean_html,
     extract_attributes,
     get_element_by_id,
     int_or_none,
     parse_count,
     parse_duration,
+    traverse_obj,
     unified_timestamp,
-    OnDemandPagedList,
-    try_get,
 )
 
 
@@ -263,19 +263,16 @@ class NewgroundsUserIE(InfoExtractor):
     def _fetch_page(self, channel_id, url, page):
         page += 1
         posts_info = self._download_json(
-            f'{url}/page/{page}', channel_id,
+            f'{url}?page={page}', channel_id,
             note=f'Downloading page {page}', headers={
                 'Accept': 'application/json, text/javascript, */*; q = 0.01',
                 'X-Requested-With': 'XMLHttpRequest',
             })
-        sequence = posts_info.get('sequence', [])
-        for year in sequence:
-            posts = try_get(posts_info, lambda x: x['years'][str(year)]['items'])
-            for post in posts:
-                path, media_id = self._search_regex(
-                    r'<a[^>]+\bhref=["\'][^"\']+((?:portal/view|audio/listen)/(\d+))[^>]+>',
-                    post, 'url', group=(1, 2))
-                yield self.url_result(f'https://www.newgrounds.com/{path}', NewgroundsIE.ie_key(), media_id)
+        for post in traverse_obj(posts_info, ('items', ..., ..., {str})):
+            path, media_id = self._search_regex(
+                r'<a[^>]+\bhref=["\'][^"\']+((?:portal/view|audio/listen)/(\d+))[^>]+>',
+                post, 'url', group=(1, 2))
+            yield self.url_result(f'https://www.newgrounds.com/{path}', NewgroundsIE.ie_key(), media_id)
 
     def _real_extract(self, url):
         channel_id = self._match_id(url)
diff --git a/yt_dlp/extractor/redge.py b/yt_dlp/extractor/redge.py
new file mode 100644
index 000000000..875d6f8aa
--- /dev/null
+++ b/yt_dlp/extractor/redge.py
@@ -0,0 +1,135 @@
+import functools
+
+from .common import InfoExtractor
+from ..networking import HEADRequest
+from ..utils import (
+    float_or_none,
+    int_or_none,
+    join_nonempty,
+    parse_qs,
+    update_url_query,
+)
+from ..utils.traversal import traverse_obj
+
+
+class RedCDNLivxIE(InfoExtractor):
+    _VALID_URL = r'https?://[^.]+\.(?:dcs\.redcdn|atmcdn)\.pl/(?:live(?:dash|hls|ss)|nvr)/o2/(?P<tenant>[^/?#]+)/(?P<id>[^?#]+)\.livx'
+    IE_NAME = 'redcdnlivx'
+
+    _TESTS = [{
+        'url': 'https://r.dcs.redcdn.pl/livedash/o2/senat/ENC02/channel.livx?indexMode=true&startTime=638272860000&stopTime=638292544000',
+        'info_dict': {
+            'id': 'ENC02-638272860000-638292544000',
+            'ext': 'mp4',
+            'title': 'ENC02',
+            'duration': 19683.982,
+            'live_status': 'was_live',
+        },
+    }, {
+        'url': 'https://r.dcs.redcdn.pl/livedash/o2/sejm/ENC18/live.livx?indexMode=true&startTime=722333096000&stopTime=722335562000',
+        'info_dict': {
+            'id': 'ENC18-722333096000-722335562000',
+            'ext': 'mp4',
+            'title': 'ENC18',
+            'duration': 2463.995,
+            'live_status': 'was_live',
+        },
+    }, {
+        'url': 'https://r.dcs.redcdn.pl/livehls/o2/sportevolution/live/triathlon2018/warsaw.livx/playlist.m3u8?startTime=550305000000&stopTime=550327620000',
+        'info_dict': {
+            'id': 'triathlon2018-warsaw-550305000000-550327620000',
+            'ext': 'mp4',
+            'title': 'triathlon2018/warsaw',
+            'duration': 22619.98,
+            'live_status': 'was_live',
+        },
+    }, {
+        'url': 'https://n-25-12.dcs.redcdn.pl/nvr/o2/sejm/Migacz-ENC01/1.livx?startTime=722347200000&stopTime=722367345000',
+        'only_matching': True,
+    }, {
+        'url': 'https://redir.atmcdn.pl/nvr/o2/sejm/ENC08/1.livx?startTime=503831270000&stopTime=503840040000',
+        'only_matching': True,
+    }]
+
+    """
+    Known methods (first in url path):
+    - `livedash` - DASH MPD
+    - `livehls` - HTTP Live Streaming
+    - `livess` - IIS Smooth Streaming
+    - `nvr` - CCTV mode, directly returns a file, typically flv, avc1, aac
+    - `sc` - shoutcast/icecast (audio streams, like radio)
+    """
+
+    def _real_extract(self, url):
+        tenant, path = self._match_valid_url(url).group('tenant', 'id')
+        qs = parse_qs(url)
+        start_time = traverse_obj(qs, ('startTime', 0, {int_or_none}))
+        stop_time = traverse_obj(qs, ('stopTime', 0, {int_or_none}))
+
+        def livx_mode(mode):
+            suffix = ''
+            if mode == 'livess':
+                suffix = '/manifest'
+            elif mode == 'livehls':
+                suffix = '/playlist.m3u8'
+            file_qs = {}
+            if start_time:
+                file_qs['startTime'] = start_time
+            if stop_time:
+                file_qs['stopTime'] = stop_time
+            if mode == 'nvr':
+                file_qs['nolimit'] = 1
+            elif mode != 'sc':
+                file_qs['indexMode'] = 'true'
+            return update_url_query(f'https://r.dcs.redcdn.pl/{mode}/o2/{tenant}/{path}.livx{suffix}', file_qs)
+
+        # no id or title for a transmission. making ones up.
+        title = path \
+            .replace('/live', '').replace('live/', '') \
+            .replace('/channel', '').replace('channel/', '') \
+            .strip('/')
+        video_id = join_nonempty(title.replace('/', '-'), start_time, stop_time)
+
+        formats = []
+        # downloading the manifest separately here instead of _extract_ism_formats to also get some stream metadata
+        ism_res = self._download_xml_handle(
+            livx_mode('livess'), video_id,
+            note='Downloading ISM manifest',
+            errnote='Failed to download ISM manifest',
+            fatal=False)
+        ism_doc = None
+        if ism_res is not False:
+            ism_doc, ism_urlh = ism_res
+            formats, _ = self._parse_ism_formats_and_subtitles(ism_doc, ism_urlh.url, 'ss')
+
+        nvr_urlh = self._request_webpage(
+            HEADRequest(livx_mode('nvr')), video_id, 'Follow flv file redirect', fatal=False,
+            expected_status=lambda _: True)
+        if nvr_urlh and nvr_urlh.status == 200:
+            formats.append({
+                'url': nvr_urlh.url,
+                'ext': 'flv',
+                'format_id': 'direct-0',
+                'preference': -1,   # might be slow
+            })
+        formats.extend(self._extract_mpd_formats(livx_mode('livedash'), video_id, mpd_id='dash', fatal=False))
+        formats.extend(self._extract_m3u8_formats(
+            livx_mode('livehls'), video_id, m3u8_id='hls', ext='mp4', fatal=False))
+
+        time_scale = traverse_obj(ism_doc, ('@TimeScale', {int_or_none})) or 10000000
+        duration = traverse_obj(
+            ism_doc, ('@Duration', {functools.partial(float_or_none, scale=time_scale)})) or None
+
+        live_status = None
+        if traverse_obj(ism_doc, '@IsLive') == 'TRUE':
+            live_status = 'is_live'
+        elif duration:
+            live_status = 'was_live'
+
+        return {
+            'id': video_id,
+            'title': title,
+            'formats': formats,
+            'duration': duration,
+            'live_status': live_status,
+        }
diff --git a/yt_dlp/extractor/rule34video.py b/yt_dlp/extractor/rule34video.py
index e6bb4258e..85ad7e2ff 100644
--- a/yt_dlp/extractor/rule34video.py
+++ b/yt_dlp/extractor/rule34video.py
@@ -18,10 +18,10 @@
 
 
 class Rule34VideoIE(InfoExtractor):
-    _VALID_URL = r'https?://(?:www\.)?rule34video\.com/videos/(?P<id>\d+)'
+    _VALID_URL = r'https?://(?:www\.)?rule34video\.com/videos?/(?P<id>\d+)'
     _TESTS = [
         {
-            'url': 'https://rule34video.com/videos/3065157/shot-it-mmd-hmv/',
+            'url': 'https://rule34video.com/video/3065157/shot-it-mmd-hmv/',
             'md5': 'ffccac2c23799dabbd192621ae4d04f3',
             'info_dict': {
                 'id': '3065157',
diff --git a/yt_dlp/extractor/sejmpl.py b/yt_dlp/extractor/sejmpl.py
new file mode 100644
index 000000000..29cb0152a
--- /dev/null
+++ b/yt_dlp/extractor/sejmpl.py
@@ -0,0 +1,218 @@
+import datetime
+
+from .common import InfoExtractor
+from .redge import RedCDNLivxIE
+from ..utils import (
+    clean_html,
+    join_nonempty,
+    js_to_json,
+    strip_or_none,
+    update_url_query,
+)
+from ..utils.traversal import traverse_obj
+
+
+def is_dst(date):
+    last_march = datetime.datetime(date.year, 3, 31)
+    last_october = datetime.datetime(date.year, 10, 31)
+    last_sunday_march = last_march - datetime.timedelta(days=last_march.isoweekday() % 7)
+    last_sunday_october = last_october - datetime.timedelta(days=last_october.isoweekday() % 7)
+    return last_sunday_march.replace(hour=2) <= date <= last_sunday_october.replace(hour=3)
+
+
+def rfc3339_to_atende(date):
+    date = datetime.datetime.fromisoformat(date)
+    date = date + datetime.timedelta(hours=1 if is_dst(date) else 0)
+    return int((date.timestamp() - 978307200) * 1000)
+
+
+class SejmIE(InfoExtractor):
+    _VALID_URL = (
+        r'https?://(?:www\.)?sejm\.gov\.pl/[Ss]ejm(?P<term>\d+)\.nsf/transmisje(?:_arch)?\.xsp(?:\?[^#]*)?#(?P<id>[\dA-F]+)',
+        r'https?://(?:www\.)?sejm\.gov\.pl/[Ss]ejm(?P<term>\d+)\.nsf/transmisje(?:_arch)?\.xsp\?(?:[^#]+&)?unid=(?P<id>[\dA-F]+)',
+        r'https?://sejm-embed\.redcdn\.pl/[Ss]ejm(?P<term>\d+)\.nsf/VideoFrame\.xsp/(?P<id>[\dA-F]+)',
+    )
+    IE_NAME = 'sejm'
+
+    _TESTS = [{
+        # multiple cameras, polish SL iterpreter
+        'url': 'https://www.sejm.gov.pl/Sejm10.nsf/transmisje_arch.xsp#6181EF1AD9CEEBB5C1258A6D006452B5',
+        'info_dict': {
+            'id': '6181EF1AD9CEEBB5C1258A6D006452B5',
+            'title': '1. posiedzenie Sejmu X kadencji',
+            'duration': 20145,
+            'live_status': 'was_live',
+            'location': 'Sala Posiedzeń',
+        },
+        'playlist': [{
+            'info_dict': {
+                'id': 'ENC01-722340000000-722360145000',
+                'ext': 'mp4',
+                'duration': 20145,
+                'title': '1. posiedzenie Sejmu X kadencji - ENC01',
+                'live_status': 'was_live',
+            },
+        }, {
+            'info_dict': {
+                'id': 'ENC30-722340000000-722360145000',
+                'ext': 'mp4',
+                'duration': 20145,
+                'title': '1. posiedzenie Sejmu X kadencji - ENC30',
+                'live_status': 'was_live',
+            },
+        }, {
+            'info_dict': {
+                'id': 'ENC31-722340000000-722360145000',
+                'ext': 'mp4',
+                'duration': 20145,
+                'title': '1. posiedzenie Sejmu X kadencji - ENC31',
+                'live_status': 'was_live',
+            },
+        }, {
+            'info_dict': {
+                'id': 'ENC32-722340000000-722360145000',
+                'ext': 'mp4',
+                'duration': 20145,
+                'title': '1. posiedzenie Sejmu X kadencji - ENC32',
+                'live_status': 'was_live',
+            },
+        }, {
+            # sign lang interpreter
+            'info_dict': {
+                'id': 'Migacz-ENC01-1-722340000000-722360145000',
+                'ext': 'mp4',
+                'duration': 20145,
+                'title': '1. posiedzenie Sejmu X kadencji - Migacz-ENC01',
+                'live_status': 'was_live',
+            },
+        }],
+    }, {
+        'url': 'https://www.sejm.gov.pl/Sejm8.nsf/transmisje.xsp?unid=9377A9D65518E9A5C125808E002E9FF2',
+        'info_dict': {
+            'id': '9377A9D65518E9A5C125808E002E9FF2',
+            'title': 'Debata "Lepsza Polska: obywatelska"',
+            'description': 'KP .Nowoczesna',
+            'duration': 8770,
+            'live_status': 'was_live',
+            'location': 'sala kolumnowa im. Kazimierza Pużaka (bud. C-D)',
+        },
+        'playlist': [{
+            'info_dict': {
+                'id': 'ENC08-1-503831270000-503840040000',
+                'ext': 'mp4',
+                'duration': 8770,
+                'title': 'Debata "Lepsza Polska: obywatelska" - ENC08',
+                'live_status': 'was_live',
+            },
+        }],
+    }, {
+        # 7th term is very special, since it does not use redcdn livx
+        'url': 'https://www.sejm.gov.pl/sejm7.nsf/transmisje_arch.xsp?rok=2015&month=11#A6E6D475ECCC6FE5C1257EF90034817F',
+        'info_dict': {
+            'id': 'A6E6D475ECCC6FE5C1257EF90034817F',
+            'title': 'Konferencja prasowa - Stanowisko SLD ws. składu nowego rządu',
+            'description': 'SLD - Biuro Prasowe Klubu',
+            'duration': 514,
+            'location': 'sala 101/bud. C',
+            'live_status': 'was_live',
+        },
+        'playlist': [{
+            'info_dict': {
+                'id': 'A6E6D475ECCC6FE5C1257EF90034817F',
+                'ext': 'mp4',
+                'title': 'Konferencja prasowa - Stanowisko SLD ws. składu nowego rządu',
+                'duration': 514,
+            },
+        }],
+    }, {
+        'url': 'https://sejm-embed.redcdn.pl/Sejm10.nsf/VideoFrame.xsp/FED58EABB97FBD53C1258A7400386492',
+        'only_matching': True,
+    }]
+
+    def _real_extract(self, url):
+        term, video_id = self._match_valid_url(url).group('term', 'id')
+        frame = self._download_webpage(
+            f'https://sejm-embed.redcdn.pl/Sejm{term}.nsf/VideoFrame.xsp/{video_id}',
+            video_id)
+        # despite it says "transmisje_arch", it works for live streams too!
+        data = self._download_json(
+            f'https://www.sejm.gov.pl/Sejm{term}.nsf/transmisje_arch.xsp/json/{video_id}',
+            video_id)
+        params = data['params']
+
+        title = strip_or_none(data.get('title'))
+
+        if data.get('status') == 'VIDEO_ENDED':
+            live_status = 'was_live'
+        elif data.get('status') == 'VIDEO_PLAYING':
+            live_status = 'is_live'
+        else:
+            live_status = None
+            self.report_warning(f'unknown status: {data.get("status")}')
+
+        start_time = rfc3339_to_atende(params['start'])
+        # current streams have a stop time of *expected* end of session, but actual times
+        # can change during the transmission. setting a stop_time would artificially
+        # end the stream at that time, while the session actually keeps going.
+        if live_status == 'was_live':
+            stop_time = rfc3339_to_atende(params['stop'])
+            duration = (stop_time - start_time) // 1000
+        else:
+            stop_time, duration = None, None
+
+        entries = []
+
+        def add_entry(file, legacy_file=False):
+            if not file:
+                return
+            file = self._proto_relative_url(file)
+            if not legacy_file:
+                file = update_url_query(file, {'startTime': start_time})
+                if stop_time is not None:
+                    file = update_url_query(file, {'stopTime': stop_time})
+                stream_id = self._search_regex(r'/o2/sejm/([^/]+)/[^./]+\.livx', file, 'stream id')
+            common_info = {
+                'url': file,
+                'duration': duration,
+            }
+            if legacy_file:
+                entries.append({
+                    **common_info,
+                    'id': video_id,
+                    'title': title,
+                })
+            else:
+                entries.append({
+                    **common_info,
+                    '_type': 'url_transparent',
+                    'ie_key': RedCDNLivxIE.ie_key(),
+                    'id': stream_id,
+                    'title': join_nonempty(title, stream_id, delim=' - '),
+                })
+
+        cameras = self._search_json(
+            r'var\s+cameras\s*=', frame, 'camera list', video_id,
+            contains_pattern=r'\[(?s:.+)\]', transform_source=js_to_json,
+            fatal=False) or []
+        for camera_file in traverse_obj(cameras, (..., 'file', {dict})):
+            if camera_file.get('flv'):
+                add_entry(camera_file['flv'])
+            elif camera_file.get('mp4'):
+                # this is only a thing in 7th term. no streams before, and starting 8th it's redcdn livx
+                add_entry(camera_file['mp4'], legacy_file=True)
+            else:
+                self.report_warning('Unknown camera stream type found')
+
+        if params.get('mig'):
+            add_entry(self._search_regex(r"var sliUrl\s*=\s*'([^']+)'", frame, 'sign language interpreter url', fatal=False))
+
+        return {
+            '_type': 'playlist',
+            'entries': entries,
+            'id': video_id,
+            'title': title,
+            'description': clean_html(data.get('desc')) or None,
+            'duration': duration,
+            'live_status': live_status,
+            'location': strip_or_none(data.get('location')),
+        }
diff --git a/yt_dlp/networking/_requests.py b/yt_dlp/networking/_requests.py
index e129110ca..00e4bdb49 100644
--- a/yt_dlp/networking/_requests.py
+++ b/yt_dlp/networking/_requests.py
@@ -8,6 +8,7 @@
 
 from ..dependencies import brotli, requests, urllib3
 from ..utils import bug_reports_message, int_or_none, variadic
+from ..utils.networking import normalize_url
 
 if requests is None:
     raise ImportError('requests module is not installed')
@@ -199,6 +200,10 @@ def rebuild_method(self, prepared_request, response):
 
         prepared_request.method = new_method
 
+        # Requests fails to resolve dot segments on absolute redirect locations
+        # See: https://github.com/yt-dlp/yt-dlp/issues/9020
+        prepared_request.url = normalize_url(prepared_request.url)
+
     def rebuild_auth(self, prepared_request, response):
         # HACK: undo status code change from rebuild_method, if applicable.
         # rebuild_auth runs after requests would remove headers/body based on status code


Test patch:
diff --git a/test/test_networking.py b/test/test_networking.py
index 62325aa8e..8cadd86f5 100644
--- a/test/test_networking.py
+++ b/test/test_networking.py
@@ -180,6 +180,12 @@ def do_GET(self):
             self.send_header('Location', '/a/b/./../../headers')
             self.send_header('Content-Length', '0')
             self.end_headers()
+        elif self.path == '/redirect_dotsegments_absolute':
+            self.send_response(301)
+            # redirect to /headers but with dot segments before - absolute url
+            self.send_header('Location', f'http://127.0.0.1:{http_server_port(self.server)}/a/b/./../../headers')
+            self.send_header('Content-Length', '0')
+            self.end_headers()
         elif self.path.startswith('/redirect_'):
             self._redirect()
         elif self.path.startswith('/method'):
@@ -345,16 +351,17 @@ def test_percent_encode(self, handler):
             res.close()
 
     @pytest.mark.parametrize('handler', ['Urllib', 'Requests'], indirect=True)
-    def test_remove_dot_segments(self, handler):
-        with handler() as rh:
+    @pytest.mark.parametrize('path', [
+        '/a/b/./../../headers',
+        '/redirect_dotsegments',
+        # https://github.com/yt-dlp/yt-dlp/issues/9020
+        '/redirect_dotsegments_absolute',
+    ])
+    def test_remove_dot_segments(self, handler, path):
+        with handler(verbose=True) as rh:
             # This isn't a comprehensive test,
-            # but it should be enough to check whether the handler is removing dot segments
-            res = validate_and_send(rh, Request(f'http://127.0.0.1:{self.http_port}/a/b/./../../headers'))
-            assert res.status == 200
-            assert res.url == f'http://127.0.0.1:{self.http_port}/headers'
-            res.close()
-
-            res = validate_and_send(rh, Request(f'http://127.0.0.1:{self.http_port}/redirect_dotsegments'))
+            # but it should be enough to check whether the handler is removing dot segments in required scenarios
+            res = validate_and_send(rh, Request(f'http://127.0.0.1:{self.http_port}{path}'))
             assert res.status == 200
             assert res.url == f'http://127.0.0.1:{self.http_port}/headers'
             res.close()