Repository: yt-dlp/yt-dlp

Issue:
[Feature Request] Add support for Triller
### DO NOT REMOVE OR SKIP THE ISSUE TEMPLATE

- [X] I understand that I will be **blocked** if I 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 **2022.08.19** ([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/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

- Account: https://triller.co/@charlidamelio
- Single video: https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc
- 
On each page load, a file called `media.ts` is downloaded, this seems to contain the full video. Example URL: https://uploads.cdn.triller.co/v1/uploads/71621277/hls/media-5/media.ts



### Provide a description that is worded well enough to be understood

(I'm not sure what goes here)

[Triller](https://en.wikipedia.org/wiki/Triller_(app)) is a short video website, very similar to TikTok. `yt-dlp` already supports Tiktok, so I figured it would make sense to also support Triller.

### 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: ['-v', 'https://triller.co/@charlidamelio']
[debug] Encodings: locale UTF-8, fs utf-8, pref UTF-8, out utf-8, error utf-8, screen utf-8
[debug] yt-dlp version 2022.08.19 [48c88e0] (zip)
[debug] Python 3.10.4 (CPython 64bit) - Linux-5.15.0-46-generic-x86_64-with-glibc2.35 (glibc 2.35)
[debug] Checking exe version: ffmpeg -bsfs
[debug] Checking exe version: ffprobe -bsfs
[debug] exe versions: ffmpeg 4.4.2 (setts), ffprobe 4.4.2
[debug] Optional libraries: brotli-1.0.9, certifi-2020.06.20, secretstorage-3.3.1, sqlite3-2.6.0
[debug] Proxy map: {}
[debug] [generic] Extracting URL: https://triller.co/@charlidamelio
[generic] @charlidamelio: Downloading webpage
WARNING: [generic] Falling back on generic information extractor
[generic] @charlidamelio: Extracting information
[debug] Looking for Brightcove embeds
[debug] Looking for embeds
ERROR: Unsupported URL: https://triller.co/@charlidamelio
Traceback (most recent call last):
  File "/home/raleigh/Downloads/./yt-dlp/yt_dlp/YoutubeDL.py", line 1442, in wrapper
    return func(self, *args, **kwargs)
  File "/home/raleigh/Downloads/./yt-dlp/yt_dlp/YoutubeDL.py", line 1518, in __extract_info
    ie_result = ie.extract(url)
  File "/home/raleigh/Downloads/./yt-dlp/yt_dlp/extractor/common.py", line 666, in extract
    ie_result = self._real_extract(url)
  File "/home/raleigh/Downloads/./yt-dlp/yt_dlp/extractor/generic.py", line 3077, in _real_extract
    raise UnsupportedError(url)
yt_dlp.utils.UnsupportedError: Unsupported URL: https://triller.co/@charlidamelio
```



Discussion:

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
@@ -1787,6 +1787,10 @@
 from .toutv import TouTvIE
 from .toypics import ToypicsUserIE, ToypicsIE
 from .traileraddict import TrailerAddictIE
+from .triller import (
+    TrillerIE,
+    TrillerUserIE,
+)
 from .trilulilu import TriluliluIE
 from .trovo import (
     TrovoIE,
diff --git a/yt_dlp/extractor/triller.py b/yt_dlp/extractor/triller.py
new file mode 100644
--- /dev/null
+++ b/yt_dlp/extractor/triller.py
@@ -0,0 +1,304 @@
+import itertools
+import json
+
+from .common import InfoExtractor
+from ..utils import (
+    int_or_none,
+    str_or_none,
+    traverse_obj,
+    unified_strdate,
+    unified_timestamp,
+    url_basename,
+    ExtractorError,
+)
+
+
+class TrillerBaseIE(InfoExtractor):
+    _NETRC_MACHINE = 'triller'
+    _AUTH_TOKEN = None
+    _API_BASE_URL = 'https://social.triller.co/v1.5'
+
+    def _perform_login(self, username, password):
+        if self._AUTH_TOKEN:
+            return
+
+        user_check = self._download_json(
+            f'{self._API_BASE_URL}/api/user/is-valid-username', None, note='Checking username',
+            fatal=False, expected_status=400, headers={
+                'Content-Type': 'application/json',
+                'Origin': 'https://triller.co',
+            }, data=json.dumps({'username': username}, separators=(',', ':')).encode('utf-8'))
+        if user_check.get('status'):  # endpoint returns "status":false if username exists
+            raise ExtractorError('Unable to login: Invalid username', expected=True)
+
+        credentials = {
+            'username': username,
+            'password': password,
+        }
+        login = self._download_json(
+            f'{self._API_BASE_URL}/user/auth', None, note='Logging in',
+            fatal=False, expected_status=400, headers={
+                'Content-Type': 'application/json',
+                'Origin': 'https://triller.co',
+            }, data=json.dumps(credentials, separators=(',', ':')).encode('utf-8'))
+        if not login.get('auth_token'):
+            if login.get('error') == 1008:
+                raise ExtractorError('Unable to login: Incorrect password', expected=True)
+            raise ExtractorError('Unable to login')
+
+        self._AUTH_TOKEN = login['auth_token']
+
+    def _get_comments(self, video_id, limit=15):
+        comment_info = self._download_json(
+            f'{self._API_BASE_URL}/api/videos/{video_id}/comments_v2',
+            video_id, fatal=False, note='Downloading comments API JSON',
+            headers={'Origin': 'https://triller.co'}, query={'limit': limit}) or {}
+        if not comment_info.get('comments'):
+            return
+        for comment_dict in comment_info['comments']:
+            yield {
+                'author': traverse_obj(comment_dict, ('author', 'username')),
+                'author_id': traverse_obj(comment_dict, ('author', 'user_id')),
+                'id': comment_dict.get('id'),
+                'text': comment_dict.get('body'),
+                'timestamp': unified_timestamp(comment_dict.get('timestamp')),
+            }
+
+    def _check_user_info(self, user_info):
+        if not user_info:
+            self.report_warning('Unable to extract user info')
+        elif user_info.get('private') and not user_info.get('followed_by_me'):
+            raise ExtractorError('This video is private', expected=True)
+        elif traverse_obj(user_info, 'blocked_by_user', 'blocking_user'):
+            raise ExtractorError('The author of the video is blocked', expected=True)
+        return user_info
+
+    def _parse_video_info(self, video_info, username, user_info=None):
+        video_uuid = video_info.get('video_uuid')
+        video_id = video_info.get('id')
+
+        formats = []
+        video_url = traverse_obj(video_info, 'video_url', 'stream_url')
+        if video_url:
+            formats.append({
+                'url': video_url,
+                'ext': 'mp4',
+                'vcodec': 'h264',
+                'width': video_info.get('width'),
+                'height': video_info.get('height'),
+                'format_id': url_basename(video_url).split('.')[0],
+                'filesize': video_info.get('filesize'),
+            })
+        video_set = video_info.get('video_set') or []
+        for video in video_set:
+            resolution = video.get('resolution') or ''
+            formats.append({
+                'url': video['url'],
+                'ext': 'mp4',
+                'vcodec': video.get('codec'),
+                'vbr': int_or_none(video.get('bitrate'), 1000),
+                'width': int_or_none(resolution.split('x')[0]),
+                'height': int_or_none(resolution.split('x')[1]),
+                'format_id': url_basename(video['url']).split('.')[0],
+            })
+        audio_url = video_info.get('audio_url')
+        if audio_url:
+            formats.append({
+                'url': audio_url,
+                'ext': 'm4a',
+                'format_id': url_basename(audio_url).split('.')[0],
+            })
+
+        manifest_url = video_info.get('transcoded_url')
+        if manifest_url:
+            formats.extend(self._extract_m3u8_formats(
+                manifest_url, video_id, 'mp4', entry_protocol='m3u8_native',
+                m3u8_id='hls', fatal=False))
+        self._sort_formats(formats)
+
+        comment_count = int_or_none(video_info.get('comment_count'))
+
+        user_info = user_info or traverse_obj(video_info, 'user', default={})
+
+        return {
+            'id': str_or_none(video_id) or video_uuid,
+            'title': video_info.get('description') or f'Video by {username}',
+            'thumbnail': video_info.get('thumbnail_url'),
+            'description': video_info.get('description'),
+            'uploader': str_or_none(username),
+            'uploader_id': str_or_none(user_info.get('user_id')),
+            'creator': str_or_none(user_info.get('name')),
+            'timestamp': unified_timestamp(video_info.get('timestamp')),
+            'upload_date': unified_strdate(video_info.get('timestamp')),
+            'duration': int_or_none(video_info.get('duration')),
+            'view_count': int_or_none(video_info.get('play_count')),
+            'like_count': int_or_none(video_info.get('likes_count')),
+            'artist': str_or_none(video_info.get('song_artist')),
+            'track': str_or_none(video_info.get('song_title')),
+            'webpage_url': f'https://triller.co/@{username}/video/{video_uuid}',
+            'uploader_url': f'https://triller.co/@{username}',
+            'extractor_key': TrillerIE.ie_key(),
+            'extractor': TrillerIE.IE_NAME,
+            'formats': formats,
+            'comment_count': comment_count,
+            '__post_extractor': self.extract_comments(video_id, comment_count),
+        }
+
+
+class TrillerIE(TrillerBaseIE):
+    _VALID_URL = r'''(?x)
+            https?://(?:www\.)?triller\.co/
+            @(?P<username>[\w\._]+)/video/
+            (?P<id>[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})
+        '''
+    _TESTS = [{
+        'url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
+        'md5': '228662d783923b60d78395fedddc0a20',
+        'info_dict': {
+            'id': '71595734',
+            'ext': 'mp4',
+            'title': 'md5:9a2bf9435c5c4292678996a464669416',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+            'description': 'md5:9a2bf9435c5c4292678996a464669416',
+            'uploader': 'theestallion',
+            'uploader_id': '18992236',
+            'creator': 'Megan Thee Stallion',
+            'timestamp': 1660598222,
+            'upload_date': '20220815',
+            'duration': 47,
+            'height': 3840,
+            'width': 2160,
+            'view_count': int,
+            'like_count': int,
+            'artist': 'Megan Thee Stallion',
+            'track': 'Her',
+            'webpage_url': 'https://triller.co/@theestallion/video/2358fcd7-3df2-4c77-84c8-1d091610a6cf',
+            'uploader_url': 'https://triller.co/@theestallion',
+            'comment_count': int,
+        }
+    }, {
+        'url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
+        'md5': '874055f462af5b0699b9dbb527a505a0',
+        'info_dict': {
+            'id': '71621339',
+            'ext': 'mp4',
+            'title': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+            'description': 'md5:4c91ea82760fe0fffb71b8c3aa7295fc',
+            'uploader': 'charlidamelio',
+            'uploader_id': '1875551',
+            'creator': 'charli damelio',
+            'timestamp': 1660773354,
+            'upload_date': '20220817',
+            'duration': 16,
+            'height': 1920,
+            'width': 1080,
+            'view_count': int,
+            'like_count': int,
+            'artist': 'Dixie',
+            'track': 'Someone to Blame',
+            'webpage_url': 'https://triller.co/@charlidamelio/video/46c6fcfa-aa9e-4503-a50c-68444f44cddc',
+            'uploader_url': 'https://triller.co/@charlidamelio',
+            'comment_count': int,
+        }
+    }]
+
+    def _real_extract(self, url):
+        username, video_uuid = self._match_valid_url(url).group('username', 'id')
+
+        video_info = traverse_obj(self._download_json(
+            f'{self._API_BASE_URL}/api/videos/{video_uuid}',
+            video_uuid, note='Downloading video info API JSON',
+            errnote='Unable to download video info API JSON',
+            headers={
+                'Origin': 'https://triller.co',
+            }), ('videos', 0))
+        if not video_info:
+            raise ExtractorError('No video info found in API response')
+
+        user_info = self._check_user_info(video_info.get('user') or {})
+        return self._parse_video_info(video_info, username, user_info)
+
+
+class TrillerUserIE(TrillerBaseIE):
+    _VALID_URL = r'https?://(?:www\.)?triller\.co/@(?P<id>[\w\._]+)/?(?:$|[#?])'
+    _TESTS = [{
+        # first videos request only returns 2 videos
+        'url': 'https://triller.co/@theestallion',
+        'playlist_mincount': 9,
+        'info_dict': {
+            'id': '18992236',
+            'title': 'theestallion',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+        }
+    }, {
+        'url': 'https://triller.co/@charlidamelio',
+        'playlist_mincount': 25,
+        'info_dict': {
+            'id': '1875551',
+            'title': 'charlidamelio',
+            'thumbnail': r're:^https://uploads\.cdn\.triller\.co/.+\.jpg$',
+        }
+    }]
+
+    def _real_initialize(self):
+        if not self._AUTH_TOKEN:
+            guest = self._download_json(
+                f'{self._API_BASE_URL}/user/create_guest',
+                None, note='Creating guest session', data=b'', headers={
+                    'Origin': 'https://triller.co',
+                }, query={
+                    'platform': 'Web',
+                    'app_version': '',
+                })
+            if not guest.get('auth_token'):
+                raise ExtractorError('Unable to fetch required auth token for user extraction')
+
+            self._AUTH_TOKEN = guest['auth_token']
+
+    def _extract_video_list(self, username, user_id, limit=6):
+        query = {
+            'limit': limit,
+        }
+        for page in itertools.count(1):
+            for retry in self.RetryManager():
+                try:
+                    video_list = self._download_json(
+                        f'{self._API_BASE_URL}/api/users/{user_id}/videos',
+                        username, note=f'Downloading user video list page {page}',
+                        errnote='Unable to download user video list', headers={
+                            'Authorization': f'Bearer {self._AUTH_TOKEN}',
+                            'Origin': 'https://triller.co',
+                        }, query=query)
+                except ExtractorError as e:
+                    if isinstance(e.cause, json.JSONDecodeError) and e.cause.pos == 0:
+                        retry.error = e
+                        continue
+                    raise
+            if not video_list.get('videos'):
+                break
+            yield from video_list['videos']
+            query['before_time'] = traverse_obj(video_list, ('videos', -1, 'timestamp'))
+            if not query['before_time']:
+                break
+
+    def _entries(self, videos, username, user_info):
+        for video in videos:
+            yield self._parse_video_info(video, username, user_info)
+
+    def _real_extract(self, url):
+        username = self._match_id(url)
+        user_info = self._check_user_info(self._download_json(
+            f'{self._API_BASE_URL}/api/users/by_username/{username}',
+            username, note='Downloading user info',
+            errnote='Failed to download user info', headers={
+                'Authorization': f'Bearer {self._AUTH_TOKEN}',
+                'Origin': 'https://triller.co',
+            }).get('user', {}))
+
+        user_id = str_or_none(user_info.get('user_id'))
+        videos = self._extract_video_list(username, user_id)
+        thumbnail = user_info.get('avatar_url')
+
+        return self.playlist_result(
+            self._entries(videos, username, user_info), user_id, username, thumbnail=thumbnail)


Test patch: