"""YouTube channels (and playlists) as podcasts. The server only reads what YouTube publishes about a channel: its page once, when it's subscribed (feed address, name, picture), and from then on its RSS feed like any podcast's. It never fetches a video or its sound: an episode's address is the video's page (https://www.youtube.com/watch?v=…), and each app finds the audio stream for it itself and plays only that (docs/youtube.md). Hence no skipping of silence and no ID3 chapters found on the server for them. Shorts are left out: a minute of sound cut from a vertical video isn't an episode. """ import html import re import urllib.parse import requests WATCH = 'https://www.youtube.com/watch?v=' FEED = 'https://www.youtube.com/feeds/videos.xml' HOSTS = {'youtube.com', 'www.youtube.com', 'm.youtube.com', 'music.youtube.com', 'youtu.be'} TIMEOUT = 15 MAX_PAGE = 4 * 1024 * 1024 # Without this, a server in the EU gets the cookie consent page instead (SOCS=CAI: declined). COOKIES = {'SOCS': 'CAI'} HEADERS = {'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0', 'Accept-Language': 'de,en;q=0.8'} _CHANNEL_ID = re.compile(r'^UC[\w-]{22}$') _VIDEO_ID = re.compile(r'^[\w-]{11}$') # Where a page names its own channel, surest first: a channel page in its head, a video's in # its videoDetails. _OWN_CHANNEL = ( r'"]+)') def description_html(text): """A video description (plain text) as shownotes: links clickable, lines kept.""" escaped = html.escape(text or '', quote=False) linked = _LINK.sub(lambda m: f'{m.group(1)}', escaped) return '

' + linked.strip().replace('\n\n', '

').replace('\n', '
') + '

' if linked.strip() else '' def _page(url): r = requests.get(url, headers=HEADERS, cookies=COOKIES, timeout=TIMEOUT, stream=True) try: if r.status_code >= 400: raise NotAChannel(f'HTTP {r.status_code}') body = r.raw.read(MAX_PAGE, decode_content=True) finally: r.close() return body.decode('utf-8', 'replace') def _meta(page, prop): m = re.search(r' 1 and _CHANNEL_ID.match(segs[1]): page_url = f'https://www.youtube.com/channel/{segs[1]}' elif parts.hostname == 'youtu.be' and segs: page_url = WATCH + segs[0] elif segs and (segs[0].startswith('@') or segs[0] in ('c', 'user', 'watch', 'shorts', 'live')): page_url = urllib.parse.urlunsplit(('https', 'www.youtube.com', parts.path, parts.query, '')) else: raise NotAChannel('not a channel address') try: page = _page(page_url) except requests.RequestException as e: raise NotAChannel(type(e).__name__) # The page's own channel, never the first one named (featured channels come earlier). m = None for pattern in _OWN_CHANNEL: m = re.search(pattern, page) if m: break if not m: raise NotAChannel('no channel found') is_channel_page = not (segs and segs[0] in ('watch', 'shorts', 'live')) and parts.hostname != 'youtu.be' return { 'feed': channel_feed(m.group(1)), # A video's page is about the video: its channel's name and picture come with the feed. 'title': _meta(page, 'og:title') if is_channel_page else '', 'artwork': _meta(page, 'og:image') if is_channel_page else '', } def feed_channel_id(channel): """The channel a YouTube feed (feedparser's .feed) belongs to, '' for another feed.""" for url in (channel.get('link') or '', (channel.get('author_detail') or {}).get('href') or ''): m = re.match(r'^https://www\.youtube\.com/channel/(UC[\w-]{22})$', url) if m: return m.group(1) return '' def channel_artwork(channel_id): """The channel's picture (the feed has none), '' if it can't be had.""" try: return _meta(_page(f'https://www.youtube.com/channel/{channel_id}'), 'og:image') except (requests.RequestException, NotAChannel): return ''