"""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 ''