diora-web/podcasts/sponsor.py
mrwnslz 1194472572
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m56s
Test / e2e (push) Failing after 1m33s
Podcasts: Werbung und Schluss in YouTube-Folgen überspringen (SponsorBlock)
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
2026-10-25 16:18:24 +01:00

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