Repository: yt-dlp/yt-dlp

Issue:
[Crunchyroll] improve chapter handling
### 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 requesting a site-specific feature
- [X] I've verified that I'm running yt-dlp version **2023.07.06** ([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 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)
- [ ] 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 I'm willing to share it if required

### Region

Germany

### Example URLs

https://www.crunchyroll.com/de/watch/GYE50G43R/ill-wait-here-luffy-vs-the-enraged-army

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

I recently discovered that the plugin downloads chapters and tried embedding them in the resulting file. This works, but for the given example it returned a chapter that is 0.12 seconds, followed by the intro.
Putting that to active use in a player (mpc-hc) results in 2 positions I can jump to: 0.0 seconds and 0.12 seconds.

Even though the end of the intro chapter is marked correctly in the file (only in mkv format), most players will ignore it and instead look for the start of the next chapter which is not present. In mp4 format the end time of chapters is not included at all.

I've made some changes to the code in my fork (https://github.com/Yavos/yt-dlp/tree/dev-crunchyroll-chapters) to address 2 issues:
- removing unnecessary (automatically generated) chapters at the start
- adding a chapter after the intro to be actually able to jump there in the player

The output of yt-dlp doesn't really give any useful information, so I'm gonna paste the relevant parts from mediainfo instead:
```
Menu #1
ID                                       : 4
Codec ID                                 : text
Duration                                 : 2 min 50 s
Language                                 : English
Menu For                                 : 1,2,3
Duration_FirstFrame                      : -169990
00:00:00.000                             : <Untitled Chapter 1>
00:00:00.120                             : Intro
Bit rate mode                            : VBR

Menu #2
00:00:00.000                             : <Untitled Chapter 1>
00:00:00.120                             : Intro
```

I'll make a pull request in a day or two (following the guideline to give people time to see if this is even relevant)

### Provide verbose output that clearly demonstrates the problem

- [X] Run **your** yt-dlp command with **-vU** flag added (`yt-dlp -vU <your command line>`)
- [ ] 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: ['-k', '-f', 'best[height<=240]', '-o', '%(season)s\\%(episode_number)s %(episode)s [%(id)s][%(height)sp].%(ext)s', '--embed-metadata', '--sub-lang', 'de-DE', '--embed-subs', '--user-agent', 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0', '--cookies-from-browser', 'firefox', '-vU', 'https://www.crunchyroll.com/de/watch/GYE50G43R/ill-wait-here-luffy-vs-the-enraged-army']
Extracting cookies from firefox
[debug] Extracting cookies from: "C:\Users\Paul\AppData\Roaming\Mozilla\Firefox\Profiles\axe56wbw.Paul\cookies.sqlite"
Extracted 1610 cookies from firefox
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out utf-8 (No VT), error utf-8 (No VT), screen utf-8 (No VT)
[debug] yt-dlp version stable@2023.07.06 [b532a3481] (source)
[debug] Lazy loading extractors is disabled
[debug] Git HEAD: a01098234
[debug] Python 3.7.4 (CPython AMD64 64bit) - Windows-7-6.1.7601-SP1 (OpenSSL 1.1.1c  28 May 2019)
[debug] exe versions: ffmpeg git-2020-03-24-e5d25d1, ffprobe git-2020-03-24-e5d25d1
[debug] Optional libraries: Cryptodome-3.19.0, brotli-1.1.0, certifi-2023.07.22, mutagen-1.47.0, sqlite3-2.6.0, websockets-11.0.3
[debug] Proxy map: {}
[debug] Loaded 1875 extractors
[debug] Fetching release info: https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest
Available version: stable@2023.07.06, Current version: stable@2023.07.06
yt-dlp is up to date (stable@2023.07.06)
[crunchyroll] Extracting URL: https://www.crunchyroll.com/de/watch/GYE50G43R/ill-wait-here-luffy-vs-the-enraged-army
[debug] [crunchyroll] Using cxApiParam=noaihdevm_6iyg0a8l0q
[crunchyroll] Authenticating with grant_type=etp_rt_cookie
[crunchyroll] GYE50G43R: Downloading object info JSON (cms)
[crunchyroll] Retrieving signed policy
[crunchyroll] GYE50G43R: Downloading stream info JSON (signed cms)
[crunchyroll] To get all formats of a hardsub language, use "--extractor-args crunchyrollbeta:hardsub=<language_code or all>". See https://github.com/yt-dlp/yt-dlp#crunchyrollbeta-crunchyroll for more info
[crunchyroll] GYE50G43R: Downloading adaptive_hls HLS manifest
[crunchyroll] GYE50G43R: Downloading chapter info
[info] GYE50G43R: Downloading subtitles: de-DE
[debug] Formats sorted by: hasvid, ie_pref, lang, quality, res, fps, hdr:12(7), vcodec:vp9.2(10), channels, acodec, size, br, asr, proto, vext, aext,
hasaud, source, id
[info] GYE50G43R: Downloading 1 format(s): adaptive_hls-562
[info] Writing video subtitles to: One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].de-DE.ass
[debug] Invoking http downloader on "https://v.vrv.co/evs3/913e4b202fb11a119cbbe2c6e45c8cbd/assets/892b9fa62fa7cb35c90d8ec103290c9f_268631.txt?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cCo6Ly92LnZydi5jby9ldnMzLzkxM2U0YjIwMmZiMTFhMTE5Y2JiZTJjNmU0NWM4Y2JkL2Fzc2V0cy84OTJiOWZhNjJmYTdjYjM1YzkwZDhlYzEwMzI5MGM5Zl8yNjg2MzEudHh0IiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk1MzcwNjgyfX19XX0_&Signature=FEpDmESsd1PPD73QhzWJX0EQ-elMsS-RdNq3CYyJD2SwUz3al3EJvy93UFz296Wn9JoGcNp~VKzuC4mxOVfmJWBlxOPQ4JKp~DkfLDbTxmgKU5S-eNoiqkohlee2j4vSCCuk6ur9dJkfhLr2U3QnlZw3if92VmKszHYyD1~gDwfMXUDWe9~Vm1VnIWaE9bkDsS-sfK6iqhG96L14O9jivXuaM4AIPPiv1mzNj1JlniwEQsSE3~9i~-07QOw4lVxRnvTUAeNiKrLXTQZZVqDCoHq-Rz4bMPCUl3P9emmOxov9oof0R89RM8vkKXIc8i4oUv2R0g6LkhnFz0fI1x8zpQ__&Key-Pair-Id=APKAJMWSQ5S7ZB3MF5VA"
[debug] File locking is not supported. Proceeding without locking
[download] Destination: One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].de-DE.ass
[download] 100% of   21.63KiB in 00:00:00 at 450.64KiB/s
[debug] Invoking hlsnative downloader on "https://pl.crunchyroll.com/evs3/913e4b202fb11a119cbbe2c6e45c8cbd/assets/892b9fa62fa7cb35c90d8ec103290c9f_3334475.mp4/index-v1-a1.m3u8?res=428x240&Expires=1695370828&Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiaHR0cHM6Ly9wbC5jcnVuY2h5cm9sbC5jb20vZXZzMy85MTNlNGIyMDJmYjExYTExOWNiYmUyYzZlNDVjOGNiZC9hc3NldHMvODkyYjlmYTYyZmE3Y2IzNWM5MGQ4ZWMxMDMyOTBjOWZfMzMzNDQ3NS5tcDQvaW5kZXgtdjEtYTEubTN1OD9yZXM9NDI4eDI0MCIsIkNvbmRpdGlvbiI6eyJEYXRlTGVzc1RoYW4iOnsiQVdTOkVwb2NoVGltZSI6MTY5NTM3MDgyOH19fV19&Signature=EbcMq08glDsX5kNdyF5f-PUlahSvaorh3RP99aqSIBRh6WzJbkXAsbLLi81uVD4Dca-4tzcV9n945v7dmajMC3ukkWv8GnRVcVerbgt0bjS5wyUS5a8nw5D5eHIVeTHduAc6i9JPtg-hokeXm5nd42wnX5tTK4qrXnByQlNkvn1oqHS-AbUYAyx32FlKBmR0OyzTVQIw-6xnegZIDzVxVzSN1t0TKNcPwzoaZ-12msgtIgyFNUiTlIOMXXSwkedwi~MHFglhhT9JSfYL1o5zK1j6eCjdraVnFHu7lrGj-80usO3NHiBAe8d09m3H0JMj3-9aoaWrjdFPDj-sYnLvYg__&Key-Pair-Id=APKAJMWSQ5S7ZB3MF5VA"
[hlsnative] Downloading m3u8 manifest
[hlsnative] Total fragments: 358
[download] Destination: One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4
[download] 100% of  104.27MiB in 00:01:30 at 1.15MiB/s
[debug] ffprobe command line: ffprobe -hide_banner -show_format -show_streams -print_format json "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4"
[debug] ffmpeg command line: ffprobe -show_streams "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4"
[FixupM3u8] Fixing MPEG-TS in MP4 container of "One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel "repeat+info" -i "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4" -map 0 -dn -ignore_unknown -c copy -f mp4 "-bsf:a" aac_adtstoasc -movflags "+faststart" "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].temp.mp4"
WARNING: ASS subtitles cannot be properly embedded in mp4 files; expect issues
[EmbedSubtitle] Embedding subtitles in "One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel "repeat+info" -i "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4" -i "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee![GYE50G43R][240p].de-DE.ass" -map 0 -dn -ignore_unknown -c copy "-c:s" mov_text -map "-0:s" -map "1:0" "-metadata:s:s:0" "language=deu" -movflags "+faststart" "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].temp.mp4"
[Metadata] Adding metadata to "One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4"
[debug] ffmpeg command line: ffmpeg -y -loglevel "repeat+info" -i "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].mp4" -i "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee![GYE50G43R][240p].meta" -map 0 -dn -ignore_unknown -c copy -map_metadata 1 -write_id3v1 1 -metadata "title=One Piece - Whole Cake Island (783-878) Episode 811 – Ich werde hier warten! Ruffy gegen die wütende Armee!" -metadata "date=20171029" -metadata "description=Der Kampf gegen die wütende Armee geht in die nächste Runde. Die Söhne und Töchter von Big Mom setzen ihre Teufelskräfte ein, um Ruffy und Nami das Leben schwer zu machen." -metadata "synopsis=Der Kampf gegen die wütende Armee geht in die nächste Runde. Die Söhne und Töchter von Big Mom setzen ihre Teufelskräfte ein, um Ruffy und Nami das Leben schwer zu machen." -metadata "purl=https://www.crunchyroll.com/de/watch/GYE50G43R/ill-wait-here-luffy-vs-the-enraged-army" -metadata "comment=https://www.crunchyroll.com/de/watch/GYE50G43R/ill-wait-here-luffy-vs-the-enraged-army" -metadata "show=One Piece" -metadata "season_number=11" -metadata "episode_id=Ich werde hier warten! Ruffy gegen die wütende Armee!" -metadata "episode_sort=811" -movflags "+faststart" "file:One Piece - Whole Cake Island (783-878)\811 Ich werde hier warten! Ruffy gegen die wütende Armee! [GYE50G43R][240p].temp.mp4"
```



Discussion:
devs, see also: https://github.com/yt-dlp/yt-dlp/issues/5308#issuecomment-1710851210
The comments linked have an example with negative start time. My code would catch that but I could easily add an abs at end_time as well (no reports of negative values there so far) to prevent weird behavior with nearly infinite memory allocation in the aftermath, but that won't fix problems with times > duration if crunchyroll messes things up (also not reported yet, but mentioned as possible problem).
Probably there should be a check if start_time < end_time as well, but again that's to prevent hypothetical problems.

In my opinion it would be better to keep/implement that hypothetical stuff to chapter sanity checking of yt-dlp at a higher level like suggested over at the linked comments instead.
> * removing unnecessary (automatically generated) chapters at the start

~~Should not be done imo. If the site gives the chapter, it's not our role to delete it.~~ Misunderstood

> * adding a chapter after the intro to be actually able to jump there in the player

Should be done in core code (not in IE), but seems reasonable. Some similar code already exists, but I think it currently only handles two chapters straddling an empty region


The adding a chapter after the intro part should still be handled by the plugin and not the core code in my opinion.
Handing over a list of chapters should be a reasonable one and not one to do guess work by the core.

The first part is just to fix bad crunchyroll quality control issues. I agree that there should be some error handling for negative values and probably also for end time < start time in core, but that's a bit different.

Resolving patch:
diff --git a/yt_dlp/extractor/crunchyroll.py b/yt_dlp/extractor/crunchyroll.py
--- a/yt_dlp/extractor/crunchyroll.py
+++ b/yt_dlp/extractor/crunchyroll.py
@@ -1,4 +1,5 @@
 import base64
+import uuid
 
 from .common import InfoExtractor
 from ..networking.exceptions import HTTPError
@@ -7,12 +8,11 @@
     float_or_none,
     format_field,
     int_or_none,
-    join_nonempty,
+    jwt_decode_hs256,
     parse_age_limit,
     parse_count,
     parse_iso8601,
     qualities,
-    remove_start,
     time_seconds,
     traverse_obj,
     url_or_none,
@@ -27,6 +27,7 @@ class CrunchyrollBaseIE(InfoExtractor):
     _AUTH_HEADERS = None
     _API_ENDPOINT = None
     _BASIC_AUTH = None
+    _IS_PREMIUM = None
     _CLIENT_ID = ('cr_web', 'noaihdevm_6iyg0a8l0q')
     _LOCALE_LOOKUP = {
         'ar': 'ar-SA',
@@ -84,11 +85,16 @@ def _update_auth(self):
             self.write_debug(f'Using cxApiParam={cx_api_param}')
             CrunchyrollBaseIE._BASIC_AUTH = 'Basic ' + base64.b64encode(f'{cx_api_param}:'.encode()).decode()
 
-        grant_type = 'etp_rt_cookie' if self.is_logged_in else 'client_id'
+        auth_headers = {'Authorization': CrunchyrollBaseIE._BASIC_AUTH}
+        if self.is_logged_in:
+            grant_type = 'etp_rt_cookie'
+        else:
+            grant_type = 'client_id'
+            auth_headers['ETP-Anonymous-ID'] = uuid.uuid4()
         try:
             auth_response = self._download_json(
                 f'{self._BASE_URL}/auth/v1/token', None, note=f'Authenticating with grant_type={grant_type}',
-                headers={'Authorization': CrunchyrollBaseIE._BASIC_AUTH}, data=f'grant_type={grant_type}'.encode())
+                headers=auth_headers, data=f'grant_type={grant_type}'.encode())
         except ExtractorError as error:
             if isinstance(error.cause, HTTPError) and error.cause.status == 403:
                 raise ExtractorError(
@@ -97,6 +103,7 @@ def _update_auth(self):
                     'and your browser\'s User-Agent (with --user-agent)', expected=True)
             raise
 
+        CrunchyrollBaseIE._IS_PREMIUM = 'cr_premium' in traverse_obj(auth_response, ('access_token', {jwt_decode_hs256}, 'benefits', ...))
         CrunchyrollBaseIE._AUTH_HEADERS = {'Authorization': auth_response['token_type'] + ' ' + auth_response['access_token']}
         CrunchyrollBaseIE._AUTH_REFRESH = time_seconds(seconds=traverse_obj(auth_response, ('expires_in', {float_or_none}), default=300) - 10)
 
@@ -135,62 +142,72 @@ def _call_api(self, path, internal_id, lang, note='api', query={}):
             raise ExtractorError(f'Unexpected response when downloading {note} JSON')
         return result
 
-    def _extract_formats(self, stream_response, display_id=None):
-        requested_formats = self._configuration_arg('format') or ['vo_adaptive_hls']
-        available_formats = {}
-        for stream_type, streams in traverse_obj(
-                stream_response, (('streams', ('data', 0)), {dict.items}, ...)):
-            if stream_type not in requested_formats:
+    def _extract_chapters(self, internal_id):
+        # if no skip events are available, a 403 xml error is returned
+        skip_events = self._download_json(
+            f'https://static.crunchyroll.com/skip-events/production/{internal_id}.json',
+            internal_id, note='Downloading chapter info', fatal=False, errnote=False)
+        if not skip_events:
+            return None
+
+        chapters = []
+        for event in ('recap', 'intro', 'credits', 'preview'):
+            start = traverse_obj(skip_events, (event, 'start', {float_or_none}))
+            end = traverse_obj(skip_events, (event, 'end', {float_or_none}))
+            # some chapters have no start and/or ending time, they will just be ignored
+            if start is None or end is None:
                 continue
-            for stream in traverse_obj(streams, lambda _, v: v['url']):
-                hardsub_lang = stream.get('hardsub_locale') or ''
-                format_id = join_nonempty(stream_type, format_field(stream, 'hardsub_locale', 'hardsub-%s'))
-                available_formats[hardsub_lang] = (stream_type, format_id, hardsub_lang, stream['url'])
+            chapters.append({'title': event.capitalize(), 'start_time': start, 'end_time': end})
+
+        return chapters
+
+    def _extract_stream(self, identifier, display_id=None):
+        if not display_id:
+            display_id = identifier
+
+        self._update_auth()
+        stream_response = self._download_json(
+            f'https://cr-play-service.prd.crunchyrollsvc.com/v1/{identifier}/console/switch/play',
+            display_id, note='Downloading stream info', headers=CrunchyrollBaseIE._AUTH_HEADERS)
+
+        available_formats = {'': ('', '', stream_response['url'])}
+        for hardsub_lang, stream in traverse_obj(stream_response, ('hardSubs', {dict.items}, lambda _, v: v[1]['url'])):
+            available_formats[hardsub_lang] = (f'hardsub-{hardsub_lang}', hardsub_lang, stream['url'])
 
         requested_hardsubs = [('' if val == 'none' else val) for val in (self._configuration_arg('hardsub') or ['none'])]
-        if '' in available_formats and 'all' not in requested_hardsubs:
+        hardsub_langs = [lang for lang in available_formats if lang]
+        if hardsub_langs and 'all' not in requested_hardsubs:
             full_format_langs = set(requested_hardsubs)
+            self.to_screen(f'Available hardsub languages: {", ".join(hardsub_langs)}')
             self.to_screen(
-                'To get all formats of a hardsub language, use '
+                'To extract formats of a hardsub language, use '
                 '"--extractor-args crunchyrollbeta:hardsub=<language_code or all>". '
                 'See https://github.com/yt-dlp/yt-dlp#crunchyrollbeta-crunchyroll for more info',
                 only_once=True)
         else:
             full_format_langs = set(map(str.lower, available_formats))
 
-        audio_locale = traverse_obj(stream_response, ((None, 'meta'), 'audio_locale'), get_all=False)
+        audio_locale = traverse_obj(stream_response, ('audioLocale', {str}))
         hardsub_preference = qualities(requested_hardsubs[::-1])
-        formats = []
-        for stream_type, format_id, hardsub_lang, stream_url in available_formats.values():
-            if stream_type.endswith('hls'):
-                if hardsub_lang.lower() in full_format_langs:
-                    adaptive_formats = self._extract_m3u8_formats(
-                        stream_url, display_id, 'mp4', m3u8_id=format_id,
-                        fatal=False, note=f'Downloading {format_id} HLS manifest')
-                else:
-                    adaptive_formats = (self._m3u8_meta_format(stream_url, ext='mp4', m3u8_id=format_id),)
-            elif stream_type.endswith('dash'):
-                adaptive_formats = self._extract_mpd_formats(
-                    stream_url, display_id, mpd_id=format_id,
-                    fatal=False, note=f'Downloading {format_id} MPD manifest')
+        formats, subtitles = [], {}
+        for format_id, hardsub_lang, stream_url in available_formats.values():
+            if hardsub_lang.lower() in full_format_langs:
+                adaptive_formats, dash_subs = self._extract_mpd_formats_and_subtitles(
+                    stream_url, display_id, mpd_id=format_id, headers=CrunchyrollBaseIE._AUTH_HEADERS,
+                    fatal=False, note=f'Downloading {f"{format_id} " if hardsub_lang else ""}MPD manifest')
+                self._merge_subtitles(dash_subs, target=subtitles)
             else:
-                self.report_warning(f'Encountered unknown stream_type: {stream_type!r}', display_id, only_once=True)
-                continue
+                continue  # XXX: Update this if/when meta mpd formats are working
             for f in adaptive_formats:
                 if f.get('acodec') != 'none':
                     f['language'] = audio_locale
                 f['quality'] = hardsub_preference(hardsub_lang.lower())
             formats.extend(adaptive_formats)
 
-        return formats
-
-    def _extract_subtitles(self, data):
-        subtitles = {}
+        for locale, subtitle in traverse_obj(stream_response, (('subtitles', 'captions'), {dict.items}, ...)):
+            subtitles.setdefault(locale, []).append(traverse_obj(subtitle, {'url': 'url', 'ext': 'format'}))
 
-        for locale, subtitle in traverse_obj(data, ((None, 'meta'), 'subtitles', {dict.items}, ...)):
-            subtitles[locale] = [traverse_obj(subtitle, {'url': 'url', 'ext': 'format'})]
-
-        return subtitles
+        return formats, subtitles
 
 
 class CrunchyrollCmsBaseIE(CrunchyrollBaseIE):
@@ -245,7 +262,11 @@ class CrunchyrollBetaIE(CrunchyrollCmsBaseIE):
             'like_count': int,
             'dislike_count': int,
         },
-        'params': {'skip_download': 'm3u8', 'format': 'all[format_id~=hardsub]'},
+        'params': {
+            'skip_download': 'm3u8',
+            'extractor_args': {'crunchyrollbeta': {'hardsub': ['de-DE']}},
+            'format': 'bv[format_id~=hardsub]',
+        },
     }, {
         # Premium only
         'url': 'https://www.crunchyroll.com/watch/GYE5WKQGR',
@@ -306,6 +327,7 @@ class CrunchyrollBetaIE(CrunchyrollCmsBaseIE):
             'thumbnail': r're:^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
         },
         'params': {'skip_download': 'm3u8'},
+        'skip': 'no longer exists',
     }, {
         'url': 'https://www.crunchyroll.com/watch/G62PEZ2E6',
         'info_dict': {
@@ -359,31 +381,15 @@ def entries():
         else:
             raise ExtractorError(f'Unknown object type {object_type}')
 
-        # There might be multiple audio languages for one object (`<object>_metadata.versions`),
-        # so we need to get the id from `streams_link` instead or we dont know which language to choose
-        streams_link = response.get('streams_link')
-        if not streams_link and traverse_obj(response, (f'{object_type}_metadata', 'is_premium_only')):
+        if not self._IS_PREMIUM and traverse_obj(response, (f'{object_type}_metadata', 'is_premium_only')):
             message = f'This {object_type} is for premium members only'
             if self.is_logged_in:
                 raise ExtractorError(message, expected=True)
             self.raise_login_required(message)
 
-        # We need go from unsigned to signed api to avoid getting soft banned
-        stream_response = self._call_cms_api_signed(remove_start(
-            streams_link, '/content/v2/cms/'), internal_id, lang, 'stream info')
-        result['formats'] = self._extract_formats(stream_response, internal_id)
-        result['subtitles'] = self._extract_subtitles(stream_response)
+        result['formats'], result['subtitles'] = self._extract_stream(internal_id)
 
-        # if no intro chapter is available, a 403 without usable data is returned
-        intro_chapter = self._download_json(
-            f'https://static.crunchyroll.com/datalab-intro-v2/{internal_id}.json',
-            internal_id, note='Downloading chapter info', fatal=False, errnote=False)
-        if isinstance(intro_chapter, dict):
-            result['chapters'] = [{
-                'title': 'Intro',
-                'start_time': float_or_none(intro_chapter.get('startTime')),
-                'end_time': float_or_none(intro_chapter.get('endTime')),
-            }]
+        result['chapters'] = self._extract_chapters(internal_id)
 
         def calculate_count(item):
             return parse_count(''.join((item['displayed'], item.get('unit') or '')))
@@ -512,7 +518,7 @@ class CrunchyrollMusicIE(CrunchyrollBaseIE):
             'display_id': 'egaono-hana',
             'title': 'Egaono Hana',
             'track': 'Egaono Hana',
-            'artist': 'Goose house',
+            'artists': ['Goose house'],
             'thumbnail': r're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
             'genres': ['J-Pop'],
         },
@@ -525,11 +531,12 @@ class CrunchyrollMusicIE(CrunchyrollBaseIE):
             'display_id': 'crossing-field',
             'title': 'Crossing Field',
             'track': 'Crossing Field',
-            'artist': 'LiSA',
+            'artists': ['LiSA'],
             'thumbnail': r're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
             'genres': ['Anime'],
         },
         'params': {'skip_download': 'm3u8'},
+        'skip': 'no longer exists',
     }, {
         'url': 'https://www.crunchyroll.com/watch/concert/MC2E2AC135',
         'info_dict': {
@@ -538,7 +545,7 @@ class CrunchyrollMusicIE(CrunchyrollBaseIE):
             'display_id': 'live-is-smile-always-364joker-at-yokohama-arena',
             'title': 'LiVE is Smile Always-364+JOKER- at YOKOHAMA ARENA',
             'track': 'LiVE is Smile Always-364+JOKER- at YOKOHAMA ARENA',
-            'artist': 'LiSA',
+            'artists': ['LiSA'],
             'thumbnail': r're:(?i)^https://www.crunchyroll.com/imgsrv/.*\.jpeg?$',
             'description': 'md5:747444e7e6300907b7a43f0a0503072e',
             'genres': ['J-Pop'],
@@ -566,16 +573,14 @@ def _real_extract(self, url):
         if not response:
             raise ExtractorError(f'No video with id {internal_id} could be found (possibly region locked?)', expected=True)
 
-        streams_link = response.get('streams_link')
-        if not streams_link and response.get('isPremiumOnly'):
+        if not self._IS_PREMIUM and response.get('isPremiumOnly'):
             message = f'This {response.get("type") or "media"} is for premium members only'
             if self.is_logged_in:
                 raise ExtractorError(message, expected=True)
             self.raise_login_required(message)
 
         result = self._transform_music_response(response)
-        stream_response = self._call_api(streams_link, internal_id, lang, 'stream info')
-        result['formats'] = self._extract_formats(stream_response, internal_id)
+        result['formats'], _ = self._extract_stream(f'music/{internal_id}', internal_id)
 
         return result
 
@@ -587,7 +592,7 @@ def _transform_music_response(data):
                 'display_id': 'slug',
                 'title': 'title',
                 'track': 'title',
-                'artist': ('artist', 'name'),
+                'artists': ('artist', 'name', all),
                 'description': ('description', {str}, {lambda x: x.replace(r'\r\n', '\n') or None}),
                 'thumbnails': ('images', ..., ..., {
                     'url': ('source', {url_or_none}),
@@ -611,7 +616,7 @@ class CrunchyrollArtistIE(CrunchyrollBaseIE):
         'info_dict': {
             'id': 'MA179CB50D',
             'title': 'LiSA',
-            'genres': ['J-Pop', 'Anime', 'Rock'],
+            'genres': ['Anime', 'J-Pop', 'Rock'],
             'description': 'md5:16d87de61a55c3f7d6c454b73285938e',
         },
         'playlist_mincount': 83,


Test patch: