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

Code before:
    IE_NAME = 'agalega:series'
    _VALID_URL = r'https?://(?:www\.)?agalega\.gal/videos/category/(?P<id>[0-9]+)'
    _TESTS = [{
        'url': 'https://www.agalega.gal/videos/category/27035-galician-friki',
        'info_dict': {
            'id': '27035',
            'title': 'Galician Friki',
            'description': 'md5:68279845eea70ba3b4324bd96e3aac30',
            'thumbnail': 'https://crtvg-bucket.flumotion.cloud/category_cards/f1d4e23fa336467ba9b1330f6224c589.jpg',
        },
        'playlist_count': 7,
    },
        {
        'url': 'https://www.agalega.gal/videos/category/17175-na-gloria?cat=17176',
        'info_dict': {
            'id': '17175',
            'title': 'Na Gloria',
            'description': 'Na parroquia da Gloria vivíase ben... ata que chegaron unha madrileña e un andaluz e abriron un hotel "refinado".\n',
            'thumbnail': 'https://crtvg-bucket.flumotion.cloud/category_cards/a54fd2bcfbde439f88bb0f537335fc2a.png',
        },
        'playlist_count': 17,
    },
    ]

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

        if re.search(r'(?:(programas|peliculas-e-documentais|informativos-g24|deportes|xeracion-zeta))', url, re.I):
            raise ExtractorError('''This category contains too many items.
            Please select a specific subcategory or an individual program instead.''', expected=True)

        category_url = f'https://api-agalega.interactvty.com/api/2.0/contents/category/{video_id}/?optional_fields=image,short_description,is_premium,description'
        sub_category_url = 'https://api-agalega.interactvty.com/api/2.0/contents/subcategory/{category_id}/?optional_fields=image'
        category_contents_url = 'https://api-agalega.interactvty.com/api/2.0/contents/category_contents/{category_id}/?optional_fields=image,short_description'
        media_url = 'https://api-agalega.interactvty.com/api/2.0/contents/content_resources/{video_id}/?optional_fields=media_url'
        auth_header = self._get_access_token(video_id)
        if not auth_header:
            raise ExtractorError('Failed to get access token')

        category_raw = self._download_json(category_url, video_id, headers=auth_header)
        if not category_raw:
            raise ExtractorError('Failed to download category data')

        sub_category_raw = self._download_json(
            sub_category_url.format(category_id=video_id),
            video_id,
            headers=auth_header,
            fatal=False,
            expected_status=404,
        )


Review comment (suggestion):
Let's use `playlist_id`, since this isn't really a video
```suggestion
        playlist_id = self._match_id(url)
```

Code after:
import json
import time

from .common import InfoExtractor
from ..utils import jwt_decode_hs256, url_or_none
from ..utils.traversal import traverse_obj


class AGalegaBaseIE(InfoExtractor):
    _access_token = None

    @staticmethod
    def _jwt_is_expired(token):
        return jwt_decode_hs256(token)['exp'] - time.time() < 120

    def _refresh_access_token(self, video_id):
        AGalegaBaseIE._access_token = self._download_json(
            'https://www.agalega.gal/api/fetch-api/jwt/token', video_id,
            note='Downloading access token',
            data=json.dumps({
                'username': None,
                'password': None,
                'client': 'crtvg',
                'checkExistsCookies': False,
            }).encode())['access']

    def _call_api(self, endpoint, display_id, note, fatal=True, query=None):
        if not AGalegaBaseIE._access_token or self._jwt_is_expired(AGalegaBaseIE._access_token):
            self._refresh_access_token(endpoint)
        return self._download_json(
            f'https://api-agalega.interactvty.com/api/2.0/contents/{endpoint}', display_id,
            note=note, fatal=fatal, query=query,
            headers={'Authorization': f'jwtok {AGalegaBaseIE._access_token}'})


class AGalegaIE(AGalegaBaseIE):
    IE_NAME = 'agalega:videos'
    _VALID_URL = r'https?://(?:www\.)?agalega\.gal/videos/(?:detail/)?(?P<id>[0-9]+)'
    _TESTS = [{
        'url': 'https://www.agalega.gal/videos/288664-lr-ninguencheconta',
        'md5': '04533a66c5f863d08dd9724b11d1c223',
        'info_dict': {
            'id': '288664',
            'title': 'Roberto e Ángel Martín atenden consultas dos espectadores',
            'description': 'O cómico ademais fai un repaso dalgúns momentos da súa traxectoria profesional',
            'thumbnail': 'https://crtvg-bucket.flumotion.cloud/content_cards/2ef32c3b9f6249d9868fd8f11d389d3d.png',
            'ext': 'mp4',
        },
    }, {
        'url': 'https://www.agalega.gal/videos/detail/296152-pulso-activo-7',
        'md5': '26df7fdcf859f38ad92d837279d6b56d',
        'info_dict': {
            'id': '296152',
            'title': 'Pulso activo | 18-11-2025',
            'description': 'Anxo, Noemí, Silvia e Estrella  comparten as sensacións da clase de Eddy.',
            'thumbnail': 'https://crtvg-bucket.flumotion.cloud/content_cards/a6bb7da6c8994b82bf961ac6cad1707b.png',
            'ext': 'mp4',
        },
    }]

    def _real_extract(self, url):
        video_id = self._match_id(url)
        content_data = self._call_api(
            f'content/{video_id}/', video_id, note='Downloading content data', fatal=False,
            query={
                'optional_fields': 'image,is_premium,short_description,has_subtitle',
            })
        resource_data = self._call_api(
            f'content_resources/{video_id}/', video_id, note='Downloading resource data',
            query={
                'optional_fields': 'media_url',
            })

        formats = []
        subtitles = {}
        for m3u8_url in traverse_obj(resource_data, ('results', ..., 'media_url', {url_or_none})):
            fmts, subs = self._extract_m3u8_formats_and_subtitles(
                m3u8_url, video_id, ext='mp4', m3u8_id='hls')
            formats.extend(fmts)
            self._merge_subtitles(subs, target=subtitles)

        return {
            'id': video_id,
            'formats': formats,
            'subtitles': subtitles,
            **traverse_obj(content_data, {
                'title': ('name', {str}),
                'description': (('description', 'short_description'), {str}, any),
                'thumbnail': ('image', {url_or_none}),
            }),
        }