Repository: yt-dlp/yt-dlp

Issue:
[niconico:live] Nico Nico Live server migration starting 2025.03
### Checklist

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

### Region

Japan

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

### Nico Nico Live will begin switching its streaming servers starting in February 2025.
https://blog.nicovideo.jp/niconews/235926.html

### Work Schedule

- February 2025: Start switching streaming servers for user live broadcasts
- March 2025: Start switching streaming servers for official and channel broadcasts
- March 2025: Complete the transition for all broadcasts

Due to this change, some users' live streams are no longer downloadable.
Additionally, archives are also no longer downloadable.
It appears that all broadcasts will switch to the new system by March 2025.

Is it possible to adapt to this change?

### Confirmed Changes
The playlist has changed from master.m3u8 to variant.m3u8.
Retrieving the CMAF AES key now requires Cookie: dlive_bid.

### URLs where the new server transition has been confirmed
https://live.nicovideo.jp/watch/lv347034931
https://live.nicovideo.jp/watch/lv347036606
https://live.nicovideo.jp/watch/lv347033014

### Execution Command
```
yt-dlp.exe -P "T:\ytdl" -o "[%%(upload_date)s] [%%(id)s] %%(title)s.%%(ext)s" --cookies-from-browser firefox https://live.nicovideo.jp/watch/lv347036606
```


### Provide verbose output that clearly demonstrates the problem

- [x] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [x] 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', '-P', 'T:\\ytdl', '-o', '[%%(upload_date)s] [%%(id)s] %%(title)s.%%(ext)s', '--cookies-from-browser', 'firefox', 'https://live.nicovideo.jp/watch/lv347036606']
[debug] Encodings: locale cp932, fs utf-8, pref cp932, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2025.01.26 from yt-dlp/yt-dlp [3b4531934] (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 2025-02-13-git-19a2d26177-full_build-www.gyan.dev (setts), ffprobe 2025-02-13-git-19a2d26177-full_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.21.0, brotli-1.1.0, certifi-2024.12.14, curl_cffi-0.5.10, mutagen-1.47.0, requests-2.32.3, sqlite3-3.40.1, urllib3-2.3.0, websockets-14.2
[debug] Proxy map: {}
Extracting cookies from firefox
[debug] Extracting cookies from: "C:\Users\User\AppData\Roaming\Mozilla\Firefox\Profiles\xxxxxxxx.default-release\cookies.sqlite"
Extracted 163 cookies from firefox
[debug] Request Handlers: urllib, requests, websockets, curl_cffi
[debug] Loaded 1839 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: stable@2025.01.26 from yt-dlp/yt-dlp
yt-dlp is up to date (stable@2025.01.26 from yt-dlp/yt-dlp)
[niconico:live] Extracting URL: https://live.nicovideo.jp/watch/lv347036606
[niconico:live] lv347036606: Downloading webpage
[niconico:live] lv347036606: Connecting to WebSocket server
[debug] [niconico:live] [debug] Sending HLS server request
[debug] [niconico:live] Server said: {"type":"serverTime","data":{"currentMs":"2025-02-15T01:32:24.906+09:00"}}
[debug] [niconico:live] Server said: {"type":"seat","data":{"keepIntervalSec":30}}
[niconico:live] lv347036606: Downloading m3u8 information
ERROR: [niconico:live] lv347036606: Failed to download m3u8 information: HTTP Error 403: Forbidden (caused by <HTTPError 403: Forbidden>)
  File "yt_dlp\extractor\common.py", line 742, in extract
  File "yt_dlp\extractor\niconico.py", line 1039, in _real_extract
  File "yt_dlp\extractor\common.py", line 2055, in _extract_m3u8_formats
  File "yt_dlp\extractor\common.py", line 2077, in _extract_m3u8_formats_and_subtitles
  File "yt_dlp\extractor\common.py", line 962, in _download_webpage_handle
  File "yt_dlp\extractor\common.py", line 911, in _request_webpage

  File "yt_dlp\extractor\common.py", line 898, in _request_webpage
  File "yt_dlp\YoutubeDL.py", line 4175, in urlopen
  File "yt_dlp\networking\common.py", line 117, in send
  File "yt_dlp\networking\_helper.py", line 208, in wrapper
  File "yt_dlp\networking\common.py", line 340, in send
  File "yt_dlp\networking\_requests.py", line 365, in _send
yt_dlp.networking.exceptions.HTTPError: HTTP Error 403: Forbidden
```

Hints from issue comments:
@nnn-revo2012: According to the announcement from Niconico, the system will work as follows:

Until March 2025, both the old and new video servers for Niconico Live will coexist (for both real-time streaming and time-shifted playback).
From April 2025, all real-time streams will use the new video server, but time-shifted streams from before the transition will remain on the old video server.
Therefore, I think the program needs to determine whether a Niconico Live stream is using the old or new video server and handle each case accordingly.

I live in a region where Japanese copyright laws apply, so I cannot create a program to decrypt and save AES-encrypted Niconico Live streams (as there is a risk of being sued by Dwango).
**If someone living outside of Japan creates it, there is no issue because Japanese copyright laws do not apply.**

Solution patch:
diff --git a/yt_dlp/downloader/niconico.py b/yt_dlp/downloader/niconico.py
index 462c6e2d6..310144e7d 100644
--- a/yt_dlp/downloader/niconico.py
+++ b/yt_dlp/downloader/niconico.py
@@ -85,6 +85,7 @@ def communicate_ws(reconnect):
                             'quality': live_quality,
                             'protocol': 'hls+fmp4',
                             'latency': live_latency,
+                            'accessRightMethod': 'single_cookie',
                             'chasePlay': False,
                         },
                         'room': {
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index 679a0a6a6..379e64548 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -903,6 +903,7 @@
     IviIE,
 )
 from .ivideon import IvideonIE
+from .ivoox import IvooxIE
 from .iwara import (
     IwaraIE,
     IwaraPlaylistIE,
@@ -960,7 +961,10 @@
 )
 from .kicker import KickerIE
 from .kickstarter import KickStarterIE
-from .kika import KikaIE
+from .kika import (
+    KikaIE,
+    KikaPlaylistIE,
+)
 from .kinja import KinjaEmbedIE
 from .kinopoisk import KinoPoiskIE
 from .kommunetv import KommunetvIE
@@ -1493,6 +1497,10 @@
 )
 from .parler import ParlerIE
 from .parlview import ParlviewIE
+from .parti import (
+    PartiLivestreamIE,
+    PartiVideoIE,
+)
 from .patreon import (
     PatreonCampaignIE,
     PatreonIE,
@@ -1739,6 +1747,7 @@
     RoosterTeethSeriesIE,
 )
 from .rottentomatoes import RottenTomatoesIE
+from .roya import RoyaLiveIE
 from .rozhlas import (
     MujRozhlasIE,
     RozhlasIE,
diff --git a/yt_dlp/extractor/agora.py b/yt_dlp/extractor/agora.py
index 983558425..e040db601 100644
--- a/yt_dlp/extractor/agora.py
+++ b/yt_dlp/extractor/agora.py
@@ -146,7 +146,7 @@ class TokFMPodcastIE(InfoExtractor):
         'url': 'https://audycje.tokfm.pl/podcast/91275,-Systemowy-rasizm-Czy-zamieszki-w-USA-po-morderstwie-w-Minneapolis-doprowadza-do-zmian-w-sluzbach-panstwowych',
         'info_dict': {
             'id': '91275',
-            'ext': 'aac',
+            'ext': 'mp3',
             'title': 'md5:a9b15488009065556900169fb8061cce',
             'episode': 'md5:a9b15488009065556900169fb8061cce',
             'series': 'Analizy',
@@ -164,23 +164,20 @@ def _real_extract(self, url):
             raise ExtractorError('No such podcast', expected=True)
         metadata = metadata[0]
 
-        formats = []
-        for ext in ('aac', 'mp3'):
-            url_data = self._download_json(
-                f'https://api.podcast.radioagora.pl/api4/getSongUrl?podcast_id={media_id}&device_id={uuid.uuid4()}&ppre=false&audio={ext}',
-                media_id, f'Downloading podcast {ext} URL')
-            # prevents inserting the mp3 (default) multiple times
-            if 'link_ssl' in url_data and f'.{ext}' in url_data['link_ssl']:
-                formats.append({
-                    'url': url_data['link_ssl'],
-                    'ext': ext,
-                    'vcodec': 'none',
-                    'acodec': ext,
-                })
+        mp3_url = self._download_json(
+            'https://api.podcast.radioagora.pl/api4/getSongUrl',
+            media_id, 'Downloading podcast mp3 URL', query={
+                'podcast_id': media_id,
+                'device_id': str(uuid.uuid4()),
+                'ppre': 'false',
+                'audio': 'mp3',
+            })['link_ssl']
 
         return {
             'id': media_id,
-            'formats': formats,
+            'url': mp3_url,
+            'vcodec': 'none',
+            'ext': 'mp3',
             'title': metadata.get('podcast_name'),
             'series': metadata.get('series_name'),
             'episode': metadata.get('podcast_name'),
diff --git a/yt_dlp/extractor/common.py b/yt_dlp/extractor/common.py
index 4c1bc4cf4..d5607296d 100644
--- a/yt_dlp/extractor/common.py
+++ b/yt_dlp/extractor/common.py
@@ -1570,6 +1570,8 @@ def _yield_json_ld(self, html, video_id, *, fatal=True, default=NO_DEFAULT):
         """Yield all json ld objects in the html"""
         if default is not NO_DEFAULT:
             fatal = False
+        if not fatal and not isinstance(html, str):
+            return
         for mobj in re.finditer(JSON_LD_RE, html):
             json_ld_item = self._parse_json(
                 mobj.group('json_ld'), video_id, fatal=fatal,
diff --git a/yt_dlp/extractor/crowdbunker.py b/yt_dlp/extractor/crowdbunker.py
index bf814570f..ca0323431 100644
--- a/yt_dlp/extractor/crowdbunker.py
+++ b/yt_dlp/extractor/crowdbunker.py
@@ -5,7 +5,9 @@
     int_or_none,
     try_get,
     unified_strdate,
+    url_or_none,
 )
+from ..utils.traversal import traverse_obj
 
 
 class CrowdBunkerIE(InfoExtractor):
@@ -44,16 +46,15 @@ def _real_extract(self, url):
                 'url': sub_url,
             })
 
-        mpd_url = try_get(video_json, lambda x: x['dashManifest']['url'])
-        if mpd_url:
-            fmts, subs = self._extract_mpd_formats_and_subtitles(mpd_url, video_id)
+        if mpd_url := traverse_obj(video_json, ('dashManifest', 'url', {url_or_none})):
+            fmts, subs = self._extract_mpd_formats_and_subtitles(mpd_url, video_id, mpd_id='dash', fatal=False)
             formats.extend(fmts)
-            subtitles = self._merge_subtitles(subtitles, subs)
-        m3u8_url = try_get(video_json, lambda x: x['hlsManifest']['url'])
-        if m3u8_url:
-            fmts, subs = self._extract_m3u8_formats_and_subtitles(mpd_url, video_id)
+            self._merge_subtitles(subs, target=subtitles)
+
+        if m3u8_url := traverse_obj(video_json, ('hlsManifest', 'url', {url_or_none})):
+            fmts, subs = self._extract_m3u8_formats_and_subtitles(m3u8_url, video_id, m3u8_id='hls', fatal=False)
             formats.extend(fmts)
-            subtitles = self._merge_subtitles(subtitles, subs)
+            self._merge_subtitles(subs, target=subtitles)
 
         thumbnails = [{
             'url': image['url'],
diff --git a/yt_dlp/extractor/ivoox.py b/yt_dlp/extractor/ivoox.py
new file mode 100644
index 000000000..36e02493a
--- /dev/null
+++ b/yt_dlp/extractor/ivoox.py
@@ -0,0 +1,78 @@
+from .common import InfoExtractor
+from ..utils import int_or_none, parse_iso8601, url_or_none, urljoin
+from ..utils.traversal import traverse_obj
+
+
+class IvooxIE(InfoExtractor):
+    _VALID_URL = (
+        r'https?://(?:www\.)?ivoox\.com/(?:\w{2}/)?[^/?#]+_rf_(?P<id>[0-9]+)_1\.html',
+        r'https?://go\.ivoox\.com/rf/(?P<id>[0-9]+)',
+    )
+    _TESTS = [{
+        'url': 'https://www.ivoox.com/dex-08x30-rostros-del-mal-los-asesinos-en-audios-mp3_rf_143594959_1.html',
+        'md5': '993f712de5b7d552459fc66aa3726885',
+        'info_dict': {
+            'id': '143594959',
+            'ext': 'mp3',
+            'timestamp': 1742731200,
+            'channel': 'DIAS EXTRAÑOS con Santiago Camacho',
+            'title': 'DEx 08x30 Rostros del mal: Los asesinos en serie que aterrorizaron España',
+            'description': 'md5:eae8b4b9740d0216d3871390b056bb08',
+            'uploader': 'Santiago Camacho',
+            'thumbnail': 'https://static-1.ivoox.com/audios/c/d/5/2/cd52f46783fe735000c33a803dce2554_XXL.jpg',
+            'upload_date': '20250323',
+            'episode': 'DEx 08x30 Rostros del mal: Los asesinos en serie que aterrorizaron España',
+            'duration': 11837,
+            'tags': ['españa', 'asesinos en serie', 'arropiero', 'historia criminal', 'mataviejas'],
+        },
+    }, {
+        'url': 'https://go.ivoox.com/rf/143594959',
+        'only_matching': True,
+    }, {
+        'url': 'https://www.ivoox.com/en/campodelgas-28-03-2025-audios-mp3_rf_144036942_1.html',
+        'only_matching': True,
+    }]
+
+    def _real_extract(self, url):
+        media_id = self._match_id(url)
+        webpage = self._download_webpage(url, media_id, fatal=False)
+
+        data = self._search_nuxt_data(
+            webpage, media_id, fatal=False, traverse=('data', 0, 'data', 'audio'))
+
+        direct_download = self._download_json(
+            f'https://vcore-web.ivoox.com/v1/public/audios/{media_id}/download-url', media_id, fatal=False,
+            note='Fetching direct download link', headers={'Referer': url})
+
+        download_paths = {
+            *traverse_obj(direct_download, ('data', 'downloadUrl', {str}, filter, all)),
+            *traverse_obj(data, (('downloadUrl', 'mediaUrl'), {str}, filter)),
+        }
+
+        formats = []
+        for path in download_paths:
+            formats.append({
+                'url': urljoin('https://ivoox.com', path),
+                'http_headers': {'Referer': url},
+            })
+
+        return {
+            'id': media_id,
+            'formats': formats,
+            'uploader': self._html_search_regex(r'data-prm-author="([^"]+)"', webpage, 'author', default=None),
+            'timestamp': parse_iso8601(
+                self._html_search_regex(r'data-prm-pubdate="([^"]+)"', webpage, 'timestamp', default=None)),
+            'channel': self._html_search_regex(r'data-prm-podname="([^"]+)"', webpage, 'channel', default=None),
+            'title': self._html_search_regex(r'data-prm-title="([^"]+)"', webpage, 'title', default=None),
+            'thumbnail': self._og_search_thumbnail(webpage, default=None),
+            'description': self._og_search_description(webpage, default=None),
+            **self._search_json_ld(webpage, media_id, default={}),
+            **traverse_obj(data, {
+                'title': ('title', {str}),
+                'description': ('description', {str}),
+                'thumbnail': ('image', {url_or_none}),
+                'timestamp': ('uploadDate', {parse_iso8601(delimiter=' ')}),
+                'duration': ('duration', {int_or_none}),
+                'tags': ('tags', ..., 'name', {str}),
+            }),
+        }
diff --git a/yt_dlp/extractor/kika.py b/yt_dlp/extractor/kika.py
index 69f4a3ce0..e27756452 100644
--- a/yt_dlp/extractor/kika.py
+++ b/yt_dlp/extractor/kika.py
@@ -1,3 +1,5 @@
+import itertools
+
 from .common import InfoExtractor
 from ..utils import (
     determine_ext,
@@ -124,3 +126,43 @@ def _extract_formats(self, media_info, video_id):
                         'vbr': ('bitrateVideo', {int_or_none}, {lambda x: None if x == -1 else x}),
                     }),
                 }
+
+
+class KikaPlaylistIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:www\.)?kika\.de/[\w-]+/(?P<id>[a-z-]+\d+)'
+
+    _TESTS = [{
+        'url': 'https://www.kika.de/logo/logo-die-welt-und-ich-562',
+        'info_dict': {
+            'id': 'logo-die-welt-und-ich-562',
+            'title': 'logo!',
+            'description': 'md5:7b9d7f65561b82fa512f2cfb553c397d',
+        },
+        'playlist_count': 100,
+    }]
+
+    def _entries(self, playlist_url, playlist_id):
+        for page in itertools.count(1):
+            data = self._download_json(playlist_url, playlist_id, note=f'Downloading page {page}')
+            for item in traverse_obj(data, ('content', lambda _, v: url_or_none(v['api']['url']))):
+                yield self.url_result(
+                    item['api']['url'], ie=KikaIE,
+                    **traverse_obj(item, {
+                        'id': ('id', {str}),
+                        'title': ('title', {str}),
+                        'duration': ('duration', {int_or_none}),
+                        'timestamp': ('date', {parse_iso8601}),
+                    }))
+
+            playlist_url = traverse_obj(data, ('links', 'next', {url_or_none}))
+            if not playlist_url:
+                break
+
+    def _real_extract(self, url):
+        playlist_id = self._match_id(url)
+        brand_data = self._download_json(
+            f'https://www.kika.de/_next-api/proxy/v1/brands/{playlist_id}', playlist_id)
+
+        return self.playlist_result(
+            self._entries(brand_data['videoSubchannel']['videosPageUrl'], playlist_id),
+            playlist_id, title=brand_data.get('title'), description=brand_data.get('description'))
diff --git a/yt_dlp/extractor/mixcloud.py b/yt_dlp/extractor/mixcloud.py
index 19b7fd4e7..852670fcb 100644
--- a/yt_dlp/extractor/mixcloud.py
+++ b/yt_dlp/extractor/mixcloud.py
@@ -10,7 +10,9 @@
     parse_iso8601,
     strip_or_none,
     try_get,
+    url_or_none,
 )
+from ..utils.traversal import traverse_obj
 
 
 class MixcloudBaseIE(InfoExtractor):
@@ -37,7 +39,7 @@ class MixcloudIE(MixcloudBaseIE):
             'ext': 'm4a',
             'title': 'Cryptkeeper',
             'description': 'After quite a long silence from myself, finally another Drum\'n\'Bass mix with my favourite current dance floor bangers.',
-            'uploader': 'Daniel Holbach',
+            'uploader': 'dholbach',
             'uploader_id': 'dholbach',
             'thumbnail': r're:https?://.*\.jpg',
             'view_count': int,
@@ -46,10 +48,11 @@ class MixcloudIE(MixcloudBaseIE):
             'uploader_url': 'https://www.mixcloud.com/dholbach/',
             'artist': 'Submorphics & Chino , Telekinesis, Porter Robinson, Enei, Breakage ft Jess Mills',
             'duration': 3723,
-            'tags': [],
+            'tags': ['liquid drum and bass', 'drum and bass'],
             'comment_count': int,
             'repost_count': int,
             'like_count': int,
+            'artists': list,
         },
         'params': {'skip_download': 'm3u8'},
     }, {
@@ -67,7 +70,7 @@ class MixcloudIE(MixcloudBaseIE):
             'upload_date': '20150203',
             'uploader_url': 'https://www.mixcloud.com/gillespeterson/',
             'duration': 2992,
-            'tags': [],
+            'tags': ['jazz', 'soul', 'world music', 'funk'],
             'comment_count': int,
             'repost_count': int,
             'like_count': int,
@@ -149,8 +152,6 @@ def _real_extract(self, url):
         elif reason:
             raise ExtractorError('Track is restricted', expected=True)
 
-        title = cloudcast['name']
-
         stream_info = cloudcast['streamInfo']
         formats = []
 
@@ -182,47 +183,39 @@ def _real_extract(self, url):
             self.raise_login_required(metadata_available=True)
 
         comments = []
-        for edge in (try_get(cloudcast, lambda x: x['comments']['edges']) or []):
-            node = edge.get('node') or {}
+        for node in traverse_obj(cloudcast, ('comments', 'edges', ..., 'node', {dict})):
             text = strip_or_none(node.get('comment'))
             if not text:
                 continue
-            user = node.get('user') or {}
             comments.append({
-                'author': user.get('displayName'),
-                'author_id': user.get('username'),
                 'text': text,
-                'timestamp': parse_iso8601(node.get('created')),
+                **traverse_obj(node, {
+                    'author': ('user', 'displayName', {str}),
+                    'author_id': ('user', 'username', {str}),
+                    'timestamp': ('created', {parse_iso8601}),
+                }),
             })
 
-        tags = []
-        for t in cloudcast.get('tags'):
-            tag = try_get(t, lambda x: x['tag']['name'], str)
-            if not tag:
-                tags.append(tag)
-
-        get_count = lambda x: int_or_none(try_get(cloudcast, lambda y: y[x]['totalCount']))
-
-        owner = cloudcast.get('owner') or {}
-
         return {
             'id': track_id,
-            'title': title,
             'formats': formats,
-            'description': cloudcast.get('description'),
-            'thumbnail': try_get(cloudcast, lambda x: x['picture']['url'], str),
-            'uploader': owner.get('displayName'),
-            'timestamp': parse_iso8601(cloudcast.get('publishDate')),
-            'uploader_id': owner.get('username'),
-            'uploader_url': owner.get('url'),
-            'duration': int_or_none(cloudcast.get('audioLength')),
-            'view_count': int_or_none(cloudcast.get('plays')),
-            'like_count': get_count('favorites'),
-            'repost_count': get_count('reposts'),
-            'comment_count': get_count('comments'),
             'comments': comments,
-            'tags': tags,
-            'artist': ', '.join(cloudcast.get('featuringArtistList') or []) or None,
+            **traverse_obj(cloudcast, {
+                'title': ('name', {str}),
+                'description': ('description', {str}),
+                'thumbnail': ('picture', 'url', {url_or_none}),
+                'timestamp': ('publishDate', {parse_iso8601}),
+                'duration': ('audioLength', {int_or_none}),
+                'uploader': ('owner', 'displayName', {str}),
+                'uploader_id': ('owner', 'username', {str}),
+                'uploader_url': ('owner', 'url', {url_or_none}),
+                'view_count': ('plays', {int_or_none}),
+                'like_count': ('favorites', 'totalCount', {int_or_none}),
+                'repost_count': ('reposts', 'totalCount', {int_or_none}),
+                'comment_count': ('comments', 'totalCount', {int_or_none}),
+                'tags': ('tags', ..., 'tag', 'name', {str}, filter, all, filter),
+                'artists': ('featuringArtistList', ..., {str}, filter, all, filter),
+            }),
         }
 
 
@@ -295,7 +288,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
         'url': 'http://www.mixcloud.com/dholbach/',
         'info_dict': {
             'id': 'dholbach_uploads',
-            'title': 'Daniel Holbach (uploads)',
+            'title': 'dholbach (uploads)',
             'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
         },
         'playlist_mincount': 36,
@@ -303,7 +296,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
         'url': 'http://www.mixcloud.com/dholbach/uploads/',
         'info_dict': {
             'id': 'dholbach_uploads',
-            'title': 'Daniel Holbach (uploads)',
+            'title': 'dholbach (uploads)',
             'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
         },
         'playlist_mincount': 36,
@@ -311,7 +304,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
         'url': 'http://www.mixcloud.com/dholbach/favorites/',
         'info_dict': {
             'id': 'dholbach_favorites',
-            'title': 'Daniel Holbach (favorites)',
+            'title': 'dholbach (favorites)',
             'description': 'md5:a3f468a60ac8c3e1f8616380fc469b2b',
         },
         # 'params': {
@@ -337,7 +330,7 @@ class MixcloudUserIE(MixcloudPlaylistBaseIE):
             'title': 'First Ear (stream)',
             'description': 'we maraud for ears',
         },
-        'playlist_mincount': 269,
+        'playlist_mincount': 267,
     }]
 
     _TITLE_KEY = 'displayName'
@@ -361,7 +354,7 @@ class MixcloudPlaylistIE(MixcloudPlaylistBaseIE):
             'id': 'maxvibes_jazzcat-on-ness-radio',
             'title': 'Ness Radio sessions',
         },
-        'playlist_mincount': 59,
+        'playlist_mincount': 58,
     }]
     _TITLE_KEY = 'name'
     _DESCRIPTION_KEY = 'description'
diff --git a/yt_dlp/extractor/niconico.py b/yt_dlp/extractor/niconico.py
index 62dd0ab9c..5e66aebeb 100644
--- a/yt_dlp/extractor/niconico.py
+++ b/yt_dlp/extractor/niconico.py
@@ -27,6 +27,7 @@
     traverse_obj,
     try_get,
     unescapeHTML,
+    unified_timestamp,
     update_url_query,
     url_basename,
     url_or_none,
@@ -985,6 +986,7 @@ def _real_extract(self, url):
                     'quality': 'abr',
                     'protocol': 'hls+fmp4',
                     'latency': latency,
+                    'accessRightMethod': 'single_cookie',
                     'chasePlay': False,
                 },
                 'room': {
@@ -1005,6 +1007,7 @@ def _real_extract(self, url):
             if data.get('type') == 'stream':
                 m3u8_url = data['data']['uri']
                 qualities = data['data']['availableQualities']
+                cookies = data['data']['cookies']
                 break
             elif data.get('type') == 'disconnect':
                 self.write_debug(recv)
@@ -1043,6 +1046,11 @@ def _real_extract(self, url):
                     **res,
                 })
 
+        for cookie in cookies:
+            self._set_cookie(
+                cookie['domain'], cookie['name'], cookie['value'],
+                expire_time=unified_timestamp(cookie['expires']), path=cookie['path'], secure=cookie['secure'])
+
         formats = self._extract_m3u8_formats(m3u8_url, video_id, ext='mp4', live=True)
         for fmt, q in zip(formats, reversed(qualities[1:])):
             fmt.update({
diff --git a/yt_dlp/extractor/parti.py b/yt_dlp/extractor/parti.py
new file mode 100644
index 000000000..acadefc4e
--- /dev/null
+++ b/yt_dlp/extractor/parti.py
@@ -0,0 +1,101 @@
+from .common import InfoExtractor
+from ..utils import UserNotLive, int_or_none, parse_iso8601, url_or_none, urljoin
+from ..utils.traversal import traverse_obj
+
+
+class PartiBaseIE(InfoExtractor):
+    def _call_api(self, path, video_id, note=None):
+        return self._download_json(
+            f'https://api-backend.parti.com/parti_v2/profile/{path}', video_id, note)
+
+
+class PartiVideoIE(PartiBaseIE):
+    IE_NAME = 'parti:video'
+    _VALID_URL = r'https?://(?:www\.)?parti\.com/video/(?P<id>\d+)'
+    _TESTS = [{
+        'url': 'https://parti.com/video/66284',
+        'info_dict': {
+            'id': '66284',
+            'ext': 'mp4',
+            'title': 'NOW LIVE ',
+            'upload_date': '20250327',
+            'categories': ['Gaming'],
+            'thumbnail': 'https://assets.parti.com/351424_eb9e5250-2821-484a-9c5f-ca99aa666c87.png',
+            'channel': 'ItZTMGG',
+            'timestamp': 1743044379,
+        },
+        'params': {'skip_download': 'm3u8'},
+    }]
+
+    def _real_extract(self, url):
+        video_id = self._match_id(url)
+        data = self._call_api(f'get_livestream_channel_info/recent/{video_id}', video_id)
+
+        return {
+            'id': video_id,
+            'formats': self._extract_m3u8_formats(
+                urljoin('https://watch.parti.com', data['livestream_recording']), video_id, 'mp4'),
+            **traverse_obj(data, {
+                'title': ('event_title', {str}),
+                'channel': ('user_name', {str}),
+                'thumbnail': ('event_file', {url_or_none}),
+                'categories': ('category_name', {str}, filter, all),
+                'timestamp': ('event_start_ts', {int_or_none}),
+            }),
+        }
+
+
+class PartiLivestreamIE(PartiBaseIE):
+    IE_NAME = 'parti:livestream'
+    _VALID_URL = r'https?://(?:www\.)?parti\.com/creator/(?P<service>[\w]+)/(?P<id>[\w/-]+)'
+    _TESTS = [{
+        'url': 'https://parti.com/creator/parti/Capt_Robs_Adventures',
+        'info_dict': {
+            'id': 'Capt_Robs_Adventures',
+            'ext': 'mp4',
+            'title': r"re:I'm Live on Parti \d{4}-\d{2}-\d{2} \d{2}:\d{2}",
+            'view_count': int,
+            'thumbnail': r're:https://assets\.parti\.com/.+\.png',
+            'timestamp': 1743879776,
+            'upload_date': '20250405',
+            'live_status': 'is_live',
+        },
+        'params': {'skip_download': 'm3u8'},
+    }, {
+        'url': 'https://parti.com/creator/discord/sazboxgaming/0',
+        'only_matching': True,
+    }]
+
+    def _real_extract(self, url):
+        service, creator_slug = self._match_valid_url(url).group('service', 'id')
+
+        encoded_creator_slug = creator_slug.replace('/', '%23')
+        creator_id = self._call_api(
+            f'get_user_by_social_media/{service}/{encoded_creator_slug}',
+            creator_slug, note='Fetching user ID')
+
+        data = self._call_api(
+            f'get_livestream_channel_info/{creator_id}', creator_id,
+            note='Fetching user profile feed')['channel_info']
+
+        if not traverse_obj(data, ('channel', 'is_live', {bool})):
+            raise UserNotLive(video_id=creator_id)
+
+        channel_info = data['channel']
+
+        return {
+            'id': creator_slug,
+            'formats': self._extract_m3u8_formats(
+                channel_info['playback_url'], creator_slug, live=True, query={
+                    'token': channel_info['playback_auth_token'],
+                    'player_version': '1.17.0',
+                }),
+            'is_live': True,
+            **traverse_obj(data, {
+                'title': ('livestream_event_info', 'event_name', {str}),
+                'description': ('livestream_event_info', 'event_description', {str}),
+                'thumbnail': ('livestream_event_info', 'livestream_preview_file', {url_or_none}),
+                'timestamp': ('stream', 'start_time', {parse_iso8601}),
+                'view_count': ('stream', 'viewer_count', {int_or_none}),
+            }),
+        }
diff --git a/yt_dlp/extractor/roya.py b/yt_dlp/extractor/roya.py
new file mode 100644
index 000000000..e9fe304ee
--- /dev/null
+++ b/yt_dlp/extractor/roya.py
@@ -0,0 +1,43 @@
+from .common import InfoExtractor
+from ..utils.traversal import traverse_obj
+
+
+class RoyaLiveIE(InfoExtractor):
+    _VALID_URL = r'https?://roya\.tv/live-stream/(?P<id>\d+)'
+    _TESTS = [{
+        'url': 'https://roya.tv/live-stream/1',
+        'info_dict': {
+            'id': '1',
+            'title': r're:Roya TV \d{4}-\d{2}-\d{2} \d{2}:\d{2}',
+            'ext': 'mp4',
+            'live_status': 'is_live',
+        },
+    }, {
+        'url': 'https://roya.tv/live-stream/21',
+        'info_dict': {
+            'id': '21',
+            'title': r're:Roya News \d{4}-\d{2}-\d{2} \d{2}:\d{2}',
+            'ext': 'mp4',
+            'live_status': 'is_live',
+        },
+    }, {
+        'url': 'https://roya.tv/live-stream/10000',
+        'only_matching': True,
+    }]
+
+    def _real_extract(self, url):
+        media_id = self._match_id(url)
+
+        stream_url = self._download_json(
+            f'https://ticket.roya-tv.com/api/v5/fastchannel/{media_id}', media_id)['data']['secured_url']
+
+        title = traverse_obj(
+            self._download_json('https://backend.roya.tv/api/v01/channels/schedule-pagination', media_id, fatal=False),
+            ('data', 0, 'channel', lambda _, v: str(v['id']) == media_id, 'title', {str}, any))
+
+        return {
+            'id': media_id,
+            'formats': self._extract_m3u8_formats(stream_url, media_id, 'mp4', m3u8_id='hls', live=True),
+            'title': title,
+            'is_live': True,
+        }
diff --git a/yt_dlp/extractor/rumble.py b/yt_dlp/extractor/rumble.py
index 74c7e4f17..757d6994c 100644
--- a/yt_dlp/extractor/rumble.py
+++ b/yt_dlp/extractor/rumble.py
@@ -7,7 +7,6 @@
     ExtractorError,
     UnsupportedError,
     clean_html,
-    determine_ext,
     extract_attributes,
     format_field,
     get_element_by_class,
@@ -36,7 +35,7 @@ class RumbleEmbedIE(InfoExtractor):
             'upload_date': '20191020',
             'channel_url': 'https://rumble.com/c/WMAR',
             'channel': 'WMAR',
-            'thumbnail': 'https://sp.rmbl.ws/s8/1/5/M/z/1/5Mz1a.qR4e-small-WMAR-2-News-Latest-Headline.jpg',
+            'thumbnail': r're:https://.+\.jpg',
             'duration': 234,
             'uploader': 'WMAR',
             'live_status': 'not_live',
@@ -52,7 +51,7 @@ class RumbleEmbedIE(InfoExtractor):
             'upload_date': '20220217',
             'channel_url': 'https://rumble.com/c/CyberTechNews',
             'channel': 'CTNews',
-            'thumbnail': 'https://sp.rmbl.ws/s8/6/7/i/9/h/7i9hd.OvCc.jpg',
+            'thumbnail': r're:https://.+\.jpg',
             'duration': 901,
             'uploader': 'CTNews',
             'live_status': 'not_live',
@@ -114,6 +113,22 @@ class RumbleEmbedIE(InfoExtractor):
             'live_status': 'was_live',
         },
         'params': {'skip_download': True},
+    }, {
+        'url': 'https://rumble.com/embed/v6pezdb',
+        'info_dict': {
+            'id': 'v6pezdb',
+            'ext': 'mp4',
+            'title': '"Es war einmal ein Mädchen" – Ein filmisches Zeitzeugnis aus Leningrad 1944',
+            'uploader': 'RT DE',
+            'channel': 'RT DE',
+            'channel_url': 'https://rumble.com/c/RTDE',
+            'duration': 309,
+            'thumbnail': 'https://1a-1791.com/video/fww1/dc/s8/1/n/z/2/y/nz2yy.qR4e-small-Es-war-einmal-ein-Mdchen-Ei.jpg',
+            'timestamp': 1743703500,
+            'upload_date': '20250403',
+            'live_status': 'not_live',
+        },
+        'params': {'skip_download': True},
     }, {
         'url': 'https://rumble.com/embed/ufe9n.v5pv5f',
         'only_matching': True,
@@ -168,40 +183,42 @@ def _real_extract(self, url):
             live_status = None
 
         formats = []
-        for ext, ext_info in (video.get('ua') or {}).items():
-            if isinstance(ext_info, dict):
-                for height, video_info in ext_info.items():
+        for format_type, format_info in (video.get('ua') or {}).items():
+            if isinstance(format_info, dict):
+                for height, video_info in format_info.items():
                     if not traverse_obj(video_info, ('meta', 'h', {int_or_none})):
                         video_info.setdefault('meta', {})['h'] = height
-                ext_info = ext_info.values()
+                format_info = format_info.values()
 
-            for video_info in ext_info:
+            for video_info in format_info:
                 meta = video_info.get('meta') or {}
                 if not video_info.get('url'):
                     continue
-                if ext == 'hls':
+                # With default query params returns m3u8 variants which are duplicates, without returns tar files
+                if format_type == 'tar':
+                    continue
+                if format_type == 'hls':
                     if meta.get('live') is True and video.get('live') == 1:
                         live_status = 'post_live'
                     formats.extend(self._extract_m3u8_formats(
                         video_info['url'], video_id,
                         ext='mp4', m3u8_id='hls', fatal=False, live=live_status == 'is_live'))
                     continue
-                timeline = ext == 'timeline'
-                if timeline:
-                    ext = determine_ext(video_info['url'])
+                is_timeline = format_type == 'timeline'
+                is_audio = format_type == 'audio'
                 formats.append({
-                    'ext': ext,
-                    'acodec': 'none' if timeline else None,
+                    'acodec': 'none' if is_timeline else None,
+                    'vcodec': 'none' if is_audio else None,
                     'url': video_info['url'],
-                    'format_id': join_nonempty(ext, format_field(meta, 'h', '%sp')),
-                    'format_note': 'Timeline' if timeline else None,
-                    'fps': None if timeline else video.get('fps'),
+                    'format_id': join_nonempty(format_type, format_field(meta, 'h', '%sp')),
+                    'format_note': 'Timeline' if is_timeline else None,
+                    'fps': None if is_timeline or is_audio else video.get('fps'),
                     **traverse_obj(meta, {
-                        'tbr': 'bitrate',
-                        'filesize': 'size',
-                        'width': 'w',
-                        'height': 'h',
-                    }, expected_type=lambda x: int(x) or None),
+                        'tbr': ('bitrate', {int_or_none}),
+                        'filesize': ('size', {int_or_none}),
+                        'width': ('w', {int_or_none}),
+                        'height': ('h', {int_or_none}),
+                    }),
                 })
 
         subtitles = {
diff --git a/yt_dlp/extractor/vk.py b/yt_dlp/extractor/vk.py
index faf3e60b0..ef67c210b 100644
--- a/yt_dlp/extractor/vk.py
+++ b/yt_dlp/extractor/vk.py
@@ -544,7 +544,7 @@ def _real_extract(self, url):
                 'uploader_id': (('author_id', 'authorId'), {str_or_none}, any),
                 'duration': ('duration', {int_or_none}),
                 'chapters': ('time_codes', lambda _, v: isinstance(v['time'], int), {
-                    'title': ('text', {str}),
+                    'title': ('text', {unescapeHTML}),
                     'start_time': 'time',
                 }),
             }),
diff --git a/yt_dlp/extractor/yandexvideo.py b/yt_dlp/extractor/yandexvideo.py
index cdd32c5e4..09e7c9878 100644
--- a/yt_dlp/extractor/yandexvideo.py
+++ b/yt_dlp/extractor/yandexvideo.py
@@ -2,15 +2,17 @@
 
 from .common import InfoExtractor
 from ..utils import (
+    bug_reports_message,
     determine_ext,
-    extract_attributes,
     int_or_none,
     lowercase_escape,
     parse_qs,
-    traverse_obj,
+    qualities,
     try_get,
+    update_url_query,
     url_or_none,
 )
+from ..utils.traversal import traverse_obj
 
 
 class YandexVideoIE(InfoExtractor):
@@ -186,7 +188,22 @@ def _real_extract(self, url):
         return self.url_result(data_json['video']['url'])
 
 
-class ZenYandexIE(InfoExtractor):
+class ZenYandexBaseIE(InfoExtractor):
+    def _fetch_ssr_data(self, url, video_id):
+        webpage = self._download_webpage(url, video_id)
+        redirect = self._search_json(
+            r'(?:var|let|const)\s+it\s*=', webpage, 'redirect', video_id, default={}).get('retpath')
+        if redirect:
+            video_id = self._match_id(redirect)
+            webpage = self._download_webpage(redirect, video_id, note='Redirecting')
+        return video_id, self._search_json(
+            r'(?:var|let|const)\s+_params\s*=\s*\(', webpage, 'metadata', video_id,
+            contains_pattern=r'{["\']ssrData.+}')['ssrData']
+
+
+class ZenYandexIE(ZenYandexBaseIE):
+    IE_NAME = 'dzen.ru'
+    IE_DESC = 'Дзен (dzen) formerly Яндекс.Дзен (Yandex Zen)'
     _VALID_URL = r'https?://(zen\.yandex|dzen)\.ru(?:/video)?/(media|watch)/(?:(?:id/[^/]+/|[^/]+/)(?:[a-z0-9-]+)-)?(?P<id>[a-z0-9-]+)'
     _TESTS = [{
         'url': 'https://zen.yandex.ru/media/id/606fd806cc13cb3c58c05cf5/vot-eto-focus-dedy-morozy-na-gidrociklah-60c7c443da18892ebfe85ed7',
@@ -216,6 +233,7 @@ class ZenYandexIE(InfoExtractor):
             'timestamp': 1573465585,
         },
         'params': {'skip_download': 'm3u8'},
+        'skip': 'The page does not exist',
     }, {
         'url': 'https://zen.yandex.ru/video/watch/6002240ff8b1af50bb2da5e3',
         'info_dict': {
@@ -227,6 +245,9 @@ class ZenYandexIE(InfoExtractor):
             'uploader': 'TechInsider',
             'timestamp': 1611378221,
             'upload_date': '20210123',
+            'view_count': int,
+            'duration': 243,
+            'tags': ['опыт', 'эксперимент', 'огонь'],
         },
         'params': {'skip_download': 'm3u8'},
     }, {
@@ -240,6 +261,9 @@ class ZenYandexIE(InfoExtractor):
             'uploader': 'TechInsider',
             'upload_date': '20210123',
             'timestamp': 1611378221,
+            'view_count': int,
+            'duration': 243,
+            'tags': ['опыт', 'эксперимент', 'огонь'],
         },
         'params': {'skip_download': 'm3u8'},
     }, {
@@ -252,44 +276,56 @@ class ZenYandexIE(InfoExtractor):
 
     def _real_extract(self, url):
         video_id = self._match_id(url)
-        webpage = self._download_webpage(url, video_id)
-        redirect = self._search_json(r'var it\s*=', webpage, 'redirect', id, default={}).get('retpath')
-        if redirect:
-            video_id = self._match_id(redirect)
-            webpage = self._download_webpage(redirect, video_id, note='Redirecting')
-        data_json = self._search_json(
-            r'("data"\s*:|data\s*=)', webpage, 'metadata', video_id, contains_pattern=r'{["\']_*serverState_*video.+}')
-        serverstate = self._search_regex(r'(_+serverState_+video-site_[^_]+_+)', webpage, 'server state')
-        uploader = self._search_regex(r'(<a\s*class=["\']card-channel-link[^"\']+["\'][^>]+>)',
-                                      webpage, 'uploader', default='<a>')
-        uploader_name = extract_attributes(uploader).get('aria-label')
-        item_id = traverse_obj(data_json, (serverstate, 'videoViewer', 'openedItemId', {str}))
-        video_json = traverse_obj(data_json, (serverstate, 'videoViewer', 'items', item_id, {dict})) or {}
+        video_id, ssr_data = self._fetch_ssr_data(url, video_id)
+        video_data = ssr_data['videoMetaResponse']
 
         formats, subtitles = [], {}
-        for s_url in traverse_obj(video_json, ('video', 'streams', ..., {url_or_none})):
+        quality = qualities(('4', '0', '1', '2', '3', '5', '6', '7'))
+        # Deduplicate stream URLs. The "dzen_dash" query parameter is present in some URLs but can be omitted
+        stream_urls = set(traverse_obj(video_data, (
+            'video', ('id', ('streams', ...), ('mp4Streams', ..., 'url'), ('oneVideoStreams', ..., 'url')),
+            {url_or_none}, {update_url_query(query={'dzen_dash': []})})))
+        for s_url in stream_urls:
             ext = determine_ext(s_url)
-            if ext == 'mpd':
-                fmts, subs = self._extract_mpd_formats_and_subtitles(s_url, video_id, mpd_id='dash')
-            elif ext == 'm3u8':
-                fmts, subs = self._extract_m3u8_formats_and_subtitles(s_url, video_id, 'mp4')
+            content_type = traverse_obj(parse_qs(s_url), ('ct', 0))
+            if ext == 'mpd' or content_type == '6':
+                fmts, subs = self._extract_mpd_formats_and_subtitles(s_url, video_id, mpd_id='dash', fatal=False)
+            elif ext == 'm3u8' or content_type == '8':
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(s_url, video_id, 'mp4', m3u8_id='hls', fatal=False)
+            elif content_type == '0':
+                format_type = traverse_obj(parse_qs(s_url), ('type', 0))
+                formats.append({
+                    'url': s_url,
+                    'format_id': format_type,
+                    'ext': 'mp4',
+                    'quality': quality(format_type),
+                })
+                continue
+            else:
+                self.report_warning(f'Unsupported stream URL: {s_url}{bug_reports_message()}')
+                continue
             formats.extend(fmts)
-            subtitles = self._merge_subtitles(subtitles, subs)
+            self._merge_subtitles(subs, target=subtitles)
+
         return {
             'id': video_id,
-            'title': video_json.get('title') or self._og_search_title(webpage),
             'formats': formats,
             'subtitles': subtitles,
-            'duration': int_or_none(video_json.get('duration')),
-            'view_count': int_or_none(video_json.get('views')),
-            'timestamp': int_or_none(video_json.get('publicationDate')),
-            'uploader': uploader_name or data_json.get('authorName') or try_get(data_json, lambda x: x['publisher']['name']),
-            'description': video_json.get('description') or self._og_search_description(webpage),
-            'thumbnail': self._og_search_thumbnail(webpage) or try_get(data_json, lambda x: x['og']['imageUrl']),
+            **traverse_obj(video_data, {
+                'title': ('title', {str}),
+                'description': ('description', {str}),
+                'thumbnail': ('image', {url_or_none}),
+                'duration': ('video', 'duration', {int_or_none}),
+                'view_count': ('video', 'views', {int_or_none}),
+                'timestamp': ('publicationDate', {int_or_none}),
+                'tags': ('tags', ..., {str}),
+                'uploader': ('source', 'title', {str}),
+            }),
         }
 
 
-class ZenYandexChannelIE(InfoExtractor):
+class ZenYandexChannelIE(ZenYandexBaseIE):
+    IE_NAME = 'dzen.ru:channel'
     _VALID_URL = r'https?://(zen\.yandex|dzen)\.ru/(?!media|video)(?:id/)?(?P<id>[a-z0-9-_]+)'
     _TESTS = [{
         'url': 'https://zen.yandex.ru/tok_media',
@@ -323,8 +359,8 @@ class ZenYandexChannelIE(InfoExtractor):
         'url': 'https://zen.yandex.ru/jony_me',
         'info_dict': {
             'id': 'jony_me',
-            'description': 'md5:ce0a5cad2752ab58701b5497835b2cc5',
-            'title': 'JONY ',
+            'description': 'md5:7c30d11dc005faba8826feae99da3113',
+            'title': 'JONY',
         },
         'playlist_count': 18,
     }, {
@@ -333,9 +369,8 @@ class ZenYandexChannelIE(InfoExtractor):
         'url': 'https://zen.yandex.ru/tatyanareva',
         'info_dict': {
             'id': 'tatyanareva',
-            'description': 'md5:40a1e51f174369ec3ba9d657734ac31f',
+            'description': 'md5:92e56fa730a932ca2483ba5c2186ad96',
             'title': 'Татьяна Рева',
-            'entries': 'maxcount:200',
         },
         'playlist_mincount': 46,
     }, {
@@ -348,43 +383,31 @@ class ZenYandexChannelIE(InfoExtractor):
         'playlist_mincount': 657,
     }]
 
-    def _entries(self, item_id, server_state_json, server_settings_json):
-        items = (traverse_obj(server_state_json, ('feed', 'items', ...))
-                 or traverse_obj(server_settings_json, ('exportData', 'items', ...)))
-
-        more = (traverse_obj(server_state_json, ('links', 'more'))
-                or traverse_obj(server_settings_json, ('exportData', 'more', 'link')))
-
+    def _entries(self, feed_data, channel_id):
         next_page_id = None
         for page in itertools.count(1):
-            for item in items or []:
-                if item.get('type') != 'gif':
-                    continue
-                video_id = traverse_obj(item, 'publication_id', 'publicationId') or ''
-                yield self.url_result(item['link'], ZenYandexIE, video_id.split(':')[-1])
+            for item in traverse_obj(feed_data, (
+                (None, ('items', lambda _, v: v['tab'] in ('shorts', 'longs'))),
+                'items', lambda _, v: url_or_none(v['link']),
+            )):
+                yield self.url_result(item['link'], ZenYandexIE, item.get('id'), title=item.get('title'))
 
+            more = traverse_obj(feed_data, ('more', 'link', {url_or_none}))
             current_page_id = next_page_id
             next_page_id = traverse_obj(parse_qs(more), ('next_page_id', -1))
-            if not all((more, items, next_page_id, next_page_id != current_page_id)):
+            if not all((more, next_page_id, next_page_id != current_page_id)):
                 break
 
-            data = self._download_json(more, item_id, note=f'Downloading Page {page}')
-            items, more = data.get('items'), traverse_obj(data, ('more', 'link'))
+            feed_data = self._download_json(more, channel_id, note=f'Downloading Page {page}')
 
     def _real_extract(self, url):
-        item_id = self._match_id(url)
-        webpage = self._download_webpage(url, item_id)
-        redirect = self._search_json(
-            r'var it\s*=', webpage, 'redirect', item_id, default={}).get('retpath')
-        if redirect:
-            item_id = self._match_id(redirect)
-            webpage = self._download_webpage(redirect, item_id, note='Redirecting')
-        data = self._search_json(
-            r'("data"\s*:|data\s*=)', webpage, 'channel data', item_id, contains_pattern=r'{\"__serverState__.+}')
-        server_state_json = traverse_obj(data, lambda k, _: k.startswith('__serverState__'), get_all=False)
-        server_settings_json = traverse_obj(data, lambda k, _: k.startswith('__serverSettings__'), get_all=False)
+        channel_id = self._match_id(url)
+        channel_id, ssr_data = self._fetch_ssr_data(url, channel_id)
+        channel_data = ssr_data['exportResponse']
 
         return self.playlist_result(
-            self._entries(item_id, server_state_json, server_settings_json),
-            item_id, traverse_obj(server_state_json, ('channel', 'source', 'title')),
-            traverse_obj(server_state_json, ('channel', 'source', 'description')))
+            self._entries(channel_data['feedData'], channel_id),
+            channel_id, **traverse_obj(channel_data, ('channel', 'source', {
+                'title': ('title', {str}),
+                'description': ('description', {str}),
+            })))
diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py
index 24525560e..99d725087 100644
--- a/yt_dlp/utils/_utils.py
+++ b/yt_dlp/utils/_utils.py
@@ -2044,7 +2044,7 @@ def url_or_none(url):
     if not url or not isinstance(url, str):
         return None
     url = url.strip()
-    return url if re.match(r'(?:(?:https?|rt(?:m(?:pt?[es]?|fp)|sp[su]?)|mms|ftps?):)?//', url) else None
+    return url if re.match(r'(?:(?:https?|rt(?:m(?:pt?[es]?|fp)|sp[su]?)|mms|ftps?|wss?):)?//', url) else None
 
 
 def strftime_or_none(timestamp, date_format='%Y%m%d', default=None):


Test patch:
diff --git a/test/test_utils.py b/test/test_utils.py
index e60ceed8f..aedb565ec 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -659,6 +659,8 @@ def test_url_or_none(self):
         self.assertEqual(url_or_none('mms://foo.de'), 'mms://foo.de')
         self.assertEqual(url_or_none('rtspu://foo.de'), 'rtspu://foo.de')
         self.assertEqual(url_or_none('ftps://foo.de'), 'ftps://foo.de')
+        self.assertEqual(url_or_none('ws://foo.de'), 'ws://foo.de')
+        self.assertEqual(url_or_none('wss://foo.de'), 'wss://foo.de')
 
     def test_parse_age_limit(self):
         self.assertEqual(parse_age_limit(None), None)