Repository: yt-dlp/yt-dlp
File: yt_dlp/extractor/beacon.py (Python)

Code before:

class BeaconTvIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?beacon\.tv/content/(?P<id>.+)'

    _TESTS = [{
        'url': 'https://beacon.tv/content/welcome-to-beacon',
        'md5': 'b3f5932d437f288e662f10f3bfc5bd04',
        'info_dict': {
            'id': 'welcome-to-beacon',
            'ext': 'mp4',
            'upload_date': '20240509',
            'description': 'md5:ea2bd32e71acf3f9fca6937412cc3563',
            'thumbnail': 'https://imagedelivery.net/_18WuG-hYMyVquF6KgU_xQ/critrole-beacon-staging-backend/beacon_trailer_thumbnail_beacon_website-5-600x600.png/format=auto',
            'title': 'Your home for Critical Role!',
            'timestamp': 1715227200,
        }
    }]

    def _real_extract(self, url):
        video_id = self._match_id(url)
        webpage = self._download_webpage(url, video_id)

        json_data_string = self._html_search_regex(r'<script id="__NEXT_DATA__" type="application/json">(.+)</script>', webpage, 'JSON Body')
        json_data = self._parse_json(json_data_string, video_id)

        state = traverse_obj(json_data, ('props', 'pageProps', '__APOLLO_STATE__'))

        content_data = None
        for key, value in state.items():
            # We can be given many different content objects, we want the one where the slug matches the video ID.
            if key.startswith('Content') and traverse_obj(value, ('slug')) == video_id:
                content_data = value
                break

        # If the user is not authenticated, and this video is not public, the content will be hidden. In this case show an error to the user.
        if content_data is None:
            raise ExtractorError('Failed to find content data. Either the given content is not a video, or it requires authentication', expected=True)
        if content_data['contentVideo'] is None:
            raise ExtractorError('Failed to find content video. Either the given content is not a video, or it requires authentication', expected=True)

        # Apollo GraphQL quirk, works with references. We grab the thumbnail reference so we
        thumbnail_ref = traverse_obj(content_data, ('thumbnail', '__ref'))
        image_data = None
        if thumbnail_ref is not None:
            image_data = traverse_obj(state, (thumbnail_ref))

        # Prefer landscape thumbnail
        thumbnail_url = traverse_obj(image_data, ('sizes', 'landscape', 'url'))
        # If not found, try for square thumbnail
        if thumbnail_url is None:
            thumbnail_url = traverse_obj(image_data, ('sizes', 'square', 'url'))

Review comment (suggestion):
Since this data is mandatory, we might as well:
```suggestion
        state = self._search_nextjs_data(webpage, video_id)['props']['pageProps']['__APOLLO_STATE__']
```

Code after:
)


class BeaconTvIE(InfoExtractor):
    _VALID_URL = r'https?://(?:www\.)?beacon\.tv/content/(?P<id>[\w-]+)'

    _TESTS = [{
        'url': 'https://beacon.tv/content/welcome-to-beacon',
        'md5': 'b3f5932d437f288e662f10f3bfc5bd04',
        'info_dict': {
            'id': 'welcome-to-beacon',
            'ext': 'mp4',
            'upload_date': '20240509',
            'description': 'md5:ea2bd32e71acf3f9fca6937412cc3563',
            'thumbnail': 'https://cdn.jwplayer.com/v2/media/I4CkkEvN/poster.jpg?width=720',
            'title': 'Your home for Critical Role!',
            'timestamp': 1715227200,
            'duration': 105.494,
        },
    }, {
        'url': 'https://beacon.tv/content/re-slayers-take-trailer',
        'md5': 'd879b091485dbed2245094c8152afd89',
        'info_dict': {
            'id': 're-slayers-take-trailer',
            'ext': 'mp4',
            'title': 'The Re-Slayer’s Take | Official Trailer',
            'timestamp': 1715189040,
            'upload_date': '20240508',
            'duration': 53.249,
            'thumbnail': 'https://cdn.jwplayer.com/v2/media/PW5ApIw3/poster.jpg?width=720',
        },
    }]

    def _real_extract(self, url):
        video_id = self._match_id(url)
        webpage = self._download_webpage(url, video_id)

        content_data = traverse_obj(self._search_nextjs_data(webpage, video_id), (
            'props', 'pageProps', '__APOLLO_STATE__',
            lambda k, v: k.startswith('Content:') and v['slug'] == video_id, any))
        if not content_data:
            raise ExtractorError('Failed to extract content data')

        jwplayer_data = traverse_obj(content_data, (
            (('contentVideo', 'video', 'videoData'),
             ('contentPodcast', 'podcast', 'audioData')), {json.loads}, {dict}, any))
        if not jwplayer_data:
            if content_data.get('contentType') not in ('videoPodcast', 'video', 'podcast'):
                raise ExtractorError('Content is not a video/podcast', expected=True)
            if traverse_obj(content_data, ('contentTier', '__ref')) != 'MemberTier:65b258d178f89be87b4dc0a4':
                self.raise_login_required('This video/podcast is for members only')