Repository: yt-dlp/yt-dlp

Issue:
Amazon MiniTV (https://www.amazon.in/minitv)
### Checklist

- [X] I'm reporting a new site support request
- [X] I've verified that I'm running yt-dlp version **2022.04.08** ([update instructions](https://github.com/yt-dlp/yt-dlp#update)) or later (specify commit)
- [X] I've checked that all provided URLs are alive and playable in a browser
- [X] I've checked that none of provided URLs [violate any copyrights](https://github.com/ytdl-org/youtube-dl#can-you-add-support-for-this-anime-video-site-or-site-which-shows-current-movies-for-free) 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

India

### Example URLs

- Single episode: https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef

### Description

Free OTT service by Jeff uncle. 
Not related to Amazon Prime.
100% DRM free!

### Verbose log

```shell
yt-dlp -vU https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef
[debug] Command-line config: ['-vU', 'https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef']
[debug] Encodings: locale UTF-8, fs utf-8, out utf-8, err utf-8, pref UTF-8
[debug] yt-dlp version 2022.04.08 [7884ade65]
[debug] Python version 3.9.5 (CPython 64bit) - Linux-5.11.0-49-generic-x86_64-with-glibc2.33
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: avconv -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] Checking exe version: avprobe -bsfs
[debug] exe versions: none
[debug] Optional libraries: brotli, certifi, Cryptodome, secretstorage, mutagen, sqlite, websockets
[debug] Proxy map: {}
Latest version: 2022.04.08, Current version: 2022.04.08
yt-dlp is up to date (2022.04.08)
[debug] [generic] Extracting URL: https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef
[generic] 74271cb6-e19e-445b-a648-e15a3912b8ef: Requesting header
WARNING: [generic] Could not send HEAD request to https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef: HTTP Error 405: Method Not Allowed
[generic] 74271cb6-e19e-445b-a648-e15a3912b8ef: Downloading webpage
WARNING: [generic] Falling back on generic information extractor.
[generic] 74271cb6-e19e-445b-a648-e15a3912b8ef: Extracting information
[debug] Looking for video embeds
ERROR: Unsupported URL: https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef
Traceback (most recent call last):
  File "/home/z8512/.local/lib/python3.9/site-packages/yt_dlp/YoutubeDL.py", line 1408, in wrapper
    return func(self, *args, **kwargs)
  File "/home/z8512/.local/lib/python3.9/site-packages/yt_dlp/YoutubeDL.py", line 1478, in __extract_info
    ie_result = ie.extract(url)
  File "/home/z8512/.local/lib/python3.9/site-packages/yt_dlp/extractor/common.py", line 641, in extract
    ie_result = self._real_extract(url)
  File "/home/z8512/.local/lib/python3.9/site-packages/yt_dlp/extractor/generic.py", line 4039, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.amazon.in/minitv/tp/74271cb6-e19e-445b-a648-e15a3912b8ef
```



Discussion:
The URL only opens in the app? If so, it is likely not possible to support
Assuming OP knows what he is saying and it is indeed DRM free. Then app/mobile based or not doesn't matter.
I'd say writing extractors for mobile only sites is way more fun. Ofc i can't/won't do this but still issue is valid (again assuming it is indeed DRM free)
The issue is if the app is using any private keys to sign the requests. Unlike a webpage where the code is visible to the public, extracting private keys from apps can be considered a DMCA violation. This is why I said "it is likely not possible to support". I did not mean that the site request is invalid
It appears to work with a mobile user agent.
Is there any manifest to confirm that it's DRM free? Or on which point you're saying it's DRM free
As shirt discovered, you can play it in browser (without DRM support) with a User-Agent switcher
Indeed, it's playable on mobile web with just an Indian VPN and no login, and the manifests seem to be DRM-free:

https://d3s0ximcru5bdx.cloudfront.net/ruskmedia-series3-firston-s3e1-1640783520196/hls/ruskmedia-series3-firston-s3e1.m3u8

https://d3s0ximcru5bdx.cloudfront.net/ruskmedia-series3-firston-s3e1-1640783520196/dash/ruskmedia-series3-firston-s3e1.mpd
yeah, saw non-drm now, I remember seeing these shows in primevideo with same ASINs and encrypted. (could be glitch in my mind)
It's possible, since the bitrate on Mini TV seems to be like half or less compared to Amazon Prime Video.
and as usual CBR, CVBR and AVC, HEVC ... all at S/W level security (L3 to be specific)
> The URL only opens in the app? If so, it is likely not possible to support

No no 
> It appears to work with a mobile user agent.

It does. I tested with curl
Please add minitv asap 🙏

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
@@ -87,6 +87,11 @@
 )
 from .amcnetworks import AMCNetworksIE
 from .amazon import AmazonStoreIE
+from .amazonminitv import (
+    AmazonMiniTVIE,
+    AmazonMiniTVSeasonIE,
+    AmazonMiniTVSeriesIE,
+)
 from .americastestkitchen import (
     AmericasTestKitchenIE,
     AmericasTestKitchenSeasonIE,
diff --git a/yt_dlp/extractor/amazonminitv.py b/yt_dlp/extractor/amazonminitv.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/amazonminitv.py
@@ -0,0 +1,322 @@
+import json
+
+from .common import InfoExtractor
+from ..utils import ExtractorError, int_or_none, traverse_obj, try_get
+
+
+class AmazonMiniTVIE(InfoExtractor):
+    _VALID_URL = r'(?:https?://(?:www\.)?amazon\.in/minitv/tp/|amazonminitv:(?:amzn1\.dv\.gti\.)?)(?P<id>[a-f0-9-]+)'
+    _HEADERS = {
+        'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36',
+    }
+    _CLIENT_ID = 'ATVIN'
+    _DEVICE_LOCALE = 'en_GB'
+    _TESTS = [{
+        'url': 'https://www.amazon.in/minitv/tp/75fe3a75-b8fe-4499-8100-5c9424344840?referrer=https%3A%2F%2Fwww.amazon.in%2Fminitv',
+        'md5': '0045a5ea38dddd4de5a5fcec7274b476',
+        'info_dict': {
+            'id': 'amzn1.dv.gti.75fe3a75-b8fe-4499-8100-5c9424344840',
+            'ext': 'mp4',
+            'title': 'May I Kiss You?',
+            'language': 'Hindi',
+            'thumbnail': r're:^https?://.*\.jpg$',
+            'description': 'md5:a549bfc747973e04feb707833474e59d',
+            'release_timestamp': 1644710400,
+            'release_date': '20220213',
+            'duration': 846,
+            'chapters': [{
+                'start_time': 815.0,
+                'end_time': 846,
+                'title': 'End Credits',
+            }],
+            'series': 'Couple Goals',
+            'series_id': 'amzn1.dv.gti.56521d46-b040-4fd5-872e-3e70476a04b0',
+            'season': 'Season 3',
+            'season_number': 3,
+            'season_id': 'amzn1.dv.gti.20331016-d9b9-4968-b991-c89fa4927a36',
+            'episode': 'May I Kiss You?',
+            'episode_number': 2,
+            'episode_id': 'amzn1.dv.gti.75fe3a75-b8fe-4499-8100-5c9424344840',
+        },
+    }, {
+        'url': 'https://www.amazon.in/minitv/tp/280d2564-584f-452f-9c98-7baf906e01ab?referrer=https%3A%2F%2Fwww.amazon.in%2Fminitv',
+        'md5': '9a977bffd5d99c4dd2a32b360aee1863',
+        'info_dict': {
+            'id': 'amzn1.dv.gti.280d2564-584f-452f-9c98-7baf906e01ab',
+            'ext': 'mp4',
+            'title': 'Jahaan',
+            'language': 'Hindi',
+            'thumbnail': r're:^https?://.*\.jpg',
+            'description': 'md5:05eb765a77bf703f322f120ec6867339',
+            'release_timestamp': 1647475200,
+            'release_date': '20220317',
+            'duration': 783,
+            'chapters': [],
+        },
+    }, {
+        'url': 'https://www.amazon.in/minitv/tp/280d2564-584f-452f-9c98-7baf906e01ab',
+        'only_matching': True,
+    }, {
+        'url': 'amazonminitv:amzn1.dv.gti.280d2564-584f-452f-9c98-7baf906e01ab',
+        'only_matching': True,
+    }, {
+        'url': 'amazonminitv:280d2564-584f-452f-9c98-7baf906e01ab',
+        'only_matching': True,
+    }]
+    _GRAPHQL_QUERY_CONTENT = '''
+query content($sessionIdToken: String!, $deviceLocale: String, $contentId: ID!, $contentType: ContentType!, $clientId: String) {
+  content(
+    applicationContextInput: {deviceLocale: $deviceLocale, sessionIdToken: $sessionIdToken, clientId: $clientId}
+    contentId: $contentId
+    contentType: $contentType
+  ) {
+    contentId
+    name
+    ... on Episode {
+      contentId
+      vodType
+      name
+      images
+      description {
+        synopsis
+        contentLengthInSeconds
+      }
+      publicReleaseDateUTC
+      audioTracks
+      seasonId
+      seriesId
+      seriesName
+      seasonNumber
+      episodeNumber
+      timecode {
+        endCreditsTime
+      }
+    }
+    ... on MovieContent {
+      contentId
+      vodType
+      name
+      description {
+        synopsis
+        contentLengthInSeconds
+      }
+      images
+      publicReleaseDateUTC
+      audioTracks
+    }
+  }
+}'''
+
+    def _call_api(self, asin, data=None, note=None):
+        query = {}
+        headers = self._HEADERS.copy()
+        if data:
+            name = 'graphql'
+            data['variables'].update({
+                'clientId': self._CLIENT_ID,
+                'contentType': 'VOD',
+                'deviceLocale': self._DEVICE_LOCALE,
+                'sessionIdToken': self.session_id,
+            })
+            headers.update({'Content-Type': 'application/json'})
+        else:
+            name = 'prs'
+            query.update({
+                'clientId': self._CLIENT_ID,
+                'deviceType': 'A1WMMUXPCUJL4N',
+                'contentId': asin,
+                'deviceLocale': self._DEVICE_LOCALE,
+            })
+
+        resp = self._download_json(
+            f'https://www.amazon.in/minitv/api/web/{name}',
+            asin, query=query, data=json.dumps(data).encode() if data else None,
+            headers=headers, note=note)
+
+        if 'errors' in resp:
+            raise ExtractorError(f'MiniTV said: {resp["errors"][0]["message"]}')
+
+        if data:
+            resp = resp['data'][data['operationName']]
+        return resp
+
+    def _real_initialize(self):
+        # Download webpage to get the required guest session cookies
+        self._download_webpage(
+            'https://www.amazon.in/minitv',
+            None,
+            headers=self._HEADERS,
+            note='Downloading webpage')
+
+        self.session_id = self._get_cookies('https://www.amazon.in')['session-id'].value
+
+    def _real_extract(self, url):
+        asin = f'amzn1.dv.gti.{self._match_id(url)}'
+
+        title_info = self._call_api(
+            asin, data={
+                'operationName': 'content',
+                'variables': {
+                    'contentId': asin,
+                },
+                'query': self._GRAPHQL_QUERY_CONTENT,
+            },
+            note='Downloading title info')
+
+        prs = self._call_api(asin, note='Downloading playback info')
+
+        formats = []
+        subtitles = {}
+        for type_, asset in prs['playbackAssets'].items():
+            if not isinstance(asset, dict):
+                continue
+            if type_ == 'hls':
+                m3u8_fmts, m3u8_subs = self._extract_m3u8_formats_and_subtitles(
+                    asset['manifestUrl'], asin, ext='mp4', entry_protocol='m3u8_native',
+                    m3u8_id=type_, fatal=False)
+                formats.extend(m3u8_fmts)
+                subtitles = self._merge_subtitles(subtitles, m3u8_subs)
+            elif type_ == 'dash':
+                mpd_fmts, mpd_subs = self._extract_mpd_formats_and_subtitles(
+                    asset['manifestUrl'], asin, mpd_id=type_, fatal=False)
+                formats.extend(mpd_fmts)
+                subtitles = self._merge_subtitles(subtitles, mpd_subs)
+
+        duration = traverse_obj(title_info, ('description', 'contentLengthInSeconds'))
+        credits_time = try_get(title_info, lambda x: x['timecode']['endCreditsTime'] / 1000)
+        chapters = [{
+            'start_time': credits_time,
+            'end_time': duration + credits_time,  # FIXME: I suppose this is correct
+            'title': 'End Credits',
+        }] if credits_time and duration else []
+        is_episode = title_info.get('vodType') == 'EPISODE'
+
+        return {
+            'id': asin,
+            'title': title_info.get('name'),
+            'formats': formats,
+            'subtitles': subtitles,
+            'language': traverse_obj(title_info, ('audioTracks', 0)),
+            'thumbnails': [{
+                'id': type_,
+                'url': url,
+            } for type_, url in (title_info.get('images') or {}).items()],
+            'description': traverse_obj(title_info, ('description', 'synopsis')),
+            'release_timestamp': int_or_none(try_get(title_info, lambda x: x['publicReleaseDateUTC'] / 1000)),
+            'duration': duration,
+            'chapters': chapters,
+            'series': title_info.get('seriesName'),
+            'series_id': title_info.get('seriesId'),
+            'season_number': title_info.get('seasonNumber'),
+            'season_id': title_info.get('seasonId'),
+            'episode': title_info.get('name') if is_episode else None,
+            'episode_number': title_info.get('episodeNumber'),
+            'episode_id': asin if is_episode else None,
+        }
+
+
+class AmazonMiniTVSeasonIE(AmazonMiniTVIE):
+    IE_NAME = 'amazonminitv:season'
+    _VALID_URL = r'amazonminitv:season:(?:amzn1\.dv\.gti\.)?(?P<id>[a-f0-9-]+)'
+    IE_DESC = 'Amazon MiniTV Series, "minitv:season:" prefix'
+    _TESTS = [{
+        'url': 'amazonminitv:season:amzn1.dv.gti.0aa996eb-6a1b-4886-a342-387fbd2f1db0',
+        'playlist_mincount': 6,
+        'info_dict': {
+            'id': 'amzn1.dv.gti.0aa996eb-6a1b-4886-a342-387fbd2f1db0',
+        },
+    }, {
+        'url': 'amazonminitv:season:0aa996eb-6a1b-4886-a342-387fbd2f1db0',
+        'only_matching': True,
+    }]
+    _GRAPHQL_QUERY = '''
+query getEpisodes($sessionIdToken: String!, $clientId: String, $episodeOrSeasonId: ID!, $deviceLocale: String) {
+  getEpisodes(
+    applicationContextInput: {sessionIdToken: $sessionIdToken, deviceLocale: $deviceLocale, clientId: $clientId}
+    episodeOrSeasonId: $episodeOrSeasonId
+  ) {
+    episodes {
+      ... on Episode {
+        contentId
+        name
+        images
+        seriesName
+        seasonId
+        seriesId
+        seasonNumber
+        episodeNumber
+        description {
+          synopsis
+          contentLengthInSeconds
+        }
+        publicReleaseDateUTC
+      }
+    }
+  }
+}
+'''
+
+    def _entries(self, asin):
+        season_info = self._call_api(
+            asin,
+            data={
+                'operationName': 'getEpisodes',
+                'variables': {
+                    'episodeOrSeasonId': asin,
+                },
+                'query': self._GRAPHQL_QUERY,
+            },
+            note='Downloading season info')
+
+        for episode in season_info['episodes']:
+            yield self.url_result(f'amazonminitv:{episode["contentId"]}', AmazonMiniTVIE, episode['contentId'])
+
+    def _real_extract(self, url):
+        asin = f'amzn1.dv.gti.{self._match_id(url)}'
+        return self.playlist_result(self._entries(asin), playlist_id=asin)
+
+
+class AmazonMiniTVSeriesIE(AmazonMiniTVIE):
+    IE_NAME = 'amazonminitv:series'
+    _VALID_URL = r'amazonminitv:series:(?:amzn1\.dv\.gti\.)?(?P<id>[a-f0-9-]+)'
+    _TESTS = [{
+        'url': 'amazonminitv:series:amzn1.dv.gti.56521d46-b040-4fd5-872e-3e70476a04b0',
+        'playlist_mincount': 3,
+        'info_dict': {
+            'id': 'amzn1.dv.gti.56521d46-b040-4fd5-872e-3e70476a04b0',
+        },
+    }, {
+        'url': 'amazonminitv:series:56521d46-b040-4fd5-872e-3e70476a04b0',
+        'only_matching': True,
+    }]
+    _GRAPHQL_QUERY = '''
+query getSeasons($sessionIdToken: String!, $deviceLocale: String, $episodeOrSeasonOrSeriesId: ID!, $clientId: String) {
+  getSeasons(
+    applicationContextInput: {deviceLocale: $deviceLocale, sessionIdToken: $sessionIdToken, clientId: $clientId}
+    episodeOrSeasonOrSeriesId: $episodeOrSeasonOrSeriesId
+  ) {
+    seasons {
+      seasonId
+    }
+  }
+}
+'''
+
+    def _entries(self, asin):
+        season_info = self._call_api(
+            asin,
+            data={
+                'operationName': 'getSeasons',
+                'variables': {
+                    'episodeOrSeasonOrSeriesId': asin,
+                },
+                'query': self._GRAPHQL_QUERY,
+            },
+            note='Downloading series info')
+
+        for season in season_info['seasons']:
+            yield self.url_result(f'amazonminitv:season:{season["seasonId"]}', AmazonMiniTVSeasonIE, season['seasonId'])
+
+    def _real_extract(self, url):
+        asin = f'amzn1.dv.gti.{self._match_id(url)}'
+        return self.playlist_result(self._entries(asin), playlist_id=asin)


Test patch: