Repository: yt-dlp/yt-dlp

Issue:
[air.tv] Add site support
### 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.09.01** ([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

world

### Example URLs

https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ

https://embed.air.tv/v1/iframe/W87jcWleSn2hXZN47zJZsQ

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

This site looks both simple (to obtain links to media) and tricky (to get all available resolutions). While they host their own content, some videos (in addition) link to content hosted on other sites. For example, the source code for the first URL above also links to a Grabien file (the source notes the files that also appear on YouTube, but does not link to them):

```
{"type":"application/x-mpegURL","src":"https://cdn-sp-gcs.air.tv/videos/W/8/W87jcWleSn2hXZN47zJZsQ/4203fb11c1020248fd78b252f136e39a-1280x720.m3u8"},
{"type":"video/mp4","src":"https://cdn-sp-gcs.air.tv/videos/W/8/W87jcWleSn2hXZN47zJZsQ/7db6011ddd43ced9559963e47c4213ec-1280x720.mp4"},
{"type":"video/mp4","src":"https://media.grabien.com/c/streams/1670/_ofxx_bNb0zWU4UIahzrbQ/1664835802/1670523.mp4"},
{"type":"video/mp4","src":"https://cdn-sp-gcs.air.tv/videos/W/8/W87jcWleSn2hXZN47zJZsQ/9795000a-e7c1-45cf-834f-f9dbd7553624"}

],"sources_desktop":[

{"type":"application/x-mpegURL","src":"https://api.air.tv/v1/portal/hls/W87jcWleSn2hXZN47zJZsQ"},
{"type":"video/mp4","src":"https://cdn-sp-gcs.air.tv/videos/W/8/W87jcWleSn2hXZN47zJZsQ/7db6011ddd43ced9559963e47c4213ec-1280x720.mp4"},
{"type":"video/mp4","src":"https://media.grabien.com/c/streams/1670/_ofxx_bNb0zWU4UIahzrbQ/1664835802/1670523.mp4"},
{"type":"video/mp4","src":"https://cdn-sp-gcs.air.tv/videos/W/8/W87jcWleSn2hXZN47zJZsQ/9795000a-e7c1-45cf-834f-f9dbd7553624"}
```

### 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
$ yt-dlp -v 'https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ'
[debug] Command-line config: ['--restrict-filenames', '-o', '%(title)s-%(id)s-%(uploader)s.%(ext)s', '-w', '-v', 'https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ']
[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.09.01 [5d7c7d6] (pip)
[debug] Python 3.9.13 (CPython 64bit) - Linux-5.15.41-with-glibc2.35 (glibc 2.35)
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: ffmpeg 4.4.2 (setts), ffprobe 4.4.2
[debug] Optional libraries: Crypto-3.15.0, certifi-3021.03.16
[debug] Proxy map: {}
[debug] Loaded 1670 extractors
[debug] [generic] Extracting URL: https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ
[generic] watch?v=W87jcWleSn2hXZN47zJZsQ: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] watch?v=W87jcWleSn2hXZN47zJZsQ: Extracting information
[debug] Looking for Brightcove embeds
[debug] Looking for embeds
ERROR: Unsupported URL: https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ
Traceback (most recent call last):
  File "/usr/lib/python3.9/site-packages/yt_dlp/YoutubeDL.py", line 1459, in wrapper
    return func(self, *args, **kwargs)
  File "/usr/lib/python3.9/site-packages/yt_dlp/YoutubeDL.py", line 1535, in __extract_info
    ie_result = ie.extract(url)
  File "/usr/lib/python3.9/site-packages/yt_dlp/extractor/common.py", line 670, in extract
    ie_result = self._real_extract(url)
  File "/usr/lib/python3.9/site-packages/yt_dlp/extractor/generic.py", line 3078, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ
```



Discussion:
related upstream issue: https://github.com/ytdl-org/youtube-dl/issues/22299

for devs:
streaming urls and other metadata is available under `__NEXT_DATA__` (use `_search_nextjs_data`)

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
@@ -78,6 +78,7 @@
     WyborczaVideoIE,
 )
 from .airmozilla import AirMozillaIE
+from .airtv import AirTVIE
 from .aljazeera import AlJazeeraIE
 from .alphaporno import AlphaPornoIE
 from .amara import AmaraIE
diff --git a/yt_dlp/extractor/airtv.py b/yt_dlp/extractor/airtv.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/airtv.py
@@ -0,0 +1,96 @@
+from .common import InfoExtractor
+from .youtube import YoutubeIE
+from ..utils import (
+    determine_ext,
+    int_or_none,
+    mimetype2ext,
+    parse_iso8601,
+    traverse_obj
+)
+
+
+class AirTVIE(InfoExtractor):
+    _VALID_URL = r'https?://www\.air\.tv/watch\?v=(?P<id>\w+)'
+    _TESTS = [{
+        # without youtube_id
+        'url': 'https://www.air.tv/watch?v=W87jcWleSn2hXZN47zJZsQ',
+        'info_dict': {
+            'id': 'W87jcWleSn2hXZN47zJZsQ',
+            'ext': 'mp4',
+            'release_date': '20221003',
+            'release_timestamp': 1664792603,
+            'channel_id': 'vgfManQlRQKgoFQ8i8peFQ',
+            'title': 'md5:c12d49ed367c3dadaa67659aff43494c',
+            'upload_date': '20221003',
+            'duration': 151,
+            'view_count': int,
+            'thumbnail': 'https://cdn-sp-gcs.air.tv/videos/W/8/W87jcWleSn2hXZN47zJZsQ/b13fc56464f47d9d62a36d110b9b5a72-4096x2160_9.jpg',
+            'timestamp': 1664792603,
+        }
+    }, {
+        # with youtube_id
+        'url': 'https://www.air.tv/watch?v=sv57EC8tRXG6h8dNXFUU1Q',
+        'info_dict': {
+            'id': '2ZTqmpee-bQ',
+            'ext': 'mp4',
+            'comment_count': int,
+            'tags': 'count:11',
+            'channel_follower_count': int,
+            'like_count': int,
+            'uploader': 'Newsflare',
+            'thumbnail': 'https://i.ytimg.com/vi_webp/2ZTqmpee-bQ/maxresdefault.webp',
+            'availability': 'public',
+            'title': 'Geese Chase Alligator Across Golf Course',
+            'uploader_id': 'NewsflareBreaking',
+            'channel_url': 'https://www.youtube.com/channel/UCzSSoloGEz10HALUAbYhngQ',
+            'description': 'md5:99b21d9cea59330149efbd9706e208f5',
+            'age_limit': 0,
+            'channel_id': 'UCzSSoloGEz10HALUAbYhngQ',
+            'uploader_url': 'http://www.youtube.com/user/NewsflareBreaking',
+            'view_count': int,
+            'categories': ['News & Politics'],
+            'live_status': 'not_live',
+            'playable_in_embed': True,
+            'channel': 'Newsflare',
+            'duration': 37,
+            'upload_date': '20180511',
+        }
+    }]
+
+    def _get_formats_and_subtitle(self, json_data, video_id):
+        formats, subtitles = [], {}
+        for source in traverse_obj(json_data, 'sources', 'sources_desktop', ...):
+            ext = determine_ext(source.get('src'), mimetype2ext(source.get('type')))
+            if ext == 'm3u8':
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(source.get('src'), video_id)
+                formats.extend(fmts)
+                self._merge_subtitles(subs, target=subtitles)
+            else:
+                formats.append({'url': source.get('src'), 'ext': ext})
+        return formats, subtitles
+
+    def _real_extract(self, url):
+        display_id = self._match_id(url)
+        webpage = self._download_webpage(url, display_id)
+
+        nextjs_json = self._search_nextjs_data(webpage, display_id)['props']['pageProps']['initialState']['videos'][display_id]
+        if nextjs_json.get('youtube_id'):
+            return self.url_result(
+                f'https://www.youtube.com/watch?v={nextjs_json.get("youtube_id")}', YoutubeIE)
+
+        formats, subtitles = self._get_formats_and_subtitle(nextjs_json, display_id)
+        return {
+            'id': display_id,
+            'title': nextjs_json.get('title') or self._html_search_meta('og:title', webpage),
+            'formats': formats,
+            'subtitles': subtitles,
+            'description': nextjs_json.get('description') or None,
+            'duration': int_or_none(nextjs_json.get('duration')),
+            'thumbnails': [
+                {'url': thumbnail}
+                for thumbnail in traverse_obj(nextjs_json, ('default_thumbnails', ...))],
+            'channel_id': traverse_obj(nextjs_json, 'channel', 'channel_slug'),
+            'timestamp': parse_iso8601(nextjs_json.get('created')),
+            'release_timestamp': parse_iso8601(nextjs_json.get('published')),
+            'view_count': int_or_none(nextjs_json.get('views')),
+        }


Test patch: