diora-web/podcasts/youtube.py
mrwnslz a3673a6042
All checks were successful
Test / e2e (push) Successful in 1m53s
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m35s
YouTube: Kanal eines Videos auch über externalChannelId finden
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:15:42 +02:00

188 lines
7.1 KiB
Python

"""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'<link rel="canonical" href="https://www\.youtube\.com/channel/(UC[\w-]{22})"',
r'type="application/rss\+xml" title="RSS" href="[^"]*channel_id=(UC[\w-]{22})"',
r'"externalId":"(UC[\w-]{22})"',
r'<meta itemprop="identifier" content="(UC[\w-]{22})"',
r'"videoDetails":\{[^{}]*?"channelId":"(UC[\w-]{22})"',
r'"externalChannelId":"(UC[\w-]{22})"', # a video's owner, in its microformat
r'"channelId":"(UC[\w-]{22})","isOwnerViewing"', # videoDetails with braces before it
)
class NotAChannel(Exception):
pass
def _host(url):
try:
return (urllib.parse.urlsplit(url).hostname or '').lower()
except ValueError:
return ''
def is_youtube(url):
"""An address on YouTube (a channel, a video, a playlist, a feed)."""
return _host(url) in HOSTS
def is_feed(url):
return is_youtube(url) and urllib.parse.urlsplit(url).path == '/feeds/videos.xml'
def is_video(audio_url):
"""An episode that is a YouTube video (its sound is found by the app)."""
return audio_url.startswith(WATCH)
def channel_feed(channel_id):
return f'{FEED}?channel_id={channel_id}'
def video_url(entry):
"""The episode address for a YouTube feed entry, '' for a Short (or not a video)."""
vid = entry.get('yt_videoid') or ''
if not _VIDEO_ID.match(vid) or '/shorts/' in (entry.get('link') or ''):
return ''
return WATCH + vid
def video_id_in(audio_url):
"""The video an episode is, judged by its address: a watch page, or a file named after
the video (as servers that turn channels into podcasts name them), '' otherwise."""
if is_video(audio_url):
return audio_url[len(WATCH):]
path = urllib.parse.urlsplit(audio_url).path
m = re.search(r'/([\w-]{11})\.(?:mp3|m4a|mp4|webm|opus|ogg|aac)$', path)
return m.group(1) if m else ''
def entry_artwork(entry):
thumbs = entry.get('media_thumbnail') or []
return (thumbs[0].get('url') or '') if thumbs else ''
_LINK = re.compile(r'(https?://[^\s<>"]+)')
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'<a href="{m.group(1)}">{m.group(1)}</a>', escaped)
return '<p>' + linked.strip().replace('\n\n', '</p><p>').replace('\n', '<br>') + '</p>' 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'<meta property="%s" content="([^"]*)"' % re.escape(prop), page)
return html.unescape(m.group(1)) if m else ''
def resolve(url):
"""{'feed', 'title', 'artwork'} for a YouTube address someone wants to subscribe to:
a channel (/@name, /channel/…, /c/…, /user/…), a playlist, or a video (its channel).
Raises NotAChannel when it leads to none."""
parts = urllib.parse.urlsplit(url.strip())
query = urllib.parse.parse_qs(parts.query)
path = parts.path.rstrip('/')
if path == '/feeds/videos.xml':
return {'feed': url.strip(), 'title': '', 'artwork': ''}
if 'list' in query and path in ('/playlist', '/watch'):
playlist = query['list'][0]
if not re.match(r'^[\w-]{10,64}$', playlist):
raise NotAChannel('playlist id')
page = ''
try:
page = _page(f'https://www.youtube.com/playlist?list={playlist}')
except (requests.RequestException, NotAChannel):
pass
return {'feed': f'{FEED}?playlist_id={playlist}', 'title': _meta(page, 'og:title'),
'artwork': _meta(page, 'og:image')}
segs = [s for s in path.split('/') if s]
if segs[:1] == ['channel'] and len(segs) > 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 ''