diora-web/podcasts/test_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

196 lines
8.5 KiB
Python

"""Skipping the ad and the outro of a YouTube episode (sponsor.py)."""
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from . import sponsor
from .models import PodcastEpisode, PodcastFeed
VIDEO = 'https://www.youtube.com/watch?v=aqz-KE-bpKQ'
VID = 'aqz-KE-bpKQ'
def seg(start, end, category='sponsor', action='skip'):
return {'UUID': f'{category}-{start}', 'category': category, 'actionType': action,
'segment': [start, end], 'votes': 10, 'videoDuration': 600}
class SpanTests(TestCase):
"""What is taken out of SponsorBlock's answer."""
def test_only_sponsor_and_outro(self):
self.assertEqual(sponsor._spans([
seg(10, 20), seg(30, 40, 'outro'),
seg(50, 60, 'intro'), seg(70, 80, 'selfpromo'),
seg(90, 100, 'interaction'), seg(110, 120, 'music_offtopic'),
]), [[10, 20], [30, 40]])
def test_full_and_poi_segments_are_left_alone(self):
# "full": the whole video is a sponsor — jumping would leave no sound at all.
self.assertEqual(sponsor._spans([seg(0, 600, 'sponsor', 'full')]), [])
def test_mute_counts_as_skipped(self):
self.assertEqual(sponsor._spans([seg(10, 20, 'sponsor', 'mute')]), [[10, 20]])
def test_touching_parts_become_one(self):
self.assertEqual(sponsor._spans([seg(10, 20), seg(20, 30)]), [[10, 30]])
self.assertEqual(sponsor._spans([seg(10, 20), seg(19.5, 30)]), [[10, 30]])
def test_order_does_not_matter(self):
self.assertEqual(sponsor._spans([seg(30, 40, 'outro'), seg(10, 20)]), [[10, 20], [30, 40]])
def test_nonsense_is_ignored(self):
self.assertEqual(sponsor._spans([
seg(10, 20), 'nonsense', {}, {'category': 'sponsor'}, {'category': 'sponsor', 'segment': [1]},
{'category': 'sponsor', 'segment': ['a', 'b']}, {'category': 'sponsor', 'segment': [20, 10]},
{'category': 'sponsor', 'segment': [-5, 5]}, seg(30, 30.1), # too short to be worth it
]), [[10, 20]])
def test_nothing_at_all(self):
self.assertEqual(sponsor._spans(None), [])
self.assertEqual(sponsor._spans([]), [])
def test_the_video_id_of_an_episode(self):
self.assertEqual(sponsor.video_id_of(VIDEO), VID)
self.assertEqual(sponsor.video_id_of(f'https://example.org/podcasts/{VID}.mp3'), VID)
self.assertEqual(sponsor.video_id_of('https://example.org/folge.mp3'), '')
class FetchTests(TestCase):
def setUp(self):
self.answers = []
def get(url, **kw):
self.asked = (url, kw.get('params'), kw.get('timeout'))
return self.answers.pop(0)
self.get = get
def _answer(self, status=200, body=None):
class R:
status_code = status
def json(_self):
return body
r = R()
r.status_code = status
return r
def test_a_video_nobody_marked_is_not_an_error(self):
self.answers = [self._answer(404)]
with patch('podcasts.sponsor.requests.get', self.get):
self.assertEqual(sponsor.fetch(VID), [])
url, params, timeout = self.asked
self.assertEqual(url, sponsor.API)
self.assertEqual(params['videoID'], VID)
self.assertEqual(params['categories'], ['sponsor', 'outro'])
self.assertEqual(timeout, sponsor.TIMEOUT)
def test_what_the_api_answers_is_read(self):
self.answers = [self._answer(200, [seg(60, 90, 'outro'), seg(10, 20)])]
with patch('podcasts.sponsor.requests.get', self.get):
self.assertEqual(sponsor.fetch(VID), [[10, 20], [60, 90]])
def test_a_broken_answer_is_an_error(self):
for answer in (self._answer(500), self._answer(200, {'error': 'nope'})):
self.answers = [answer]
with patch('podcasts.sponsor.requests.get', self.get):
with self.assertRaises(RuntimeError):
sponsor.fetch(VID)
def test_ask_remembers_an_error_instead_of_raising(self):
def boom(url, **kw):
raise OSError('no route to host')
with patch('podcasts.sponsor.requests.get', boom):
got = sponsor.ask(VID)
self.assertEqual(got['spans'], [])
self.assertEqual(got['v'], sponsor.VERSION)
self.assertIn('no route', got['error'])
self.assertTrue(got['at'])
def test_a_stored_answer_is_used_until_it_is_old(self):
now = timezone.now().isoformat()
fresh = {'v': sponsor.VERSION, 'spans': [[1, 2]], 'at': now}
self.assertTrue(sponsor.current(fresh))
self.assertFalse(sponsor.current(None))
self.assertFalse(sponsor.current({}))
self.assertFalse(sponsor.current({'v': sponsor.VERSION - 1, 'spans': [], 'at': now}))
self.assertFalse(sponsor.current({'v': sponsor.VERSION, 'spans': []})) # no "at"
def test_a_failure_is_asked_again_sooner_than_an_answer(self):
def ago(seconds):
return (timezone.now() - timedelta(seconds=seconds)).isoformat()
old = {'v': sponsor.VERSION, 'spans': [[1, 2]], 'at': ago(sponsor.STALE + 60)}
self.assertFalse(sponsor.current(old))
self.assertTrue(sponsor.current(dict(old, at=ago(sponsor.STALE - 60))))
failed = {'v': sponsor.VERSION, 'spans': [], 'error': 'HTTP 500', 'at': ago(sponsor.STALE_ERROR + 60)}
self.assertFalse(sponsor.current(failed))
class EpisodeSponsorViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='alice', password='pw12345678')
self.client.force_login(self.user)
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://www.youtube.com/feeds/videos.xml?channel_id=UCx',
title='Kanal')
self.video = PodcastEpisode.objects.create(
feed=self.feed, guid=f'yt:video:{VID}', title='Eine Folge', audio_url=VIDEO,
pub_date=timezone.now())
self.other = PodcastEpisode.objects.create(
feed=self.feed, guid='e1', title='Eine Folge', audio_url='https://example.org/e1.mp3',
pub_date=timezone.now())
self.answers = []
def _get(self, url, **kw):
return self.answers.pop(0)
def _answer(self, body):
class R:
status_code = 200
def json(_self):
return body
return R()
def url(self, ep):
return f'/podcasts/episodes/{ep.id}/sponsor/'
def test_a_video_is_asked_once_and_the_answer_is_kept(self):
self.answers = [self._answer([seg(10, 20), seg(60, 90, 'outro')])]
with patch('podcasts.sponsor.requests.get', self._get):
d = self.client.get(self.url(self.video)).json()
self.assertEqual(d, {'ready': True, 'v': sponsor.VERSION, 'spans': [[10, 20], [60, 90]]})
self.assertEqual(PodcastEpisode.objects.get(pk=self.video.pk).sponsor['spans'], [[10, 20], [60, 90]])
# Kept: the second call doesn't ask again.
with patch('podcasts.sponsor.requests.get', self._get):
d = self.client.get(self.url(self.video)).json()
self.assertEqual(d['spans'], [[10, 20], [60, 90]])
def test_a_failed_ask_answers_empty_and_is_remembered(self):
def boom(url, **kw):
raise OSError('timed out')
with patch('podcasts.sponsor.requests.get', boom):
d = self.client.get(self.url(self.video)).json()
self.assertEqual(d['spans'], [])
self.assertEqual(d['error'], 'timed out')
self.assertIn('error', PodcastEpisode.objects.get(pk=self.video.pk).sponsor)
def test_an_episode_that_is_no_video_is_never_asked_for(self):
with patch('podcasts.sponsor.requests.get', self._get):
d = self.client.get(self.url(self.other)).json()
self.assertEqual(d, {'ready': True, 'v': sponsor.VERSION, 'spans': []})
self.assertIsNone(PodcastEpisode.objects.get(pk=self.other.pk).sponsor)
def test_a_foreign_episode_is_not_found(self):
other = User.objects.create_user(username='bob', password='pw12345678')
feed = PodcastFeed.objects.create(user=other, rss_url='https://example.org/f', title='F')
ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url=VIDEO,
pub_date=timezone.now())
self.assertEqual(self.client.get(self.url(ep)).status_code, 404)
def test_asking_without_a_login_is_refused(self):
self.client.logout()
self.assertEqual(self.client.get(self.url(self.video)).status_code, 401)