Repository: yt-dlp/yt-dlp

Issue:
Please add support for https://www.ondemandchina.com/.
### 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

United States

### Example URLs

https://www.ondemandchina.com/en/watch/nirvana-in-fire/ep-1

### Description

https://www.ondemandchina.com/ is a streaming website specialising in mainly Chinese language content. At the moment of requesting support for the website, an account is not required to access any content. I hope that you can add support for the website in the very near future.

### Verbose log

```shell
[debug] Command-line config: ['--format', '(bestvideo[vcodec^=av01][height>=4320][fps>30]/bestvideo[vcodec^=vp9.2][height>=4320][fps>30]/bestvideo[vcodec^=vp9][height>=4320][fps>30]/bestvideo[vcodec^=avc1][height>=4320][fps>30]/bestvideo[height>=4320][fps>30]/bestvideo[vcodec^=av01][height>=4320]/bestvideo[vcodec^=vp9.2][height>=4320]/bestvideo[vcodec^=vp9][height>=4320]/bestvideo[vcodec^=avc1][height>=4320]/bestvideo[height>=4320]/bestvideo[vcodec^=av01][height>=2880][fps>30]/bestvideo[vcodec^=vp9.2][height>=2880][fps>30]/bestvideo[vcodec^=vp9][height>=2880][fps>30]/bestvideo[vcodec^=avc1][height>=2880][fps>30]/bestvideo[height>=2880][fps>30]/bestvideo[vcodec^=av01][height>=2880]/bestvideo[vcodec^=vp9.2][height>=2880]/bestvideo[vcodec^=vp9][height>=2880]/bestvideo[vcodec^=avc1][height>=2880]/bestvideo[height>=2880]/bestvideo[vcodec^=av01][height>=2160][fps>30]/bestvideo[vcodec^=vp9.2][height>=2160][fps>30]/bestvideo[vcodec^=vp9][height>=2160][fps>30]/bestvideo[vcodec^=avc1][height>=2160][fps>30]/bestvideo[height>=2160][fps>30]/bestvideo[vcodec^=av01][height>=2160]/bestvideo[vcodec^=vp9.2][height>=2160]/bestvideo[vcodec^=vp9][height>=2160]/bestvideo[vcodec^=avc1][height>=2160]/bestvideo[height>=2160]/bestvideo[vcodec^=av01][height>=1440][fps>30]/bestvideo[vcodec^=vp9.2][height>=1440][fps>30]/bestvideo[vcodec^=vp9][height>=1440][fps>30]/bestvideo[vcodec^=avc1][height>=1440][fps>30]/bestvideo[height>=1440][fps>30]/bestvideo[vcodec^=av01][height>=1440]/bestvideo[vcodec^=vp9.2][height>=1440]/bestvideo[vcodec^=vp9][height>=1440]/bestvideo[vcodec^=avc1][height>=1440]/bestvideo[height>=1440]/bestvideo[vcodec^=av01][height>=1080][fps>30]/bestvideo[vcodec^=vp9.2][height>=1080][fps>30]/bestvideo[vcodec^=vp9][height>=1080][fps>30]/bestvideo[vcodec^=avc1][height>=1080][fps>30]/bestvideo[height>=1080][fps>30]/bestvideo[vcodec^=av01][height>=1080]/bestvideo[vcodec^=vp9.2][height>=1080]/bestvideo[vcodec^=vp9][height>=1080]/bestvideo[vcodec^=avc1][height>=1080]/bestvideo[height>=1080]/bestvideo[vcodec^=av01][height>=720][fps>30]/bestvideo[vcodec^=vp9.2][height>=720][fps>30]/bestvideo[vcodec^=vp9][height>=720][fps>30]/bestvideo[vcodec^=avc1][height>=720][fps>30]/bestvideo[height>=720][fps>30]/bestvideo[vcodec^=av01][height>=720]/bestvideo[vcodec^=vp9.2][height>=720]/bestvideo[vcodec^=vp9][height>=720]/bestvideo[vcodec^=avc1][height>=720]/bestvideo[height>=720]/bestvideo[vcodec^=av01][height>=480][fps>30]/bestvideo[vcodec^=vp9.2][height>=480][fps>30]/bestvideo[vcodec^=vp9][height>=480][fps>30]/bestvideo[vcodec^=avc1][height>=480][fps>30]/bestvideo[height>=480][fps>30]/bestvideo[vcodec^=av01][height>=480]/bestvideo[vcodec^=vp9.2][height>=480]/bestvideo[vcodec^=vp9][height>=480]/bestvideo[vcodec^=avc1][height>=480]/bestvideo[height>=480]/bestvideo[vcodec^=av01][height>=360][fps>30]/bestvideo[vcodec^=vp9.2][height>=360][fps>30]/bestvideo[vcodec^=vp9][height>=360][fps>30]/bestvideo[vcodec^=avc1][height>=360][fps>30]/bestvideo[height>=360][fps>30]/bestvideo[vcodec^=av01][height>=360]/bestvideo[vcodec^=vp9.2][height>=360]/bestvideo[vcodec^=vp9][height>=360]/bestvideo[vcodec^=avc1][height>=360]/bestvideo[height>=360]/bestvideo[vcodec^=avc1][height>=240][fps>30]/bestvideo[vcodec^=av01][height>=240][fps>30]/bestvideo[vcodec^=vp9.2][height>=240][fps>30]/bestvideo[vcodec^=vp9][height>=240][fps>30]/bestvideo[height>=240][fps>30]/bestvideo[vcodec^=avc1][height>=240]/bestvideo[vcodec^=av01][height>=240]/bestvideo[vcodec^=vp9.2][height>=240]/bestvideo[vcodec^=vp9][height>=240]/bestvideo[height>=240]/bestvideo[vcodec^=avc1][height>=144][fps>30]/bestvideo[vcodec^=av01][height>=144][fps>30]/bestvideo[vcodec^=vp9.2][height>=144][fps>30]/bestvideo[vcodec^=vp9][height>=144][fps>30]/bestvideo[height>=144][fps>30]/bestvideo[vcodec^=avc1][height>=144]/bestvideo[vcodec^=av01][height>=144]/bestvideo[vcodec^=vp9.2][height>=144]/bestvideo[vcodec^=vp9][height>=144]/bestvideo[height>=144]/bestvideo)+(bestaudio[acodec^=opus]/bestaudio)/best', '--verbose', '--force-ipv4', '--ignore-errors', '--no-continue', '--no-overwrites', '--download-archive', 'archive.log', '--add-metadata', '--parse-metadata', '%(title)s:%(meta_title)s', '--parse-metadata', '%(uploader)s:%(meta_artist)s', '--all-subs', '--embed-subs', '--check-formats', '--concurrent-fragments', '5', '--output', '%(uploader)s - %(upload_date)s - %(title)s [%(id)s].%(ext)s', '--merge-output-format', 'mkv', '--throttled-rate', '100K', '--batch-file', 'Source - Watch on PC.txt']
[debug] Batch file urls: ['https://www.ondemandchina.com/en/watch/nirvana-in-fire/ep-1']
[debug] Encodings: locale cp1252, fs utf-8, out cp1252 (No ANSI), err cp1252 (No ANSI), pref cp1252
[debug] yt-dlp version 2022.04.08 [7884ade65]
[debug] Python version 3.9.12 (CPython 64bit) - Windows-10-10.0.19044-SP0
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: ffmpeg 4.4-full_build-www.gyan.dev (setts), ffprobe 4.4-full_build-www.gyan.dev
[debug] Optional libraries: brotli, certifi, Cryptodome, mutagen, sqlite, websockets
[debug] Proxy map: {}
[debug] Loading archive file 'archive.log'
[debug] [generic] Extracting URL: https://www.ondemandchina.com/en/watch/nirvana-in-fire/ep-1
[generic] ep-1: Requesting header
WARNING: [generic] Falling back on generic information extractor.
[generic] ep-1: Downloading webpage
[generic] ep-1: Extracting information
[debug] Looking for video embeds
ERROR: Unsupported URL: https://www.ondemandchina.com/en/watch/nirvana-in-fire/ep-1
Traceback (most recent call last):
  File "C:\Users\Nomaan\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0\LocalCache\local-packages\Python39\site-packages\yt_dlp\YoutubeDL.py", line 1408, in wrapper
    return func(self, *args, **kwargs)
  File "C:\Users\Nomaan\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0\LocalCache\local-packages\Python39\site-packages\yt_dlp\YoutubeDL.py", line 1478, in __extract_info
    ie_result = ie.extract(url)
  File "C:\Users\Nomaan\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0\LocalCache\local-packages\Python39\site-packages\yt_dlp\extractor\common.py", line 641, in extract
    ie_result = self._real_extract(url)
  File "C:\Users\Nomaan\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.9_qbz5n2kfra8p0\LocalCache\local-packages\Python39\site-packages\yt_dlp\extractor\generic.py", line 4039, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.ondemandchina.com/en/watch/nirvana-in-fire/ep-1
```



Discussion:
I found a not geo blocked url : https://www.ondemandchina.com/en/watch/together-against-covid-19/ep-1
In this url, the video actually in https://odkmedia.io/odc/api/v2/playback/264394/ and in this json we can get the video in `sources`. I don't know where the `264394` came from, but i guess it's the video id. I think this sites use brightcove as backend
It seems this site using `graphql` api with `POST` Method (the url is https://odc-graphql.odkmedia.io/graphql). In context of link that i found, 
The api request data is looks like this:
```json
{
  "operationName": "Episode",
  "variables": {
    "programSlug": "together-against-covid-19",
    "episodeNumber": 1,
    "kind": "series",
    "part": null
  },
  "query": "query Episode($programSlug: String!, $episodeNumber: Int!, $kind: String!, $part: Int) {\n  episode(\n    programSlug: $programSlug\n    episodeNumber: $episodeNumber\n    kind: $kind\n    part: $part\n  ) {\n    ...EpisodeDetail\n    __typename\n  }\n}\n\nfragment EpisodeDetail on Episode {\n  id\n  episodeNumber\n  part\n  kind\n  link\n  nextEpisode {\n    ...NextEpisodeDetail\n    __typename\n  }\n  title\n  titleEn\n  titleKo\n  titleZhHans\n  titleZhHant\n  synopsis\n  synopsisEn\n  synopsisKo\n  synopsisZhHans\n  synopsisZhHant\n  seoSynopsis\n  seoSynopsisEn\n  seoSynopsisKo\n  seoSynopsisZhHans\n  seoSynopsisZhHant\n  seoKeyword\n  seoKeywordEn\n  seoKeywordKo\n  seoKeywordZhHans\n  seoKeywordZhHant\n  seoTitle\n  seoTitleEn\n  seoTitleKo\n  seoTitleZhHans\n  seoTitleZhHant\n  videoCcLanguages\n  videoDuration\n  releaseDate\n  people {\n    ...PeopleItem\n    __typename\n  }\n  images {\n    thumbnail\n    __typename\n  }\n  ageRating {\n    isBlock\n    __typename\n  }\n  __typename\n}\n\nfragment NextEpisodeDetail on Episode {\n  program {\n    slug\n    __typename\n  }\n  link\n  kind\n  part\n  episodeNumber\n  images {\n    thumbnail\n    __typename\n  }\n  title\n  titleEn\n  titleKo\n  titleZhHans\n  titleZhHant\n  __typename\n}\n\nfragment PeopleItem on People {\n  role\n  slug\n  nameEn\n  nameZhHans\n  nameZhHant\n  __typename\n}\n"
}
```

And the this requests give response like this:
```json
{
  "data": {
    "episode": {
      "id": 264394,
      "episodeNumber": 1,
      "part": null,
      "kind": "series",
      "link": "/watch/together-against-covid-19/ep-1",
      "nextEpisode": {
        "program": {
          "slug": "together-against-covid-19",
          "__typename": "Program"
        },
        "link": "/watch/together-against-covid-19/ep-2",
        "kind": "series",
        "part": null,
        "episodeNumber": 2,
        "images": {
          "thumbnail": "https://d2y2efdi5wgkcl.cloudfront.net/fit-in/256x256/media-io/2020/9/11/image.2f9631f0.jpg",
          "__typename": "Images"
        },
        "title": "EP 2 A Fight for Life",
        "titleEn": "EP 2 A Fight for Life",
        "titleKo": null,
        "titleZhHans": "第2集 生死阻击",
        "titleZhHant": "第2集 生死阻擊",
        "__typename": "Episode"
      },
      "title": "EP 1 The Calling",
      "titleEn": "EP 1 The Calling",
      "titleKo": null,
      "titleZhHans": "第1集 令出如山",
      "titleZhHant": "第1集 令出如山",
      "synopsis": "",
      "synopsisEn": null,
      "synopsisKo": null,
      "synopsisZhHans": "疫情严峻，党政军民学、东西南北中协同应考",
      "synopsisZhHant": "疫情嚴峻，黨政軍民學、東西南北中協同應考",
      "seoSynopsis": "",
      "seoSynopsisEn": null,
      "seoSynopsisKo": null,
      "seoSynopsisZhHans": null,
      "seoSynopsisZhHant": null,
      "seoKeyword": "",
      "seoKeywordEn": null,
      "seoKeywordKo": null,
      "seoKeywordZhHans": null,
      "seoKeywordZhHant": null,
      "seoTitle": "",
      "seoTitleEn": null,
      "seoTitleKo": null,
      "seoTitleZhHans": null,
      "seoTitleZhHant": null,
      "videoCcLanguages": [],
      "videoDuration": 3256880,
      "releaseDate": "2020-09-11",
      "people": [],
      "images": {
        "thumbnail": "https://d2y2efdi5wgkcl.cloudfront.net/fit-in/256x256/media-io/2020/9/11/image.d9816e81.jpg",
        "__typename": "Images"
      },
      "ageRating": {
        "isBlock": false,
        "__typename": "AgeRating"
      },
      "__typename": "Episode"
    }
  }
}
```
As we can see, we can input the `id` from response to `https://odkmedia.io/odc/api/v2/playback/<episode_id>/`. However, i didn't test this to other link

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
@@ -1287,6 +1287,7 @@
 from .nzherald import NZHeraldIE
 from .nzz import NZZIE
 from .odatv import OdaTVIE
+from .odkmedia import OnDemandChinaEpisodeIE
 from .odnoklassniki import OdnoklassnikiIE
 from .oftv import (
     OfTVIE,
diff --git a/yt_dlp/extractor/odkmedia.py b/yt_dlp/extractor/odkmedia.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/odkmedia.py
@@ -0,0 +1,105 @@
+import json
+import urllib.error
+
+from .common import InfoExtractor
+from ..utils import (
+    ExtractorError,
+    GeoRestrictedError,
+    float_or_none,
+    traverse_obj,
+    try_call
+)
+
+
+class OnDemandChinaEpisodeIE(InfoExtractor):
+    _VALID_URL = r'https?://www\.ondemandchina\.com/\w+/watch/(?P<series>[\w-]+)/(?P<id>ep-(?P<ep>\d+))'
+    _TESTS = [{
+        'url': 'https://www.ondemandchina.com/en/watch/together-against-covid-19/ep-1',
+        'info_dict': {
+            'id': '264394',
+            'ext': 'mp4',
+            'duration': 3256.88,
+            'title': 'EP 1 The Calling',
+            'alt_title': '第1集 令出如山',
+            'thumbnail': 'https://d2y2efdi5wgkcl.cloudfront.net/fit-in/256x256/media-io/2020/9/11/image.d9816e81.jpg',
+            'description': '疫情严峻，党政军民学、东西南北中协同应考',
+            'tags': ['Social Humanities', 'Documentary', 'Medical', 'Social'],
+        }
+    }]
+
+    _QUERY = '''
+        query Episode($programSlug: String!, $episodeNumber: Int!) {
+            episode(
+                programSlug: $programSlug
+                episodeNumber: $episodeNumber
+                kind: "series"
+                part: null
+            ) {
+                id
+                title
+                titleEn
+                titleKo
+                titleZhHans
+                titleZhHant
+                synopsis
+                synopsisEn
+                synopsisKo
+                synopsisZhHans
+                synopsisZhHant
+                videoDuration
+                images {
+                    thumbnail
+                }
+            }
+        }'''
+
+    def _real_extract(self, url):
+        program_slug, display_id, ep_number = self._match_valid_url(url).group('series', 'id', 'ep')
+        webpage = self._download_webpage(url, display_id)
+
+        video_info = self._download_json(
+            'https://odc-graphql.odkmedia.io/graphql', display_id,
+            headers={'Content-type': 'application/json'},
+            data=json.dumps({
+                'operationName': 'Episode',
+                'query': self._QUERY,
+                'variables': {
+                    'programSlug': program_slug,
+                    'episodeNumber': int(ep_number),
+                },
+            }).encode())['data']['episode']
+
+        try:
+            source_json = self._download_json(
+                f'https://odkmedia.io/odc/api/v2/playback/{video_info["id"]}/', display_id,
+                headers={'Authorization': '', 'service-name': 'odc'})
+        except ExtractorError as e:
+            if isinstance(e.cause, urllib.error.HTTPError):
+                error_data = self._parse_json(e.cause.read(), display_id)['detail']
+                raise GeoRestrictedError(error_data)
+
+        formats, subtitles = [], {}
+        for source in traverse_obj(source_json, ('sources', ...)):
+            if source.get('type') == 'hls':
+                fmts, subs = self._extract_m3u8_formats_and_subtitles(source.get('url'), display_id)
+                formats.extend(fmts)
+                self._merge_subtitles(subs, target=subtitles)
+            else:
+                self.report_warning(f'Unsupported format {source.get("type")}', display_id)
+
+        return {
+            'id': str(video_info['id']),
+            'duration': float_or_none(video_info.get('videoDuration'), 1000),
+            'thumbnail': (traverse_obj(video_info, ('images', 'thumbnail'))
+                          or self._html_search_meta(['og:image', 'twitter:image'], webpage)),
+            'title': (traverse_obj(video_info, 'title', 'titleEn')
+                      or self._html_search_meta(['og:title', 'twitter:title'], webpage)
+                      or self._html_extract_title(webpage)),
+            'alt_title': traverse_obj(video_info, 'titleKo', 'titleZhHans', 'titleZhHant'),
+            'description': (traverse_obj(
+                video_info, 'synopsisEn', 'synopsisKo', 'synopsisZhHans', 'synopsisZhHant', 'synopisis')
+                or self._html_search_meta(['og:description', 'twitter:description', 'description'], webpage)),
+            'formats': formats,
+            'subtitles': subtitles,
+            'tags': try_call(lambda: self._html_search_meta('keywords', webpage).split(', '))
+        }


Test patch: