Die Folgen eines YouTube-Kanals werden gehört wie Podcasts; unterbrochen wird man darin von der Werbung und vom Schluss. Beides markieren andere Hörer bei SponsorBlock: podcasts/sponsor.py fragt je Folge einmal danach (nur sponsor und outro, nur Teile, die übersprungen werden, kein „full“) und legt die Antwort an der Folge ab (PodcastEpisode.sponsor); GET /podcasts/episodes/<id>/sponsor/ antwortet damit. Eine Antwort gilt eine Woche, ein Fehlschlag eine Stunde. Der Player springt über solche Teile (podcasts-web.js für Browser und Desktop, in der App später der eigene Player); der Schalter heißt skip_sponsor, steht bei „Stille überspringen“ im Abspiel- und Tempomenü, ist an und wirkt nur bei YouTube-Folgen (default on). e2e/sponsor.spec.js spielt es in Chromium durch; sw v155
144 lines
5.7 KiB
Python
144 lines
5.7 KiB
Python
"""Skip the ad and the outro of a YouTube episode: what SponsorBlock knows about it.
|
|
|
|
A YouTube channel is a podcast here (docs/youtube.md): such an episode's audio_url is
|
|
``https://www.youtube.com/watch?v=<11-character id>``, and its sound belongs to the app —
|
|
the server never fetches it. What goes *out* of the sound is other people's work:
|
|
SponsorBlock (sponsor.ajay.app) lets listeners mark a video's parts by category, and the
|
|
two that hurt listening are the advertisement and the closing words. The server asks once
|
|
per episode and keeps the answer on it (PodcastEpisode.sponsor); the player then jumps over
|
|
those parts (podcasts-web.js for the browser and the desktop app, the app's own player for
|
|
Android).
|
|
|
|
Only those two categories are asked for, and only parts a listener skips: nothing about
|
|
intros, self-promotion, interaction reminders or non-music sections, and no part marked
|
|
``full`` — the whole video sponsored means there would be no sound left.
|
|
"""
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
import requests
|
|
|
|
from django.utils import timezone
|
|
|
|
from . import youtube as youtube_mod
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
VERSION = 1
|
|
API = 'https://sponsor.ajay.app/api/skipSegments'
|
|
CATEGORIES = ('sponsor', 'outro')
|
|
TIMEOUT = (3, 8) # connect, read: a player waits for this, so keep it short
|
|
MIN_SPAN = 0.3 # s: a shorter part isn't worth a jump
|
|
STALE = 7 * 24 * 3600 # s: an answer is asked again this long after (segments get added)
|
|
STALE_ERROR = 3600 # s: a failed ask is tried again sooner (it may have been the network)
|
|
|
|
|
|
def video_id_of(audio_url):
|
|
"""The video an episode is, judged by its address (the same reading as youtube.py)."""
|
|
return youtube_mod.video_id_in(audio_url or '')
|
|
|
|
|
|
def _spans(segments):
|
|
"""[[from, to], …] in seconds: the parts of `segments` that go, in order, merged.
|
|
|
|
What SponsorBlock answered for the categories asked for, as far as it is usable: a
|
|
segment has a start and an end, `skip` or `mute` means it goes (a jump is quieter than
|
|
muting and the player jumps anyway), and two parts that touch are one.
|
|
"""
|
|
found = []
|
|
for seg in segments or []:
|
|
if not isinstance(seg, dict):
|
|
continue
|
|
if seg.get('category') not in CATEGORIES:
|
|
continue
|
|
if seg.get('actionType', 'skip') not in ('skip', 'mute'):
|
|
continue
|
|
span = seg.get('segment')
|
|
if not isinstance(span, (list, tuple)) or len(span) != 2:
|
|
continue
|
|
try:
|
|
start, end = float(span[0]), float(span[1])
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not (start >= 0 and end > start) or end - start < MIN_SPAN or end != end: # NaN
|
|
continue
|
|
found.append([start, end])
|
|
found.sort()
|
|
out = []
|
|
for start, end in found:
|
|
if out and start <= out[-1][1] + 0.001:
|
|
out[-1][1] = max(out[-1][1], end)
|
|
else:
|
|
out.append([start, end])
|
|
return [[round(a, 3), round(b, 3)] for a, b in out]
|
|
|
|
|
|
def fetch(video_id):
|
|
"""[[from, to], …] for the video, from SponsorBlock. Raises what the asking raised."""
|
|
r = requests.get(API, params={'videoID': video_id, 'categories': list(CATEGORIES)},
|
|
timeout=TIMEOUT, headers={'User-Agent': 'diora podcast sponsorblock'})
|
|
if r.status_code == 404: # nobody has marked this video: not an error
|
|
return []
|
|
if r.status_code >= 400:
|
|
raise RuntimeError(f'HTTP {r.status_code}')
|
|
try:
|
|
data = r.json()
|
|
except ValueError:
|
|
raise RuntimeError('not JSON')
|
|
if not isinstance(data, list):
|
|
raise RuntimeError('unexpected answer')
|
|
return _spans(data)
|
|
|
|
|
|
def ask(video_id):
|
|
"""The result to store on the episode: the parts that go, or why they weren't found."""
|
|
try:
|
|
return {'v': VERSION, 'spans': fetch(video_id), 'at': timezone.now().isoformat()}
|
|
except Exception as e: # unreachable API, timeout, nonsense: remembered, retried later
|
|
log.warning('sponsor parts of video %s failed: %s', video_id, e)
|
|
return {'v': VERSION, 'spans': [], 'error': str(e)[:200] or type(e).__name__,
|
|
'at': timezone.now().isoformat()}
|
|
|
|
|
|
def current(stored):
|
|
"""Whether a stored result still counts (else the episode is asked again).
|
|
|
|
A part that goes doesn't change; new ones appear, though, so an episode is asked again
|
|
after a week — and a failure after an hour, since it was probably the network.
|
|
"""
|
|
if not stored or stored.get('v') != VERSION or 'spans' not in stored:
|
|
return False
|
|
age = STALE_ERROR if stored.get('error') else STALE
|
|
at = stored.get('at')
|
|
if not at:
|
|
return False
|
|
try:
|
|
when = datetime.fromisoformat(at)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if timezone.is_naive(when):
|
|
when = timezone.make_aware(when)
|
|
return (timezone.now() - when).total_seconds() < age
|
|
|
|
|
|
def spans_of(episode):
|
|
"""[[from, to], …]: what to leave out of this episode, asking SponsorBlock if need be.
|
|
|
|
Nothing at all for an episode that isn't a video (SponsorBlock knows only YouTube), and
|
|
nothing when the asking fails. The episode is only handed back changed through the DB
|
|
(`update`), so a caller that keeps the row doesn't have to save it.
|
|
"""
|
|
from .models import PodcastEpisode
|
|
empty = {'ready': True, 'v': VERSION, 'spans': []}
|
|
video_id = video_id_of(episode.audio_url)
|
|
if not video_id:
|
|
return empty
|
|
stored = episode.sponsor
|
|
if not current(stored):
|
|
stored = ask(video_id)
|
|
PodcastEpisode.objects.filter(pk=episode.pk).update(sponsor=stored)
|
|
episode.sponsor = stored
|
|
out = dict(empty, spans=stored.get('spans', []))
|
|
if stored.get('error'):
|
|
out['error'] = stored['error']
|
|
return out
|