Repository: yt-dlp/yt-dlp

Issue:
Twitch VODs: preserve category
### Checklist

- [X] I'm requesting a site-specific feature
- [X] I've verified that I'm running yt-dlp version **2022.07.18** ([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 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

_No response_

### Example URLs

http://www.twitch.tv/riotgames/v/6528877

### Provide a description that is worded well enough to be understood

The category of the downloaded stream is archived as chapters, but only when the stream had multiple categories over its lifetime.
If the category was the same from the start to the end, the chapter information returned by Twitch will be empty.

Currently this means, that if the category didn't change in a stream, then its category is not saved.

I was thinking on how to fix this, but I'm not sure which approach would be appropriate:
- `chapter` with a single item - it would work, but in this case Twitch does not communicate any chapters, so it needs to be obtained in an other way
- `categories` - put the main- and subcategory here, e.g. `["Games", "League of Legends"]`
- `alt_title` with the name of the category - probably not good, because what if Twitch later starts using alternative titles

The 3rd option is probably not an option.
The first would be better because then every downloaded VOD would consistently have chapters with the appropriate category.
The second might be more appropriate, though, but a few things are not clear to me in how would they be appropriate:
- what should I do if the category was changed any time? Only include what Twitch provides (usually the category that was last set, in other words the last chapter), or put all categories here from the chapter information? 
- If the answer is to put all categories there, how to store them if not only the subcategory (e.g. the League of Legends -> Minecraft), but the main category changed too? To this one, a few solutions I was thinking of:
  - `["Games", "League of Legends", "Games", "Minecraft", "Creative", "Art"]`
  - `["Games/League of Legends", "Games/Minecraft", "Creative/Art"]`

Something to consider: Twitch seems to handle the [Creative](https://www.twitch.tv/directory/creative) and [IRL](https://www.twitch.tv/directory/irl) main categories as the Game category: check the URLs of listed subcategories of them. This means that differentiating between Games, IRL and Creative subcategories might not be straightforward.

### 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] Copy the WHOLE output (starting with `[debug] Command-line config`) and insert it below

### Complete Verbose Output

```shell
[debug] Command-line config: ['http://www.twitch.tv/riotgames/v/6528877', '--sub-langs', 'all,live_chat', '--verbose', '--write-info-json', '--output', 'H:\\temp\\%(extractor_key)s\\%(uploader)s\\%(upload_date)s_%(uploader)s_%(title)s_%(resolution)s-%(fps)dfps.%(ext)s', '--skip-download']
[debug] User config "C:\Users\XXXX\AppData\Roaming\yt-dlp\config.txt": ['--output', 'H:\\Videos-Streams\\%(extractor_key)s\\%(uploader)s\\%(upload_date)s_%(uploader)s_%(title)s_%(resolution)s-%(fps)dfps.%(ext)s', '-f', 'bestvideo[height<=?1080]+bestaudio/best', '--no-overwrites', '--write-description', '--write-info-json', '--console-title', '--merge-output-format', 'mkv', '--add-metadata', '--ignore-errors', '--retries', '200', '--fragment-retries', 'infinite', '--write-sub', '--write-auto-sub', '--sub-lang', 'en,en-us,hu,live_chat', '--sub-format', 'vtt', '--embed-subs', '--embed-metadata', '--embed-thumbnail', '--concurrent-fragments', '4', '--prefer-free-formats', '--remux-video', 'mkv', '--write-comments', '-N', '32']
[debug] Encodings: locale cp1252, fs utf-8, pref cp1252, out UTF-8 (No ANSI), error UTF-8 (No ANSI), screen UTF-8 (No ANSI)
[debug] yt-dlp version 2022.07.18 [135f05ef6] (source)
[debug] Lazy loading extractors is disabled
[debug] Plugins: ['SamplePluginIE', 'SamplePluginPP']
[debug] Git HEAD: 7de38008f
[debug] Python 3.7.2 (CPython 64bit) - Windows-10-XXXXXX-SP0 
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: ffmpeg 4.3.2-2021-02-27-full_build-www.gyan.dev, ffprobe 4.3.2-2021-02-27-full_build-www.gyan.dev
[debug] Optional libraries: Cryptodome-3.11.0, mutagen-1.45.1, sqlite3-2.6.0, websockets-10.0
[debug] Proxy map: {}
[debug] [twitch:vod] Extracting URL: http://www.twitch.tv/riotgames/v/6528877
[twitch:vod] 6528877: Downloading stream metadata GraphQL
[twitch:vod] 6528877: Downloading video access token GraphQL
[twitch:vod] 6528877: Downloading m3u8 information
[twitch:vod] 6528877: Downloading storyboard metadata JSON
[twitch:vod] Downloading chat fragment JSONs
[twitch:vod] 6528877: Downloading chat fragment JSON page 1
[debug] Formats sorted by: hasvid, ie_pref, lang, quality, res, fps, hdr:12(7), vcodec:vp9.2(10), acodec, filesize, fs_approx, tbr, vbr, abr, asr, proto, vext, aext, hasaud, source, id
[twitch:vod] Extracted 0 chat messages
[info] v6528877: Downloading 1 format(s): 720p-1
WARNING: There's no video description to write
[info] Downloading video thumbnail 0 ...
[info] Writing video thumbnail 0 to: H:\temp\TwitchVod\Riot Games\20150624_Riot Games_LCK Summer Split - Week 6 Day 1_1280x720-29fps.jpg
[info] Writing video metadata as JSON to: H:\temp\TwitchVod\Riot Games\20150624_Riot Games_LCK Summer Split - Week 6 Day 1_1280x720-29fps.info.json
```



Discussion:
I think the chapter idea makes sense and is the most consistent with what we already have

Resolving patch:
diff --git a/yt_dlp/extractor/twitch.py b/yt_dlp/extractor/twitch.py
--- a/yt_dlp/extractor/twitch.py
+++ b/yt_dlp/extractor/twitch.py
@@ -204,7 +204,13 @@ class TwitchVodIE(TwitchBaseIE):
             'uploader_id': 'riotgames',
             'view_count': int,
             'start_time': 310,
-            'chapters': [],
+            'chapters': [
+                {
+                    'start_time': 0,
+                    'end_time': 17208,
+                    'title': 'League of Legends'
+                }
+            ],
             'live_status': 'was_live',
         },
         'params': {
@@ -321,6 +327,33 @@ class TwitchVodIE(TwitchBaseIE):
             'format': 'mhtml',
             'skip_download': True
         }
+    }, {
+        'note': 'VOD with single chapter',
+        'url': 'https://www.twitch.tv/videos/1536751224',
+        'info_dict': {
+            'id': 'v1536751224',
+            'ext': 'mp4',
+            'title': 'Porter Robinson Star Guardian Stream Tour with LilyPichu',
+            'duration': 8353,
+            'uploader': 'Riot Games',
+            'uploader_id': 'riotgames',
+            'timestamp': 1658267731,
+            'upload_date': '20220719',
+            'chapters': [
+                {
+                    'start_time': 0,
+                    'end_time': 8353,
+                    'title': 'League of Legends'
+                }
+            ],
+            'live_status': 'was_live',
+            'thumbnail': r're:^https?://.*\.jpg$',
+            'view_count': int,
+        },
+        'params': {
+            'skip_download': True
+        },
+        'expected_warnings': ['Unable to download JSON metadata: HTTP Error 403: Forbidden']
     }]
 
     def _download_info(self, item_id):
@@ -392,8 +425,14 @@ def _extract_info(self, info):
             'was_live': True,
         }
 
-    def _extract_moments(self, info, item_id):
-        for moment in info.get('moments') or []:
+    def _extract_chapters(self, info, item_id):
+        if not info.get('moments'):
+            game = traverse_obj(info, ('game', 'displayName'))
+            if game:
+                yield {'title': game}
+            return
+
+        for moment in info['moments']:
             start_time = int_or_none(moment.get('positionMilliseconds'), 1000)
             duration = int_or_none(moment.get('durationMilliseconds'), 1000)
             name = str_or_none(moment.get('description'))
@@ -432,7 +471,7 @@ def _extract_info_gql(self, info, item_id):
             'uploader_id': try_get(info, lambda x: x['owner']['login'], compat_str),
             'timestamp': unified_timestamp(info.get('publishedAt')),
             'view_count': int_or_none(info.get('viewCount')),
-            'chapters': list(self._extract_moments(info, item_id)),
+            'chapters': list(self._extract_chapters(info, item_id)),
             'is_live': is_live,
             'was_live': True,
         }


Test patch: