Repository: yt-dlp/yt-dlp

Issue:
On Odysee cannot download playlist.
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

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

### Checklist

- [X] I'm reporting a bug unrelated to a specific site
- [X] I've verified that I'm running yt-dlp version **2023.01.06** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [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 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)

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

When I try to download a playlist n Odysee, I get the following:

[lbry] Extracting URL: https://odysee.com/$/playlist/...
[lbry] $: Downloading stream JSON metadata
ERROR: $: An extractor error has occurred. (caused by KeyError('value')); please report this issue on  https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using  yt-dlp -U

### 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] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[lbry] Extracting URL: https://odysee.com/$/playlist/...
[lbry] $: Downloading stream JSON metadata
ERROR: $: An extractor error has occurred. (caused by KeyError('value')); please report this issue on  https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using  yt-dlp -U
```

Unsupported URL - embedded Odysee videos
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I *intentionally* remove or skip any mandatory\* field

### Checklist

- [X] I'm reporting that yt-dlp is broken on a **supported** site
- [X] I've verified that I'm running yt-dlp version **2023.09.24** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are playable in a browser with the same IP and same login details
- [X] I've checked that all URLs and arguments with special characters are [properly quoted or escaped](https://github.com/yt-dlp/yt-dlp/wiki/FAQ#video-url-contains-an-ampersand--and-im-getting-some-strange-output-1-2839-or-v-is-not-recognized-as-an-internal-or-external-command)
- [X] I've searched [known issues](https://github.com/yt-dlp/yt-dlp/issues/3766) and the [bugtracker](https://github.com/yt-dlp/yt-dlp/issues?q=) for similar issues **including closed ones**. DO NOT post duplicates
- [X] I've read the [guidelines for opening an issue](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#opening-an-issue)
- [X] I've read about [sharing account credentials](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#are-you-willing-to-share-account-details-if-needed) and I'm willing to share it if required

### Region

USA

### Provide a description that is worded well enough to be understood

Typical video URL from Odysee home video page - works with yt-dlp generic extractor:
https://odysee.com/@PeoplesCar:0/Vw-Action-2023---Aircooled-Vw-Show-Shine-Concours-Car-Competition-Volkswagen-Bug-Bus-Type3-Golf:2

Embedded URL as provided by Odysee for blogs/aggregator sites - doesn't work with generic extractor:
https://odysee.com/$/embed/@PeoplesCar:0/Vw-Action-2023---Aircooled-Vw-Show-Shine-Concours-Car-Competition-Volkswagen-Bug-Bus-Type3-Golf:2?r=93A6yhuwY1hKHb5NmCDoEaC9TXdkk7zv

Modified embedded URL - works:
https://odysee.com/@PeoplesCar:0/Vw-Action-2023---Aircooled-Vw-Show-Shine-Concours-Car-Competition-Volkswagen-Bug-Bus-Type3-Golf:2?r=93A6yhuwY1hKHb5NmCDoEaC9TXdkk7zv

Requested:
Rewrite embedded URL to omit "$/embed/", or otherwise use it.

### 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] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version nightly@2023.09.24.003044 [de015e930] (zip)
[debug] Python 3.9.2 (CPython x86_64 64bit) - Linux-5.10.0-25-amd64-x86_64-with-glibc2.31 (OpenSSL 1.1.1n  15 Mar 2022, glibc 2.31)
[debug] exe versions: ffmpeg 4.3.6-0, ffprobe 4.3.6-0
[debug] Optional libraries: certifi-2020.06.20, sqlite3-3.34.1
[debug] Proxy map: {}
[debug] Loaded 1886 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp-nightly-builds/releases/latest
Available version: nightly@2023.09.24.003044, Current version: nightly@2023.09.24.003044
Current Build Hash: 8f95bda279630943660ae603777bc72019723da5134e37bc60b82bbef9a8e925
yt-dlp is up to date (nightly@2023.09.24.003044)
[lbry] Extracting URL: https://odysee.com/$/embed/@PeoplesCar:0/Vw-Action-2023---Aircooled-Vw-Show-Shine-Concours-Car-Competition-Volkswagen-Bug-Bus-Type3-Golf:2?r=93A6yhuwY1hKHb5NmCDoEaC9TXdkk7zv
[lbry] $: Downloading stream JSON metadata
ERROR: Unsupported URL: https://odysee.com/$/embed/@PeoplesCar:0/Vw-Action-2023---Aircooled-Vw-Show-Shine-Concours-Car-Competition-Volkswagen-Bug-Bus-Type3-Golf:2?r=93A6yhuwY1hKHb5NmCDoEaC9TXdkk7zv
Traceback (most recent call last):
  File "/home/chris/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1567, in wrapper
    return func(self, *args, **kwargs)
  File "/home/chris/.local/bin/yt-dlp/yt_dlp/YoutubeDL.py", line 1702, in __extract_info
    ie_result = ie.extract(url)
  File "/home/chris/.local/bin/yt-dlp/yt_dlp/extractor/common.py", line 715, in extract
    ie_result = self._real_extract(url)
  File "/home/chris/.local/bin/yt-dlp/yt_dlp/extractor/lbry.py", line 285, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://odysee.com/$/embed/@PeoplesCar:0/Vw-Action-2023---Aircooled-Vw-Show-Shine-Concours-Car-Competition-Volkswagen-Bug-Bus-Type3-Golf:2?r=93A6yhuwY1hKHb5NmCDoEaC9TXdkk7zv
```



Discussion:
I tried to load your playlist in the browser. Does it have private restriction?
![image](https://user-images.githubusercontent.com/17416271/211142512-8374f776-b5ce-438d-ba58-b56df0409a29.png)

### DO NOT BLINDLY TICK THE CHECK BOXES

They are instructions for you to follow and then confirm by ticking them. Issues were it is obvious that you have not followed the instructions will be closed as `incomplete`. Repeated offense **will be treated as spam**
try it with the users home page - yt-dlp "https://odysee.com/@lenexists:a?view=home"
Look, the issue is the same no matter what playlist you try it with. No difference. Right now, yt-dlp just can't download playlists from Odysee. Probably a parsing issue (I suspect).
> Look, the issue is the same no matter what playlist you try it with. No difference. Right now, yt-dlp just can't download playlists from Odysee. Probably a parsing issue (I suspect).

Has the feature ever worked with Odysee? I'm trying to download a LBRY playlist that I've made public and it can't seem to parse it either.
This extractor's whole `_VALID_URL` and `display_id` situation could stand to be refactored... but for a quick fix, this works:
```diff
diff --git a/yt_dlp/extractor/lbry.py b/yt_dlp/extractor/lbry.py
index 9a9f9256f..01c63ff7e 100644
--- a/yt_dlp/extractor/lbry.py
+++ b/yt_dlp/extractor/lbry.py
@@ -80,7 +80,7 @@ def _parse_stream(self, stream, url):
 
 class LBRYIE(LBRYBaseIE):
     IE_NAME = 'lbry'
-    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + r'(?P<id>\$/[^/]+/[^/]+/{1}|@{0}/{0}|(?!@){0})'.format(LBRYBaseIE._OPT_CLAIM_ID, LBRYBaseIE._CLAIM_ID_REGEX)
+    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + r'(?P<id>\$/[^/]+/[^/]+/{1}|(?:\$/embed/)?@{0}/{0}|(?!@){0})'.format(LBRYBaseIE._OPT_CLAIM_ID, LBRYBaseIE._CLAIM_ID_REGEX)
     _TESTS = [{
         # Video
         'url': 'https://lbry.tv/@Mantega:1/First-day-LBRY:1',
@@ -230,9 +251,11 @@ class LBRYIE(LBRYBaseIE):
     def _real_extract(self, url):
         display_id = self._match_id(url)
         if display_id.startswith('$/'):
-            display_id = display_id.split('/', 2)[-1].replace('/', ':')
-        else:
+            display_id = display_id.split('/', 2)[-1]
+        if display_id.startswith('@'):
             display_id = display_id.replace(':', '#')
+        else:
+            display_id = display_id.replace('/', ':')
         display_id = urllib.parse.unquote(display_id)
         uri = 'lbry://' + display_id
         result = self._resolve_url(uri, display_id, 'stream')
```
Thanks for that...  I'll try this for myself later on.

What a project!!

Resolving patch:
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -951,6 +951,7 @@
 from .lbry import (
     LBRYIE,
     LBRYChannelIE,
+    LBRYPlaylistIE,
 )
 from .lci import LCIIE
 from .lcp import (
diff --git a/yt_dlp/extractor/lbry.py b/yt_dlp/extractor/lbry.py
--- a/yt_dlp/extractor/lbry.py
+++ b/yt_dlp/extractor/lbry.py
@@ -22,10 +22,11 @@
 
 
 class LBRYBaseIE(InfoExtractor):
-    _BASE_URL_REGEX = r'(?:https?://(?:www\.)?(?:lbry\.tv|odysee\.com)/|lbry://)'
+    _BASE_URL_REGEX = r'(?x)(?:https?://(?:www\.)?(?:lbry\.tv|odysee\.com)/|lbry://)'
     _CLAIM_ID_REGEX = r'[0-9a-f]{1,40}'
-    _OPT_CLAIM_ID = '[^:/?#&]+(?:[:#]%s)?' % _CLAIM_ID_REGEX
+    _OPT_CLAIM_ID = '[^$@:/?#&]+(?:[:#]%s)?' % _CLAIM_ID_REGEX
     _SUPPORTED_STREAM_TYPES = ['video', 'audio']
+    _PAGE_SIZE = 50
 
     def _call_api_proxy(self, method, display_id, params, resource):
         headers = {'Content-Type': 'application/json-rpc'}
@@ -77,10 +78,70 @@ def _parse_stream(self, stream, url):
 
         return info
 
+    def _fetch_page(self, display_id, url, params, page):
+        page += 1
+        page_params = {
+            'no_totals': True,
+            'page': page,
+            'page_size': self._PAGE_SIZE,
+            **params,
+        }
+        result = self._call_api_proxy(
+            'claim_search', display_id, page_params, f'page {page}')
+        for item in traverse_obj(result, ('items', lambda _, v: v['name'] and v['claim_id'])):
+            yield {
+                **self._parse_stream(item, url),
+                '_type': 'url',
+                'id': item['claim_id'],
+                'url': self._permanent_url(url, item['name'], item['claim_id']),
+            }
+
+    def _playlist_entries(self, url, display_id, claim_param, metadata):
+        qs = parse_qs(url)
+        content = qs.get('content', [None])[0]
+        params = {
+            'fee_amount': qs.get('fee_amount', ['>=0'])[0],
+            'order_by': {
+                'new': ['release_time'],
+                'top': ['effective_amount'],
+                'trending': ['trending_group', 'trending_mixed'],
+            }[qs.get('order', ['new'])[0]],
+            'claim_type': 'stream',
+            'stream_types': [content] if content in ['audio', 'video'] else self._SUPPORTED_STREAM_TYPES,
+            **claim_param,
+        }
+        duration = qs.get('duration', [None])[0]
+        if duration:
+            params['duration'] = {
+                'long': '>=1200',
+                'short': '<=240',
+            }[duration]
+        language = qs.get('language', ['all'])[0]
+        if language != 'all':
+            languages = [language]
+            if language == 'en':
+                languages.append('none')
+            params['any_languages'] = languages
+
+        entries = OnDemandPagedList(
+            functools.partial(self._fetch_page, display_id, url, params),
+            self._PAGE_SIZE)
+
+        return self.playlist_result(
+            entries, display_id, **traverse_obj(metadata, ('value', {
+                'title': 'title',
+                'description': 'description',
+            })))
+
 
 class LBRYIE(LBRYBaseIE):
     IE_NAME = 'lbry'
-    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + r'(?P<id>\$/[^/]+/[^/]+/{1}|@{0}/{0}|(?!@){0})'.format(LBRYBaseIE._OPT_CLAIM_ID, LBRYBaseIE._CLAIM_ID_REGEX)
+    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + rf'''
+        (?:\$/(?:download|embed)/)?
+        (?P<id>
+            [^$@:/?#]+/{LBRYBaseIE._CLAIM_ID_REGEX}
+            |(?:@{LBRYBaseIE._OPT_CLAIM_ID}/)?{LBRYBaseIE._OPT_CLAIM_ID}
+        )'''
     _TESTS = [{
         # Video
         'url': 'https://lbry.tv/@Mantega:1/First-day-LBRY:1',
@@ -149,7 +210,7 @@ class LBRYIE(LBRYBaseIE):
             'channel': 'Gardening In Canada',
             'channel_id': 'b8be0e93b423dad221abe29545fbe8ec36e806bc',
             'channel_url': 'https://odysee.com/@gardeningincanada:b8be0e93b423dad221abe29545fbe8ec36e806bc',
-            'formats': 'mincount:3',
+            'formats': 'mincount:3',  # FIXME
             'thumbnail': 'https://thumbnails.lbry.com/AgHSc_HzrrE',
             'license': 'Copyrighted (contact publisher)',
         }
@@ -184,12 +245,12 @@ class LBRYIE(LBRYBaseIE):
             'id': '41fbfe805eb73c8d3012c0c49faa0f563274f634',
             'ext': 'mp4',
             'title': 'Biotechnological Invasion of Skin (April 2023)',
-            'description': 'md5:709a2f4c07bd8891cda3a7cc2d6fcf5c',
+            'description': 'md5:fe28689db2cb7ba3436d819ac3ffc378',
             'channel': 'Wicked Truths',
             'channel_id': '23d2bbf856b0ceed5b1d7c5960bcc72da5a20cb0',
             'channel_url': 'https://odysee.com/@wickedtruths:23d2bbf856b0ceed5b1d7c5960bcc72da5a20cb0',
-            'timestamp': 1685790036,
-            'upload_date': '20230603',
+            'timestamp': 1695114347,
+            'upload_date': '20230919',
             'release_timestamp': 1685617473,
             'release_date': '20230601',
             'duration': 1063,
@@ -229,10 +290,10 @@ class LBRYIE(LBRYBaseIE):
 
     def _real_extract(self, url):
         display_id = self._match_id(url)
-        if display_id.startswith('$/'):
-            display_id = display_id.split('/', 2)[-1].replace('/', ':')
-        else:
+        if display_id.startswith('@'):
             display_id = display_id.replace(':', '#')
+        else:
+            display_id = display_id.replace('/', ':')
         display_id = urllib.parse.unquote(display_id)
         uri = 'lbry://' + display_id
         result = self._resolve_url(uri, display_id, 'stream')
@@ -299,7 +360,7 @@ def _real_extract(self, url):
 
 class LBRYChannelIE(LBRYBaseIE):
     IE_NAME = 'lbry:channel'
-    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + r'(?P<id>@%s)/?(?:[?&]|$)' % LBRYBaseIE._OPT_CLAIM_ID
+    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + rf'(?P<id>@{LBRYBaseIE._OPT_CLAIM_ID})/?(?:[?&]|$)'
     _TESTS = [{
         'url': 'https://lbry.tv/@LBRYFoundation:0',
         'info_dict': {
@@ -315,65 +376,50 @@ class LBRYChannelIE(LBRYBaseIE):
         'url': 'lbry://@lbry#3f',
         'only_matching': True,
     }]
-    _PAGE_SIZE = 50
-
-    def _fetch_page(self, claim_id, url, params, page):
-        page += 1
-        page_params = {
-            'channel_ids': [claim_id],
-            'claim_type': 'stream',
-            'no_totals': True,
-            'page': page,
-            'page_size': self._PAGE_SIZE,
-        }
-        page_params.update(params)
-        result = self._call_api_proxy(
-            'claim_search', claim_id, page_params, 'page %d' % page)
-        for item in (result.get('items') or []):
-            stream_claim_name = item.get('name')
-            stream_claim_id = item.get('claim_id')
-            if not (stream_claim_name and stream_claim_id):
-                continue
-
-            yield {
-                **self._parse_stream(item, url),
-                '_type': 'url',
-                'id': stream_claim_id,
-                'url': self._permanent_url(url, stream_claim_name, stream_claim_id),
-            }
 
     def _real_extract(self, url):
         display_id = self._match_id(url).replace(':', '#')
-        result = self._resolve_url(
-            'lbry://' + display_id, display_id, 'channel')
+        result = self._resolve_url(f'lbry://{display_id}', display_id, 'channel')
         claim_id = result['claim_id']
-        qs = parse_qs(url)
-        content = qs.get('content', [None])[0]
-        params = {
-            'fee_amount': qs.get('fee_amount', ['>=0'])[0],
-            'order_by': {
-                'new': ['release_time'],
-                'top': ['effective_amount'],
-                'trending': ['trending_group', 'trending_mixed'],
-            }[qs.get('order', ['new'])[0]],
-            'stream_types': [content] if content in ['audio', 'video'] else self._SUPPORTED_STREAM_TYPES,
-        }
-        duration = qs.get('duration', [None])[0]
-        if duration:
-            params['duration'] = {
-                'long': '>=1200',
-                'short': '<=240',
-            }[duration]
-        language = qs.get('language', ['all'])[0]
-        if language != 'all':
-            languages = [language]
-            if language == 'en':
-                languages.append('none')
-            params['any_languages'] = languages
-        entries = OnDemandPagedList(
-            functools.partial(self._fetch_page, claim_id, url, params),
-            self._PAGE_SIZE)
-        result_value = result.get('value') or {}
-        return self.playlist_result(
-            entries, claim_id, result_value.get('title'),
-            result_value.get('description'))
+
+        return self._playlist_entries(url, claim_id, {'channel_ids': [claim_id]}, result)
+
+
+class LBRYPlaylistIE(LBRYBaseIE):
+    IE_NAME = 'lbry:playlist'
+    _VALID_URL = LBRYBaseIE._BASE_URL_REGEX + r'\$/(?:play)?list/(?P<id>[0-9a-f-]+)'
+    _TESTS = [{
+        'url': 'https://odysee.com/$/playlist/ffef782f27486f0ac138bde8777f72ebdd0548c2',
+        'info_dict': {
+            'id': 'ffef782f27486f0ac138bde8777f72ebdd0548c2',
+            'title': 'Théâtre Classique',
+            'description': 'Théâtre Classique',
+        },
+        'playlist_mincount': 4,
+    }, {
+        'url': 'https://odysee.com/$/list/9c6658b3dd21e4f2a0602d523a13150e2b48b770',
+        'info_dict': {
+            'id': '9c6658b3dd21e4f2a0602d523a13150e2b48b770',
+            'title': 'Social Media Exposed',
+            'description': 'md5:98af97317aacd5b85d595775ea37d80e',
+        },
+        'playlist_mincount': 34,
+    }, {
+        'url': 'https://odysee.com/$/playlist/938fb11d-215f-4d1c-ad64-723954df2184',
+        'info_dict': {
+            'id': '938fb11d-215f-4d1c-ad64-723954df2184',
+        },
+        'playlist_mincount': 1000,
+    }]
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        result = traverse_obj(self._call_api_proxy('claim_search', display_id, {
+            'claim_ids': [display_id],
+            'no_totals': True,
+            'page': 1,
+            'page_size': self._PAGE_SIZE,
+        }, 'playlist'), ('items', 0))
+        claim_param = {'claim_ids': traverse_obj(result, ('value', 'claims', ..., {str}))}
+
+        return self._playlist_entries(url, display_id, claim_param, result)


Test patch: