188 lines
7.1 KiB
Python
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 ''
|