[ie/asobistage] Add extractor for live and archives
diff --git a/yt_dlp/extractor/_extractors.py b/yt_dlp/extractor/_extractors.py
index c7536553680..c8b364c7bc5 100644
--- a/yt_dlp/extractor/_extractors.py
+++ b/yt_dlp/extractor/_extractors.py
@@ -150,6 +150,7 @@
 )
 from .arnes import ArnesIE
 from .asobichannel import AsobiChannelIE, AsobiChannelTagURLIE
+from .asobistage import AsobiStageIE
 from .atresplayer import AtresPlayerIE
 from .atscaleconf import AtScaleConfEventIE
 from .atvat import ATVAtIE
diff --git a/yt_dlp/extractor/asobistage.py b/yt_dlp/extractor/asobistage.py
new file mode 100644
index 00000000000..b088a1b1321
--- /dev/null
+++ b/yt_dlp/extractor/asobistage.py
@@ -0,0 +1,154 @@
+import functools
+
+from .common import InfoExtractor
+from ..utils import str_or_none, url_or_none
+from ..utils.traversal import traverse_obj
+
+
+class AsobiStageIE(InfoExtractor):
+    IE_DESC = 'ASOBISTAGE (アソビステージ)'
+    _VALID_URL = r'https?://asobistage\.asobistore\.jp/event/(?P<id>(?P<event>\w+)/(?P<type>archive|player)/(?P<slug>\w+))(?:[?#]|$)'
+    _TESTS = [{
+        'url': 'https://asobistage.asobistore.jp/event/315passionhour_2022summer/archive/frame',
+        'info_dict': {
+            'id': '315passionhour_2022summer/archive/frame',
+            'title': '315プロダクションプレゼンツ 315パッションアワー!!!',
+            'thumbnail': r're:^https?://[\w.-]+/\w+/\w+',
+        },
+        'playlist_count': 1,
+        'playlist': [{
+            'info_dict': {
+                'id': 'edff52f2',
+                'ext': 'mp4',
+                'title': '315passion_FRAME_only',
+                'thumbnail': r're:^https?://[\w.-]+/\w+/\w+',
+            },
+        }],
+    }, {
+        'url': 'https://asobistage.asobistore.jp/event/idolmaster_idolworld2023_goods/archive/live',
+        'info_dict': {
+            'id': 'idolmaster_idolworld2023_goods/archive/live',
+            'title': 'md5:378510b6e830129d505885908bd6c576',
+            'thumbnail': r're:^https?://[\w.-]+/\w+/\w+',
+        },
+        'playlist_count': 1,
+        'playlist': [{
+            'info_dict': {
+                'id': '3aef7110',
+                'ext': 'mp4',
+                'title': 'asobistore_station_1020_serverREC',
+                'thumbnail': r're:^https?://[\w.-]+/\w+/\w+',
+            },
+        }],
+    }, {
+        'url': 'https://asobistage.asobistore.jp/event/sidem_fclive_bpct/archive/premium_hc',
+        'playlist_count': 4,
+        'info_dict': {
+            'id': 'sidem_fclive_bpct/archive/premium_hc',
+            'title': '315 Production presents F＠NTASTIC COMBINATION LIVE ～BRAINPOWER!!～/～CONNECTIME!!!!～',
+            'thumbnail': r're:^https?://[\w.-]+/\w+/\w+',
+        },
+    }, {
+        'url': 'https://asobistage.asobistore.jp/event/ijigenfes_utagassen/player/day1',
+        'only_matching': True,
+    }]
+
+    _API_HOST = 'https://asobistage-api.asobistore.jp'
+    _HEADERS = {}
+    _is_logged_in = False
+
+    @functools.cached_property
+    def _owned_tickets(self):
+        owned_tickets = set()
+        if not self._is_logged_in:
+            return owned_tickets
+
+        for path, name in [
+            ('api/v1/purchase_history/list', 'ticket purchase history'),
+            ('api/v1/serialcode/list', 'redemption history'),
+        ]:
+            response = self._download_json(
+                f'{self._API_HOST}/{path}', None, f'Downloading {name}',
+                f'Unable to download {name}', expected_status=400)
+            if traverse_obj(response, ('payload', 'error_message'), 'error') == 'notlogin':
+                self._is_logged_in = False
+                break
+            owned_tickets.update(
+                traverse_obj(response, ('payload', 'value', ..., 'digital_product_id', {str_or_none})))
+
+        return owned_tickets
+
+    def _get_available_channel_id(self, channel):
+        channel_id = traverse_obj(channel, ('chennel_vspf_id', {str}))
+        if not channel_id:
+            return None
+        # if rights_type_id == 6, then 'No conditions (no login required - non-members are OK)'
+        if traverse_obj(channel, ('viewrights', lambda _, v: v['rights_type_id'] == 6)):
+            return channel_id
+        available_tickets = traverse_obj(channel, (
+            'viewrights', ..., ('tickets', 'serialcodes'), ..., 'digital_product_id', {str_or_none}))
+        if not self._owned_tickets.intersection(available_tickets):
+            self.report_warning(
+                f'You are not a ticketholder for "{channel.get("channel_name") or channel_id}"')
+            return None
+        return channel_id
+
+    def _real_initialize(self):
+        if self._get_cookies(self._API_HOST):
+            self._is_logged_in = True
+        token = self._download_json(
+            f'{self._API_HOST}/api/v1/vspf/token', None, 'Getting token', 'Unable to get token')
+        self._HEADERS['Authorization'] = f'Bearer {token}'
+
+    def _real_extract(self, url):
+        video_id, event, type_, slug = self._match_valid_url(url).group('id', 'event', 'type', 'slug')
+        video_type = {'archive': 'archives', 'player': 'broadcasts'}[type_]
+        webpage = self._download_webpage(url, video_id)
+        event_data = traverse_obj(
+            self._search_nextjs_data(webpage, video_id, default='{}'),
+            ('props', 'pageProps', 'eventCMSData', {
+                'title': ('event_name', {str}),
+                'thumbnail': ('event_thumbnail_image', {url_or_none}),
+            }))
+
+        available_channels = traverse_obj(self._download_json(
+            f'https://asobistage.asobistore.jp/cdn/v101/events/{event}/{video_type}.json',
+            video_id, 'Getting channel list', 'Unable to get channel list'), (
+            video_type, lambda _, v: v['broadcast_slug'] == slug,
+            'channels', lambda _, v: v['chennel_vspf_id'] != '00000'))
+
+        entries = []
+        for channel_id in traverse_obj(available_channels, (..., {self._get_available_channel_id})):
+            if video_type == 'archives':
+                channel_json = self._download_json(
+                    f'https://survapi.channel.or.jp/proxy/v1/contents/{channel_id}/get_by_cuid', channel_id,
+                    'Getting archive channel info', 'Unable to get archive channel info', fatal=False,
+                    headers=self._HEADERS)
+                channel_data = traverse_obj(channel_json, ('ex_content', {
+                    'm3u8_url': 'streaming_url',
+                    'title': 'title',
+                    'thumbnail': ('thumbnail', 'url'),
+                }))
+            else:  # video_type == 'broadcasts'
+                channel_json = self._download_json(
+                    f'https://survapi.channel.or.jp/ex/events/{channel_id}', channel_id,
+                    'Getting live channel info', 'Unable to get live channel info', fatal=False,
+                    headers=self._HEADERS, query={'embed': 'channel'})
+                channel_data = traverse_obj(channel_json, ('data', {
+                    'm3u8_url': ('Channel', 'Custom_live_url'),
+                    'title': 'Name',
+                    'thumbnail': 'Poster_url',
+                }))
+
+            entries.append({
+                'id': channel_id,
+                'title': channel_data.get('title'),
+                'formats': self._extract_m3u8_formats(channel_data.get('m3u8_url'), channel_id, fatal=False),
+                'is_live': video_type == 'broadcasts',
+                'thumbnail': url_or_none(channel_data.get('thumbnail')),
+            })
+
+        if not self._is_logged_in and not entries:
+            self.raise_login_required()
+
+        return self.playlist_result(entries, video_id, **event_data)

**IMPORTANT**: PRs without the template will be CLOSED

### Description of your *pull request* and other information

<!--

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

-->

### Summary

[ASOBISTAGE (アソビステージ)](https://asobistage.asobistore.jp/) is a Japanese live-streaming platform owned by Bandai Namco Entertainment Inc.

![ASOBISTAGE logo](https://asobistage.asobistore.jp/asobi-common/assets/img/header/logo-stage.svg)

This extractor currently does not support URLs like `https://asobistage.asobistore.jp/event/foo/rental/player/normal` (note the "rental/player").

### Note

- I didn't find the way to get the correct timestamp of an video.
- The `dict.get()` check after "`traverse_obj()`" looks not good. How do I make it better? I mean, how can I check required fields in the downloaded JSON?

<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 all of the following options that apply:
- [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))

</details>
