Repository: yt-dlp/yt-dlp

Issue:
Add support for Floatplane
### 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 new site support request
- [X] I've verified that I'm running yt-dlp version **2022.11.11** ([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 none of provided URLs [violate any copyrights](https://github.com/yt-dlp/yt-dlp/blob/master/CONTRIBUTING.md#is-the-website-primarily-used-for-piracy) or contain any [DRM](https://en.wikipedia.org/wiki/Digital_rights_management) to the best of my knowledge
- [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)
- [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 am willing to share it if required

### Region

Europe/Netherlands

### Example URLs

https://www.floatplane.com/channel/GarbageTime
https://www.floatplane.com/channel/GarbageTime/posts
https://www.floatplane.com/post/WtrwiYxJPM

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

Some notes:
* channel\[channel-name\] redirects to /channel/\[channel-name\]/home
* The front-end is developed in React, with most of the UI being created from data fetched from an API. 
  I imagine this makes retrieving the actual media URL's somewhat challenging. 

### 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
[debug] Command-line config: ['-vU', 'https://www.floatplane.com/post/WtrwiYxJPM']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.11.11 [8b64402] (pip)
[debug] Python 3.10.9 (CPython x86_64 64bit) - Linux-6.0.8-gentoo-x86_64-x86_64-AMD_Ryzen_7_3700X_8-Core_Processor-with-glibc2.36 (OpenSSL 1.1.1s  1 Nov 2022, glibc 2.36)
[debug] exe versions: ffmpeg 4.4.3 (fdk,setts), ffprobe 4.4.3, rtmpdump 2.4
[debug] Optional libraries: Crypto-3.16.0, brotli-1.0.9, certifi-3021.03.16, mutagen-1.46.0, secretstorage-3.3.3, sqlite3-2.6.0
[debug] Proxy map: {}
[debug] Loaded 1723 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Latest version: 2022.11.11, Current version: 2022.11.11
yt-dlp is up to date (2022.11.11)
[debug] [generic] Extracting URL: https://www.floatplane.com/post/WtrwiYxJPM
[generic] WtrwiYxJPM: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] WtrwiYxJPM: Extracting information
[debug] Looking for embeds
ERROR: Unsupported URL: https://www.floatplane.com/post/WtrwiYxJPM
Traceback (most recent call last):
  File "/usr/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1485, in wrapper
    return func(self, *args, **kwargs)
  File "/usr/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1561, in __extract_info
    ie_result = ie.extract(url)
  File "/usr/lib/python3.10/site-packages/yt_dlp/extractor/common.py", line 674, in extract
    ie_result = self._real_extract(url)
  File "/usr/lib/python3.10/site-packages/yt_dlp/extractor/generic.py", line 2721, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.floatplane.com/post/WtrwiYxJPM
```

[Floatplane] Add extractor
**IMPORTANT**: PRs without the template will be CLOSED

### Description of your *pull request* and other information

</details>

<!--

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

-->

This adds support (via a new extractor) for [Floatplane](https://www.floatplane.com) video posts.  It does _not_ add support to download/index entire channels.

Fixes #5877 


<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 one of the following options:
- [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*?
- [ ] Fix or improvement to an extractor (Make sure to add/update tests)
- [x] 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))



Discussion:
FYI @chtk I took a swing at this today.  Feel free to try it out in my branch.
@JakeShirley Just tested your PR. Amazing work. I would have really liked to have playlists as well.  But I can work with this. I might have a stab at playlist myself. I'm probably even more of a Python script kiddie, though. 
I am a total Python script kiddy, so sorry if there are any obvious botches :) 

Also, I am not sure how to test this.  I didn't add login support (only cookie-based support) and I couldn't find any good docs on how to run tests with browser sessions/cookies.

Any help is appreciated! 
To test extractors that require login information, create a file `test/local_parameters.json` and fill it with the params that you would want to test with. In this case use the `cookiesfrombrowser` parameter for loading cookies from the browser
> To test extractors that require login information, create a file `test/local_parameters.json` and fill it with the params that you would want to test with. In this case use the `cookiesfrombrowser` parameter for loading cookies from the browser

Does this method allow me to run my extractor tests (ex: `python test/test_download.py TestDownload.test_Floatplane`).  I can't seem to get the test run to pick up my local config.
> I can't seem to get the test run to pick up my local config.

What does your `local_parameters.json` look like?
`test\local_parameters.json`

```json
{
   "cookiesfrombrowser ": "firefox"
}
```

Test command: `python test/test_download.py TestDownload.test_Floatplane`
Change to
```diff
-  "cookiesfrombrowser ": "firefox"
+  "cookiesfrombrowser": ["firefox"]
```
Wow I can't believe how much a trailing space eluded me here :D 
@pukkandan Added a few tests!
@JakeShirley Do you have the time to work on these change requests? Would love to see Floatplane support in yt-dlp.
@JakeShirley would you be interested in any help if you don't have the time?
Since the PR is stale, anyone could fork, address reviews and make a new PR

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
@@ -642,6 +642,10 @@
 from .firsttv import FirstTVIE
 from .fivetv import FiveTVIE
 from .flickr import FlickrIE
+from .floatplane import (
+    FloatplaneIE,
+    FloatplaneChannelIE,
+)
 from .folketinget import FolketingetIE
 from .footyroom import FootyRoomIE
 from .formula1 import Formula1IE
diff --git a/yt_dlp/extractor/floatplane.py b/yt_dlp/extractor/floatplane.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/floatplane.py
@@ -0,0 +1,268 @@
+import functools
+
+from .common import InfoExtractor
+from ..utils import (
+    ExtractorError,
+    OnDemandPagedList,
+    clean_html,
+    determine_ext,
+    format_field,
+    int_or_none,
+    join_nonempty,
+    parse_codecs,
+    parse_iso8601,
+    urljoin,
+)
+from ..utils.traversal import traverse_obj
+
+
+class FloatplaneIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:(?:www|beta)\.)?floatplane\.com/post/(?P<id>\w+)'
+    _TESTS = [{
+        'url': 'https://www.floatplane.com/post/2Yf3UedF7C',
+        'info_dict': {
+            'id': 'yuleLogLTT',
+            'ext': 'mp4',
+            'display_id': '2Yf3UedF7C',
+            'title': '8K Yule Log Fireplace with Crackling Fire Sounds - 10 Hours',
+            'description': 'md5:adf2970e0de1c5e3df447818bb0309f6',
+            'thumbnail': r're:^https?://.*\.jpe?g$',
+            'duration': 36035,
+            'comment_count': int,
+            'like_count': int,
+            'dislike_count': int,
+            'release_date': '20191206',
+            'release_timestamp': 1575657000,
+            'uploader': 'LinusTechTips',
+            'uploader_id': '59f94c0bdd241b70349eb72b',
+            'uploader_url': 'https://www.floatplane.com/channel/linustechtips/home',
+            'channel': 'Linus Tech Tips',
+            'channel_id': '63fe42c309e691e4e36de93d',
+            'channel_url': 'https://www.floatplane.com/channel/linustechtips/home/main',
+            'availability': 'subscriber_only',
+        },
+        'params': {'skip_download': 'm3u8'},
+    }, {
+        'url': 'https://www.floatplane.com/post/j2jqG3JmgJ',
+        'info_dict': {
+            'id': 'j2jqG3JmgJ',
+            'title': 'TJM: Does Anyone Care About Avatar: The Way of Water?',
+            'description': 'md5:00bf17dc5733e4031e99b7fd6489f274',
+            'thumbnail': r're:^https?://.*\.jpe?g$',
+            'comment_count': int,
+            'like_count': int,
+            'dislike_count': int,
+            'release_timestamp': 1671915900,
+            'release_date': '20221224',
+            'uploader': 'LinusTechTips',
+            'uploader_id': '59f94c0bdd241b70349eb72b',
+            'uploader_url': 'https://www.floatplane.com/channel/linustechtips/home',
+            'channel': "They're Just Movies",
+            'channel_id': '64135f82fc76ab7f9fbdc876',
+            'channel_url': 'https://www.floatplane.com/channel/linustechtips/home/tajm',
+            'availability': 'subscriber_only',
+        },
+        'playlist_count': 2,
+    }, {
+        'url': 'https://www.floatplane.com/post/3tK2tInhoN',
+        'info_dict': {
+            'id': '3tK2tInhoN',
+            'title': 'Extras - How Linus Communicates with Editors (Compensator 4)',
+            'description': 'md5:83cd40aae1ce124df33769600c80ca5b',
+            'thumbnail': r're:^https?://.*\.jpe?g$',
+            'comment_count': int,
+            'like_count': int,
+            'dislike_count': int,
+            'release_timestamp': 1700529120,
+            'release_date': '20231121',
+            'uploader': 'LinusTechTips',
+            'uploader_id': '59f94c0bdd241b70349eb72b',
+            'uploader_url': 'https://www.floatplane.com/channel/linustechtips/home',
+            'channel': 'FP Exclusives',
+            'channel_id': '6413623f5b12cca228a28e78',
+            'channel_url': 'https://www.floatplane.com/channel/linustechtips/home/fpexclusive',
+            'availability': 'subscriber_only',
+        },
+        'playlist_count': 2,
+    }, {
+        'url': 'https://beta.floatplane.com/post/d870PEFXS1',
+        'info_dict': {
+            'id': 'bg9SuYKEww',
+            'ext': 'mp4',
+            'display_id': 'd870PEFXS1',
+            'title': 'LCS Drama, TLOU 2 Remaster, Destiny 2 Player Count Drops, + More!',
+            'description': 'md5:80d612dcabf41b17487afcbe303ec57d',
+            'thumbnail': r're:^https?://.*\.jpe?g$',
+            'release_timestamp': 1700622000,
+            'release_date': '20231122',
+            'duration': 513,
+            'like_count': int,
+            'dislike_count': int,
+            'comment_count': int,
+            'uploader': 'LinusTechTips',
+            'uploader_id': '59f94c0bdd241b70349eb72b',
+            'uploader_url': 'https://www.floatplane.com/channel/linustechtips/home',
+            'channel': 'GameLinked',
+            'channel_id': '649dbade3540dbc3945eeda7',
+            'channel_url': 'https://www.floatplane.com/channel/linustechtips/home/gamelinked',
+            'availability': 'subscriber_only',
+        },
+        'params': {'skip_download': 'm3u8'},
+    }]
+
+    def _real_initialize(self):
+        if not self._get_cookies('https://www.floatplane.com').get('sails.sid'):
+            self.raise_login_required()
+
+    def _real_extract(self, url):
+        post_id = self._match_id(url)
+
+        post_data = self._download_json(
+            'https://www.floatplane.com/api/v3/content/post', post_id, query={'id': post_id},
+            note='Downloading post data', errnote='Unable to download post data')
+
+        if not any(traverse_obj(post_data, ('metadata', ('hasVideo', 'hasAudio')))):
+            raise ExtractorError('Post does not contain a video or audio track', expected=True)
+
+        items = []
+        for media in traverse_obj(post_data, (('videoAttachments', 'audioAttachments'), ...)):
+            media_id = media['id']
+            media_typ = media.get('type') or 'video'
+
+            metadata = self._download_json(
+                f'https://www.floatplane.com/api/v3/content/{media_typ}', media_id, query={'id': media_id},
+                note=f'Downloading {media_typ} metadata')
+
+            stream = self._download_json(
+                'https://www.floatplane.com/api/v2/cdn/delivery', media_id, query={
+                    'type': 'vod' if media_typ == 'video' else 'aod',
+                    'guid': metadata['guid']
+                }, note=f'Downloading {media_typ} stream data')
+
+            path_template = traverse_obj(stream, ('resource', 'uri', {str}))
+
+            def format_path(params):
+                path = path_template
+                for i, val in (params or {}).items():
+                    path = path.replace(f'{{qualityLevelParams.{i}}}', val)
+                return path
+
+            formats = []
+            for quality in traverse_obj(stream, ('resource', 'data', 'qualityLevels', ...)):
+                url = urljoin(stream['cdn'], format_path(traverse_obj(
+                    stream, ('resource', 'data', 'qualityLevelParams', quality['name']))))
+                formats.append({
+                    **traverse_obj(quality, {
+                        'format_id': 'name',
+                        'format_note': 'label',
+                        'width': ('width', {int}),
+                        'height': ('height', {int}),
+                    }),
+                    **parse_codecs(quality.get('codecs')),
+                    'url': url,
+                    'ext': determine_ext(url.partition('/chunk.m3u8')[0], 'mp4'),
+                })
+
+            items.append({
+                'id': media_id,
+                **traverse_obj(metadata, {
+                    'title': 'title',
+                    'duration': ('duration', {int_or_none}),
+                    'thumbnail': ('thumbnail', 'path'),
+                }),
+                'formats': formats,
+            })
+
+        uploader_url = format_field(traverse_obj(
+            post_data, 'creator'), 'urlname', 'https://www.floatplane.com/channel/%s/home', default=None)
+        channel_url = urljoin(f'{uploader_url}/', traverse_obj(post_data, ('channel', 'urlname')))
+
+        post_info = {
+            'id': post_id,
+            'display_id': post_id,
+            **traverse_obj(post_data, {
+                'title': 'title',
+                'description': ('text', {clean_html}),
+                'uploader': ('creator', 'title'),
+                'uploader_id': ('creator', 'id'),
+                'channel': ('channel', 'title'),
+                'channel_id': ('channel', 'id'),
+                'like_count': ('likes', {int_or_none}),
+                'dislike_count': ('dislikes', {int_or_none}),
+                'comment_count': ('comments', {int_or_none}),
+                'release_timestamp': ('releaseDate', {parse_iso8601}),
+                'thumbnail': ('thumbnail', 'path'),
+            }),
+            'uploader_url': uploader_url,
+            'channel_url': channel_url,
+            'availability': self._availability(needs_subscription=True),
+        }
+
+        if len(items) > 1:
+            return self.playlist_result(items, **post_info)
+
+        post_info.update(items[0])
+        return post_info
+
+
+class FloatplaneChannelIE(InfoExtractor):
+    _VALID_URL = r'https?://(?:(?:www|beta)\.)?floatplane\.com/channel/(?P<id>[\w-]+)/home(?:/(?P<channel>[\w-]+))?'
+    _PAGE_SIZE = 20
+    _TESTS = [{
+        'url': 'https://www.floatplane.com/channel/linustechtips/home/ltxexpo',
+        'info_dict': {
+            'id': 'linustechtips/ltxexpo',
+            'title': 'LTX Expo',
+            'description': 'md5:9819002f9ebe7fd7c75a3a1d38a59149',
+        },
+        'playlist_mincount': 51,
+    }, {
+        'url': 'https://www.floatplane.com/channel/ShankMods/home',
+        'info_dict': {
+            'id': 'ShankMods',
+            'title': 'Shank Mods',
+            'description': 'md5:6dff1bb07cad8e5448e04daad9be1b30',
+        },
+        'playlist_mincount': 14,
+    }, {
+        'url': 'https://beta.floatplane.com/channel/bitwit_ultra/home',
+        'info_dict': {
+            'id': 'bitwit_ultra',
+            'title': 'Bitwit Ultra',
+            'description': 'md5:1452f280bb45962976d4789200f676dd',
+        },
+        'playlist_mincount': 200,
+    }]
+
+    def _fetch_page(self, display_id, creator_id, channel_id, page):
+        query = {
+            'id': creator_id,
+            'limit': self._PAGE_SIZE,
+            'fetchAfter': page * self._PAGE_SIZE,
+        }
+        if channel_id:
+            query['channel'] = channel_id
+        page_data = self._download_json(
+            'https://www.floatplane.com/api/v3/content/creator', display_id,
+            query=query, note=f'Downloading page {page + 1}')
+        for post in page_data or []:
+            yield self.url_result(
+                f'https://www.floatplane.com/post/{post["id"]}',
+                ie=FloatplaneIE, video_id=post['id'], video_title=post.get('title'),
+                release_timestamp=parse_iso8601(post.get('releaseDate')))
+
+    def _real_extract(self, url):
+        creator, channel = self._match_valid_url(url).group('id', 'channel')
+        display_id = join_nonempty(creator, channel, delim='/')
+
+        creator_data = self._download_json(
+            'https://www.floatplane.com/api/v3/creator/named',
+            display_id, query={'creatorURL[0]': creator})[0]
+
+        channel_data = traverse_obj(
+            creator_data, ('channels', lambda _, v: v['urlname'] == channel), get_all=False) or {}
+
+        return self.playlist_result(OnDemandPagedList(functools.partial(
+            self._fetch_page, display_id, creator_data['id'], channel_data.get('id')), self._PAGE_SIZE),
+            display_id, playlist_title=channel_data.get('title') or creator_data.get('title'),
+            playlist_description=channel_data.get('about') or creator_data.get('about'))


Test patch: