Repository: yt-dlp/yt-dlp

Issue:
Site support request: STAGE+
### 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 a new site support request
- [X] I've verified that I'm running yt-dlp version **2023.03.04** ([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 [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 am willing to share it if required

### Region

All

### Example URLs

- Video info page: https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG
- Player (program chosen): https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG&t=0

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

This is a registered-user-only site for concert videos, which means the extractor has to take account credentials or cookies.

### 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] 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] Command-line config: ['-vU', 'https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version stable@2023.03.04 [392389b7d] (pip)
[debug] Python 3.10.10 (CPython x86_64 64bit) - Linux-6.2.10-arch1-1-x86_64-with-glibc2.37 (OpenSSL 3.0.8 7 Feb 2023, glibc 2.37)
[debug] exe versions: ffmpeg 6.0 (setts), ffprobe 6.0, rtmpdump 2.4
[debug] Optional libraries: Cryptodome-3.16.0, brotli-1.0.9, certifi-2022.12.07, mutagen-1.46.0, sqlite3-2.6.0, websockets-10.4
[debug] Proxy map: {}
[debug] Loaded 1786 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Available version: stable@2023.03.04, Current version: stable@2023.03.04
yt-dlp is up to date (stable@2023.03.04)
[generic] Extracting URL: https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG
[generic] vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG: Extracting information
[debug] Looking for embeds
ERROR: Unsupported URL: https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG
Traceback (most recent call last):
  File "/home/erwin/.local/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1518, in wrapper
    return func(self, *args, **kwargs)
  File "/home/erwin/.local/lib/python3.10/site-packages/yt_dlp/YoutubeDL.py", line 1594, in __extract_info
    ie_result = ie.extract(url)
  File "/home/erwin/.local/lib/python3.10/site-packages/yt_dlp/extractor/common.py", line 694, in extract
    ie_result = self._real_extract(url)
  File "/home/erwin/.local/lib/python3.10/site-packages/yt_dlp/extractor/generic.py", line 2510, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG?player=true&trackId=performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJADHG
```



Discussion:
If required, this is a valid account credential:
- username: namlotirda@gufum.com
- password: 6DVYvvSn8s3giHq

This is a 7-day free trial, DEA account.

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
@@ -1786,6 +1786,7 @@
     BellatorIE,
     ParamountNetworkIE,
 )
+from .stageplus import StagePlusVODConcertIE
 from .startrek import StarTrekIE
 from .stitcher import (
     StitcherIE,
diff --git a/yt_dlp/extractor/stageplus.py b/yt_dlp/extractor/stageplus.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/stageplus.py
@@ -0,0 +1,518 @@
+import json
+import uuid
+
+from .common import InfoExtractor
+from ..utils import (
+    float_or_none,
+    traverse_obj,
+    try_call,
+    unified_timestamp,
+    url_or_none,
+)
+
+
+class StagePlusVODConcertIE(InfoExtractor):
+    _NETRC_MACHINE = 'stageplus'
+    _VALID_URL = r'https?://(?:www\.)?stage-plus\.com/video/(?P<id>vod_concert_\w+)'
+    _TESTS = [{
+        'url': 'https://www.stage-plus.com/video/vod_concert_APNM8GRFDPHMASJKBSPJACG',
+        'playlist_count': 6,
+        'info_dict': {
+            'id': 'vod_concert_APNM8GRFDPHMASJKBSPJACG',
+            'title': 'Yuja Wang plays Rachmaninoff\'s Piano Concerto No. 2 – from Odeonsplatz',
+            'description': 'md5:50f78ec180518c9bdb876bac550996fc',
+            'artist': ['Yuja Wang', 'Lorenzo Viotti'],
+            'upload_date': '20230331',
+            'timestamp': 1680249600,
+            'release_date': '20210709',
+            'release_timestamp': 1625788800,
+            'thumbnails': 'count:3',
+        },
+        'playlist': [{
+            'info_dict': {
+                'id': 'performance_work_A1IN4PJFE9MM2RJ3CLBMUSJBBSOJAD9O',
+                'ext': 'mp4',
+                'title': 'Piano Concerto No. 2 in C Minor, Op. 18',
+                'description': 'md5:50f78ec180518c9bdb876bac550996fc',
+                'upload_date': '20230331',
+                'timestamp': 1680249600,
+                'release_date': '20210709',
+                'release_timestamp': 1625788800,
+                'duration': 2207,
+                'chapters': 'count:5',
+                'artist': ['Yuja Wang'],
+                'composer': ['Sergei Rachmaninoff'],
+                'album': 'Yuja Wang plays Rachmaninoff\'s Piano Concerto No. 2 – from Odeonsplatz',
+                'album_artist': ['Yuja Wang', 'Lorenzo Viotti'],
+                'track': 'Piano Concerto No. 2 in C Minor, Op. 18',
+                'track_number': 1,
+                'genre': 'Instrumental Concerto',
+            },
+        }],
+        'params': {'skip_download': 'm3u8'},
+    }]
+
+    # TODO: Prune this after livestream and/or album extractors are added
+    _GRAPHQL_QUERY = '''query videoDetailPage($videoId: ID!, $sliderItemsFirst: Int = 24) {
+  node(id: $videoId) {
+    __typename
+    ...LiveConcertFields
+    ... on LiveConcert {
+      artists {
+        edges {
+          role {
+            ...RoleFields
+          }
+          node {
+            id
+            name
+            sortName
+          }
+        }
+      }
+      isAtmos
+      maxResolution
+      groups {
+        id
+        name
+        typeDisplayName
+      }
+      shortDescription
+      performanceWorks {
+        ...livePerformanceWorkFields
+      }
+      totalDuration
+      sliders {
+        ...contentContainerFields
+      }
+      vodConcert {
+        __typename
+        id
+      }
+    }
+    ...VideoFields
+    ... on Video {
+      artists {
+        edges {
+          role {
+            ...RoleFields
+          }
+          node {
+            id
+            name
+            sortName
+          }
+        }
+      }
+      isAtmos
+      maxResolution
+      isLossless
+      description
+      productionDate
+      takedownDate
+      sliders {
+        ...contentContainerFields
+      }
+    }
+    ...VodConcertFields
+    ... on VodConcert {
+      artists {
+        edges {
+          role {
+            ...RoleFields
+          }
+          node {
+            id
+            name
+            sortName
+          }
+        }
+      }
+      isAtmos
+      maxResolution
+      groups {
+        id
+        name
+        typeDisplayName
+      }
+      performanceWorks {
+        ...PerformanceWorkFields
+      }
+      shortDescription
+      productionDate
+      takedownDate
+      sliders {
+        ...contentContainerFields
+      }
+    }
+  }
+}
+
+fragment LiveConcertFields on LiveConcert {
+  endTime
+  id
+  pictures {
+    ...PictureFields
+  }
+  reruns {
+    ...liveConcertRerunFields
+  }
+  publicationLevel
+  startTime
+  streamStartTime
+  subtitle
+  title
+  typeDisplayName
+  stream {
+    ...liveStreamFields
+  }
+  trailerStream {
+    ...streamFields
+  }
+  geoAccessCountries
+  geoAccessMode
+}
+
+fragment PictureFields on Picture {
+  id
+  url
+  type
+}
+
+fragment liveConcertRerunFields on LiveConcertRerun {
+  streamStartTime
+  endTime
+  startTime
+  stream {
+    ...rerunStreamFields
+  }
+}
+
+fragment rerunStreamFields on RerunStream {
+  publicationLevel
+  streamType
+  url
+}
+
+fragment liveStreamFields on LiveStream {
+  publicationLevel
+  streamType
+  url
+}
+
+fragment streamFields on Stream {
+  publicationLevel
+  streamType
+  url
+}
+
+fragment RoleFields on Role {
+  __typename
+  id
+  type
+  displayName
+}
+
+fragment livePerformanceWorkFields on LivePerformanceWork {
+  __typename
+  id
+  artists {
+    ...artistWithRoleFields
+  }
+  groups {
+    edges {
+      node {
+        id
+        name
+        typeDisplayName
+      }
+    }
+  }
+  work {
+    ...workFields
+  }
+}
+
+fragment artistWithRoleFields on ArtistWithRoleConnection {
+  edges {
+    role {
+      ...RoleFields
+    }
+    node {
+      id
+      name
+      sortName
+    }
+  }
+}
+
+fragment workFields on Work {
+  id
+  title
+  movements {
+    id
+    title
+  }
+  composers {
+    id
+    name
+  }
+  genre {
+    id
+    title
+  }
+}
+
+fragment contentContainerFields on CuratedContentContainer {
+  __typename
+  ...SliderFields
+  ...BannerFields
+}
+
+fragment SliderFields on Slider {
+  id
+  headline
+  items(first: $sliderItemsFirst) {
+    edges {
+      node {
+        id
+        __typename
+        ...AlbumFields
+        ...ArtistFields
+        ...EpochFields
+        ...GenreFields
+        ...GroupFields
+        ...LiveConcertFields
+        ...PartnerFields
+        ...PerformanceWorkFields
+        ...VideoFields
+        ...VodConcertFields
+      }
+    }
+  }
+}
+
+fragment AlbumFields on Album {
+  artistAndGroupDisplayInfo
+  id
+  pictures {
+    ...PictureFields
+  }
+  title
+}
+
+fragment ArtistFields on Artist {
+  id
+  name
+  roles {
+    ...RoleFields
+  }
+  pictures {
+    ...PictureFields
+  }
+}
+
+fragment EpochFields on Epoch {
+  id
+  endYear
+  pictures {
+    ...PictureFields
+  }
+  startYear
+  title
+}
+
+fragment GenreFields on Genre {
+  id
+  pictures {
+    ...PictureFields
+  }
+  title
+}
+
+fragment GroupFields on Group {
+  id
+  name
+  typeDisplayName
+  pictures {
+    ...PictureFields
+  }
+}
+
+fragment PartnerFields on Partner {
+  id
+  name
+  typeDisplayName
+  subtypeDisplayName
+  pictures {
+    ...PictureFields
+  }
+}
+
+fragment PerformanceWorkFields on PerformanceWork {
+  __typename
+  id
+  artists {
+    ...artistWithRoleFields
+  }
+  groups {
+    edges {
+      node {
+        id
+        name
+        typeDisplayName
+      }
+    }
+  }
+  work {
+    ...workFields
+  }
+  stream {
+    ...streamFields
+  }
+  vodConcert {
+    __typename
+    id
+  }
+  duration
+  cuePoints {
+    mark
+    title
+  }
+}
+
+fragment VideoFields on Video {
+  id
+  archiveReleaseDate
+  title
+  subtitle
+  pictures {
+    ...PictureFields
+  }
+  stream {
+    ...streamFields
+  }
+  trailerStream {
+    ...streamFields
+  }
+  duration
+  typeDisplayName
+  duration
+  geoAccessCountries
+  geoAccessMode
+  publicationLevel
+  takedownDate
+}
+
+fragment VodConcertFields on VodConcert {
+  id
+  archiveReleaseDate
+  pictures {
+    ...PictureFields
+  }
+  subtitle
+  title
+  typeDisplayName
+  totalDuration
+  geoAccessCountries
+  geoAccessMode
+  trailerStream {
+   ...streamFields
+  }
+  publicationLevel
+  takedownDate
+}
+
+fragment BannerFields on Banner {
+  description
+  link
+  pictures {
+    ...PictureFields
+  }
+  title
+}'''
+
+    _TOKEN = None
+
+    def _perform_login(self, username, password):
+        auth = self._download_json('https://audience.api.stageplus.io/oauth/token', None, headers={
+            'Content-Type': 'application/json',
+            'Origin': 'https://www.stage-plus.com',
+        }, data=json.dumps({
+            'grant_type': 'password',
+            'username': username,
+            'password': password,
+            'device_info': 'Chrome (Windows)',
+            'client_device_id': str(uuid.uuid4()),
+        }, separators=(',', ':')).encode(), note='Logging in')
+
+        if auth.get('access_token'):
+            self._TOKEN = auth['access_token']
+
+    def _real_initialize(self):
+        if self._TOKEN:
+            return
+
+        self._TOKEN = try_call(
+            lambda: self._get_cookies('https://www.stage-plus.com/')['dgplus_access_token'].value)
+        if not self._TOKEN:
+            self.raise_login_required()
+
+    def _real_extract(self, url):
+        concert_id = self._match_id(url)
+
+        data = self._download_json('https://audience.api.stageplus.io/graphql', concert_id, headers={
+            'authorization': f'Bearer {self._TOKEN}',
+            'content-type': 'application/json',
+            'Origin': 'https://www.stage-plus.com',
+        }, data=json.dumps({
+            'query': self._GRAPHQL_QUERY,
+            'variables': {'videoId': concert_id},
+            'operationName': 'videoDetailPage'
+        }, separators=(',', ':')).encode())['data']['node']
+
+        metadata = traverse_obj(data, {
+            'title': 'title',
+            'description': ('shortDescription', {str}),
+            'artist': ('artists', 'edges', ..., 'node', 'name'),
+            'timestamp': ('archiveReleaseDate', {unified_timestamp}),
+            'release_timestamp': ('productionDate', {unified_timestamp}),
+        })
+
+        thumbnails = traverse_obj(data, ('pictures', lambda _, v: url_or_none(v['url']), {
+            'id': 'name',
+            'url': 'url',
+        })) or None
+
+        m3u8_headers = {'jwt': self._TOKEN}
+
+        entries = []
+        for idx, video in enumerate(traverse_obj(data, (
+                'performanceWorks', lambda _, v: v['id'] and url_or_none(v['stream']['url']))), 1):
+            formats, subtitles = self._extract_m3u8_formats_and_subtitles(
+                video['stream']['url'], video['id'], 'mp4', m3u8_id='hls', headers=m3u8_headers)
+            entries.append({
+                'id': video['id'],
+                'formats': formats,
+                'subtitles': subtitles,
+                'http_headers': m3u8_headers,
+                'album': metadata.get('title'),
+                'album_artist': metadata.get('artist'),
+                'track_number': idx,
+                **metadata,
+                **traverse_obj(video, {
+                    'title': ('work', 'title'),
+                    'track': ('work', 'title'),
+                    'duration': ('duration', {float_or_none}),
+                    'chapters': (
+                        'cuePoints', lambda _, v: float_or_none(v['mark']) is not None, {
+                            'title': 'title',
+                            'start_time': ('mark', {float_or_none}),
+                        }),
+                    'artist': ('artists', 'edges', ..., 'node', 'name'),
+                    'composer': ('work', 'composers', ..., 'name'),
+                    'genre': ('work', 'genre', 'title'),
+                }),
+            })
+
+        return self.playlist_result(entries, concert_id, thumbnails=thumbnails, **metadata)


Test patch: