Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
import feedparser
|
|
|
|
|
from django.contrib.auth.models import User
|
|
|
|
|
from django.test import TestCase
|
|
|
|
|
|
|
|
|
|
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
from .views import FeedFetch, _refresh_feed
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
|
|
|
|
|
_parse = feedparser.parse
|
|
|
|
|
|
|
|
|
|
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
def _ok(rss, **kw):
|
|
|
|
|
"""What _fetch_feed returns for a 200 with this RSS."""
|
|
|
|
|
return FeedFetch(200, _parse(rss), **kw)
|
|
|
|
|
|
|
|
|
|
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
def _rss(items):
|
|
|
|
|
entries = ''.join(
|
|
|
|
|
f'<item><guid>{guid}</guid><title>{guid}</title><pubDate>{date}</pubDate>'
|
|
|
|
|
f'<enclosure url="https://example.org/{guid}.mp3" type="audio/mpeg" length="1"/></item>'
|
|
|
|
|
for guid, date in items
|
|
|
|
|
)
|
|
|
|
|
return f'<?xml version="1.0"?><rss version="2.0"><channel><title>Feed</title>{entries}</channel></rss>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QueuePositionTests(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://example.org/feed', title='Feed')
|
|
|
|
|
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
self.eps = [
|
|
|
|
|
PodcastEpisode.objects.create(
|
|
|
|
|
feed=self.feed, guid=f'e{i}', title=f'E{i}',
|
|
|
|
|
audio_url=f'https://example.org/e{i}.mp3', pub_date=base + timedelta(days=i),
|
|
|
|
|
)
|
|
|
|
|
for i in range(6)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def queue(self):
|
|
|
|
|
return list(PodcastQueue.objects.filter(user=self.user).order_by('position').values_list('episode_id', flat=True))
|
|
|
|
|
|
|
|
|
|
def add(self, *episodes, **extra):
|
|
|
|
|
body = {'episode_ids': [e.id for e in episodes], **extra}
|
|
|
|
|
return self.client.post('/podcasts/queue/add/', json.dumps(body), content_type='application/json')
|
|
|
|
|
|
|
|
|
|
def test_default_adds_to_the_back(self):
|
|
|
|
|
self.add(self.eps[0])
|
|
|
|
|
self.add(self.eps[1])
|
|
|
|
|
self.assertEqual(self.queue(), [self.eps[0].id, self.eps[1].id])
|
|
|
|
|
|
|
|
|
|
def test_front_from_setting(self):
|
|
|
|
|
self.add(self.eps[0], self.eps[1])
|
|
|
|
|
PodcastSettings.objects.update_or_create(user=self.user, defaults={'enqueue_position': 'front'})
|
|
|
|
|
self.add(self.eps[2])
|
|
|
|
|
self.assertEqual(self.queue(), [self.eps[2].id, self.eps[0].id, self.eps[1].id])
|
|
|
|
|
|
|
|
|
|
def test_after_current_uses_client_hint(self):
|
|
|
|
|
self.add(self.eps[0], self.eps[1], self.eps[2])
|
|
|
|
|
self.add(self.eps[3], where='after_current', current_episode_id=self.eps[1].id)
|
|
|
|
|
self.assertEqual(self.queue(), [e.id for e in (self.eps[0], self.eps[1], self.eps[3], self.eps[2])])
|
|
|
|
|
|
|
|
|
|
def test_after_current_falls_back_to_last_listened(self):
|
|
|
|
|
self.add(self.eps[0], self.eps[1], self.eps[2])
|
2026-10-02 08:26:28 +02:00
|
|
|
EpisodeProgress.objects.update_or_create(user=self.user, episode=self.eps[2], defaults={'position_seconds': 30})
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
self.add(self.eps[3], where='after_current')
|
|
|
|
|
self.assertEqual(self.queue(), [e.id for e in (self.eps[0], self.eps[1], self.eps[2], self.eps[3])])
|
|
|
|
|
|
|
|
|
|
def test_after_current_without_current_goes_to_front(self):
|
|
|
|
|
self.add(self.eps[0])
|
|
|
|
|
self.add(self.eps[1], where='after_current')
|
|
|
|
|
self.assertEqual(self.queue(), [self.eps[1].id, self.eps[0].id])
|
|
|
|
|
|
|
|
|
|
def test_already_queued_stays_where_it_is(self):
|
|
|
|
|
self.add(self.eps[0], self.eps[1])
|
|
|
|
|
r = self.add(self.eps[0], where='front')
|
|
|
|
|
self.assertFalse(r.json()['created'])
|
|
|
|
|
self.assertEqual(self.queue(), [self.eps[0].id, self.eps[1].id])
|
|
|
|
|
|
|
|
|
|
def test_single_episode_id_still_works(self):
|
|
|
|
|
r = self.client.post('/podcasts/queue/add/', json.dumps({'episode_id': self.eps[4].id}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
self.assertTrue(r.json()['created'])
|
|
|
|
|
self.assertEqual(self.queue(), [self.eps[4].id])
|
|
|
|
|
|
|
|
|
|
def test_other_users_episode_is_not_found(self):
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
feed = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/b', title='B')
|
|
|
|
|
ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3')
|
|
|
|
|
r = self.add(ep)
|
|
|
|
|
self.assertEqual(r.status_code, 404)
|
|
|
|
|
|
|
|
|
|
def test_unknown_position_is_rejected(self):
|
|
|
|
|
self.assertEqual(self.add(self.eps[0], where='middle').status_code, 400)
|
|
|
|
|
|
|
|
|
|
def test_settings_roundtrip(self):
|
|
|
|
|
r = self.client.get('/podcasts/settings/')
|
|
|
|
|
self.assertEqual(r.json()['enqueue_position'], 'back')
|
|
|
|
|
self.assertEqual(r.json()['auto_queue_position'], 'front')
|
|
|
|
|
r = self.client.post('/podcasts/settings/', json.dumps({'auto_queue_position': 'after_current'}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
self.assertEqual(r.json()['auto_queue_position'], 'after_current')
|
|
|
|
|
self.assertEqual(PodcastSettings.objects.get(user=self.user).auto_queue_position, 'after_current')
|
|
|
|
|
bad = self.client.post('/podcasts/settings/', json.dumps({'enqueue_position': 'nowhere'}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
self.assertEqual(bad.status_code, 400)
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 08:26:28 +02:00
|
|
|
class InboxRuleTests(TestCase):
|
|
|
|
|
"""Queued or started episodes leave the inbox; played means the last 30 s."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/f', title='F')
|
|
|
|
|
self.eps = [PodcastEpisode.objects.create(feed=feed, guid=f'g{i}', title=f'E{i}', duration_seconds=3600,
|
|
|
|
|
audio_url=f'https://example.org/{i}.mp3') for i in range(3)]
|
|
|
|
|
|
|
|
|
|
def inbox(self):
|
|
|
|
|
return {e['id'] for e in self.client.get('/podcasts/inbox/').json()['episodes']}
|
|
|
|
|
|
|
|
|
|
def post(self, url, body):
|
|
|
|
|
return self.client.post(url, json.dumps(body), content_type='application/json')
|
|
|
|
|
|
|
|
|
|
def test_queued_and_started_leave_the_inbox(self):
|
|
|
|
|
self.assertEqual(self.inbox(), {e.id for e in self.eps})
|
|
|
|
|
self.post('/podcasts/queue/add/', {'episode_ids': [self.eps[0].id]})
|
|
|
|
|
self.post('/podcasts/progress/save/', {'episode_id': self.eps[1].id, 'position_seconds': 12})
|
|
|
|
|
self.assertEqual(self.inbox(), {self.eps[2].id})
|
|
|
|
|
p = EpisodeProgress.objects.get(user=self.user, episode=self.eps[0])
|
|
|
|
|
self.assertFalse(p.played)
|
|
|
|
|
|
|
|
|
|
def test_played_within_the_last_30_seconds(self):
|
|
|
|
|
r = self.post('/podcasts/progress/save/', {'episode_id': self.eps[0].id, 'position_seconds': 3500})
|
|
|
|
|
self.assertFalse(r.json()['played']) # 90 % but 100 s left: not yet
|
|
|
|
|
r = self.post('/podcasts/progress/save/', {'episode_id': self.eps[0].id, 'position_seconds': 3575})
|
|
|
|
|
self.assertTrue(r.json()['played'])
|
|
|
|
|
|
|
|
|
|
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
class AutoQueueTests(TestCase):
|
|
|
|
|
"""New episodes of an auto_queue feed land where the user's setting says."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.feed = PodcastFeed.objects.create(
|
|
|
|
|
user=self.user, rss_url='https://example.org/feed', title='Feed', auto_queue=True,
|
2026-10-02 07:48:45 +02:00
|
|
|
last_refreshed_at=datetime(2026, 1, 1, tzinfo=timezone.utc), # not the first fetch
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
)
|
|
|
|
|
other = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/o', title='O')
|
|
|
|
|
self.old = [
|
|
|
|
|
PodcastEpisode.objects.create(feed=other, guid=f'o{i}', title=f'O{i}', audio_url=f'https://example.org/o{i}.mp3')
|
|
|
|
|
for i in range(3)
|
|
|
|
|
]
|
|
|
|
|
for i, ep in enumerate(self.old):
|
|
|
|
|
PodcastQueue.objects.create(user=self.user, episode=ep, position=i)
|
|
|
|
|
self.rss = _rss([
|
|
|
|
|
('new1', 'Mon, 05 Jan 2026 10:00:00 GMT'),
|
|
|
|
|
('new2', 'Tue, 06 Jan 2026 10:00:00 GMT'),
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
def refresh(self):
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
with patch('podcasts.views._fetch_feed', lambda url, **kw: _ok(self.rss)):
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
_refresh_feed(self.feed)
|
|
|
|
|
order = PodcastQueue.objects.filter(user=self.user).order_by('position')
|
|
|
|
|
return [q.episode.guid for q in order]
|
|
|
|
|
|
|
|
|
|
def test_front_is_the_default(self):
|
|
|
|
|
self.assertEqual(self.refresh(), ['new2', 'new1', 'o0', 'o1', 'o2'])
|
|
|
|
|
|
|
|
|
|
def test_back_appends_oldest_first(self):
|
|
|
|
|
PodcastSettings.objects.create(user=self.user, auto_queue_position='back')
|
|
|
|
|
self.assertEqual(self.refresh(), ['o0', 'o1', 'o2', 'new1', 'new2'])
|
|
|
|
|
|
|
|
|
|
def test_after_current(self):
|
|
|
|
|
PodcastSettings.objects.create(user=self.user, auto_queue_position='after_current')
|
|
|
|
|
EpisodeProgress.objects.create(user=self.user, episode=self.old[1], position_seconds=12)
|
|
|
|
|
self.assertEqual(self.refresh(), ['o0', 'o1', 'new2', 'new1', 'o2'])
|
|
|
|
|
|
|
|
|
|
def test_feed_without_auto_queue_queues_nothing(self):
|
|
|
|
|
self.feed.auto_queue = False
|
|
|
|
|
self.feed.save()
|
|
|
|
|
self.assertEqual(self.refresh(), ['o0', 'o1', 'o2'])
|
|
|
|
|
|
|
|
|
|
|
2026-10-02 07:48:45 +02:00
|
|
|
class FirstFetchTests(TestCase):
|
|
|
|
|
"""A new subscription doesn't flood the inbox with its back catalogue."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.rss = _rss([(f'e{i}', f'Mon, {i + 1:02d} Jan 2026 10:00:00 GMT') for i in range(5)])
|
|
|
|
|
|
|
|
|
|
def subscribe(self):
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
with patch('podcasts.views._fetch_feed', lambda url, **kw: _ok(self.rss)):
|
2026-10-02 07:48:45 +02:00
|
|
|
return self.client.post('/podcasts/feeds/add/', json.dumps({'rss_url': 'https://example.org/f'}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
|
|
|
|
|
def test_only_the_newest_is_new(self):
|
|
|
|
|
self.assertEqual(self.subscribe().json()['new_episodes'], 5)
|
|
|
|
|
inbox = self.client.get('/podcasts/inbox/').json()['episodes']
|
|
|
|
|
self.assertEqual([e['title'] for e in inbox], ['e4'])
|
|
|
|
|
# The others are still listed for the podcast, not marked played.
|
|
|
|
|
feed = PodcastFeed.objects.get(user=self.user)
|
|
|
|
|
eps = self.client.get(f'/podcasts/feeds/{feed.id}/episodes/').json()['episodes']
|
|
|
|
|
self.assertEqual(len(eps), 5)
|
|
|
|
|
self.assertFalse(any(e['played'] for e in eps))
|
|
|
|
|
|
|
|
|
|
def test_later_fetches_keep_every_new_episode(self):
|
|
|
|
|
self.subscribe()
|
|
|
|
|
feed = PodcastFeed.objects.get(user=self.user)
|
|
|
|
|
self.rss = _rss([(f'e{i}', f'Mon, {i + 1:02d} Jan 2026 10:00:00 GMT') for i in range(8)])
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
with patch('podcasts.views._fetch_feed', lambda url, **kw: _ok(self.rss)):
|
2026-10-02 07:48:45 +02:00
|
|
|
_refresh_feed(feed)
|
|
|
|
|
inbox = self.client.get('/podcasts/inbox/').json()['episodes']
|
|
|
|
|
self.assertEqual([e['title'] for e in inbox], ['e7', 'e6', 'e5', 'e4'])
|
|
|
|
|
|
|
|
|
|
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
class RefreshAllTests(TestCase):
|
|
|
|
|
"""One request refreshes all feeds on the server; one request returns the state."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.a = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='A')
|
|
|
|
|
self.b = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='B')
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
self.bobs = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='A')
|
|
|
|
|
self.fetched = []
|
|
|
|
|
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
def fake_fetch(self, url, **kw):
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
self.fetched.append(url)
|
|
|
|
|
if url.endswith('/b'):
|
|
|
|
|
raise ValueError('broken feed')
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
return _ok(_rss([(url[-1] + '1', 'Mon, 05 Jan 2026 10:00:00 GMT')]))
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
|
|
|
|
|
def test_refresh_all_reports_counts_and_skips_recent(self):
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
|
|
|
|
|
self.assertEqual(r.json(), {'ok': True, 'refreshed': 1, 'failed': 1, 'new_episodes': 1})
|
|
|
|
|
self.assertEqual(sorted(self.fetched), ['https://example.org/a', 'https://example.org/b'])
|
|
|
|
|
# A was just refreshed: a second call within max_age only retries B.
|
|
|
|
|
self.fetched.clear()
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
|
|
|
|
|
self.assertEqual(self.fetched, ['https://example.org/b'])
|
|
|
|
|
|
|
|
|
|
def test_a_shared_feed_url_is_fetched_once(self):
|
|
|
|
|
from .views import _refresh_feeds
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
refreshed, failed, new = _refresh_feeds([self.a, self.bobs])
|
|
|
|
|
self.assertEqual(self.fetched, ['https://example.org/a'])
|
|
|
|
|
self.assertEqual((refreshed, failed, new), (2, 0, 2))
|
|
|
|
|
|
|
|
|
|
def test_state_in_one_response(self):
|
|
|
|
|
ep = PodcastEpisode.objects.create(feed=self.a, guid='x', title='X', audio_url='https://example.org/x.mp3')
|
|
|
|
|
PodcastQueue.objects.create(user=self.user, episode=ep, position=0)
|
|
|
|
|
r = self.client.get('/podcasts/state/')
|
|
|
|
|
data = r.json()
|
|
|
|
|
self.assertEqual({f['title'] for f in data['feeds']}, {'A', 'B'})
|
|
|
|
|
self.assertEqual([q['episode__id'] for q in data['queue']], [ep.id])
|
|
|
|
|
self.assertEqual([e['id'] for e in data['inbox']], [ep.id])
|
|
|
|
|
self.assertTrue(data['inbox'][0]['in_queue'])
|
2026-10-02 08:01:34 +02:00
|
|
|
# Shownotes stay out of the one-request state (they can be large).
|
|
|
|
|
self.assertNotIn('description', data['inbox'][0])
|
Podcasts: Position in der Warteschlange einstellbar; Feeds nur noch auf dem Server holen (SW v51)
Warteschlange
- PodcastSettings: wohin neue Folgen kommen, getrennt für Hinzufügen von
Hand (auch beim Abspielen einer Folge außerhalb der Warteschlange) und
für automatisch eingereihte neue Folgen: ans Ende, an den Anfang oder
nach die laufende Folge. Voreinstellung wie bisher (Hand: Ende,
automatisch: Anfang). GET/POST /podcasts/settings/.
- queue/add nimmt auch mehrere Folgen (episode_ids), eine Position
(where) und die laufende Folge (current_episode_id); ohne Angabe gilt
die zuletzt angehörte, nicht fertige Folge als laufende.
- Positionen werden lückenlos neu durchnummeriert.
Feeds
- POST /podcasts/feeds/refresh-all/ aktualisiert alle Feeds des Nutzers
auf dem Server: Downloads parallel, jede Feed-Adresse nur einmal (auch
über Nutzer hinweg), mit Timeout; kürzlich aktualisierte werden
übersprungen. Der stündliche Cron-Job nutzt dasselbe.
- GET /podcasts/state/ liefert Abos, Warteschlange, Inbox und
Einstellungen in einer Antwort (für die Apps).
- Web: „↻ All“ ist eine Anfrage statt einer pro Feed; die stündliche
Schleife im Browser lädt nur noch die Listen neu, statt jeden Feed
selbst anzustoßen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:26:16 +02:00
|
|
|
self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front'})
|
Podcasts: Kapitel, Shownotes und Transkripte für die Apps
- Feeds liefern jetzt Podlove-Kapitel (psc:chapters), Podcasting-2.0-Kapitel
(podcast:chapters, JSON-URL) und Transkripte (podcast:transcript, auch
mehrere); als Beschreibung wird content:encoded bevorzugt.
- Bekannte Folgen werden beim Aktualisieren nachgezogen, aber nur die Zeilen,
die sich wirklich geändert haben.
- Neuer Endpunkt GET /podcasts/episodes/<id>/ mit bereinigten Shownotes,
Zeitmarken, Kapiteln, Transkripten und Fortschritt. JSON-Kapitel werden
einmal serverseitig geholt (nur öffentliche Adressen, max. 1 MB) und an
der Folge gespeichert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:56:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Shownotes, chapters, transcripts
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
NS = (
|
|
|
|
|
'xmlns:podcast="https://podcastindex.org/namespace/1.0" '
|
|
|
|
|
'xmlns:psc="http://podlove.org/simple-chapters" '
|
|
|
|
|
'xmlns:content="http://purl.org/rss/1.0/modules/content/" '
|
|
|
|
|
'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rich_rss(items, ns=NS):
|
|
|
|
|
"""Like _rss, but each item is (guid, extra XML inside the <item>)."""
|
|
|
|
|
entries = ''.join(
|
|
|
|
|
f'<item><guid>{guid}</guid><title>{guid}</title><pubDate>Mon, 05 Jan 2026 10:00:00 GMT</pubDate>'
|
|
|
|
|
f'<enclosure url="https://example.org/{guid}.mp3" type="audio/mpeg" length="1"/>{extra}</item>'
|
|
|
|
|
for guid, extra in items
|
|
|
|
|
)
|
|
|
|
|
return f'<?xml version="1.0"?><rss version="2.0" {ns}><channel><title>Feed</title>{entries}</channel></rss>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PSC = (
|
|
|
|
|
'<psc:chapters version="1.2">'
|
|
|
|
|
'<psc:chapter start="00:00:00" title="Intro"/>'
|
|
|
|
|
'<psc:chapter start="00:01:02.500" title="Teil & zwei" href="https://x.org/2" image="https://x.org/2.jpg"/>'
|
|
|
|
|
'<psc:chapter start="1:02:03" title="Drei"/>'
|
|
|
|
|
'<psc:chapter start="12:05" title="Kurz"/>'
|
|
|
|
|
'</psc:chapters>'
|
|
|
|
|
)
|
|
|
|
|
TRANSCRIPTS = (
|
|
|
|
|
'<podcast:transcript url="https://example.org/a.vtt" type="text/vtt" language="de"/>'
|
|
|
|
|
'<podcast:transcript url="https://example.org/a.srt" type="application/srt" rel="captions"/>'
|
|
|
|
|
'<podcast:transcript url="javascript:alert(1)" type="text/html"/>'
|
|
|
|
|
)
|
|
|
|
|
CHAPTERS_JSON = {
|
|
|
|
|
'version': '1.2.0',
|
|
|
|
|
'chapters': [
|
|
|
|
|
{'startTime': 95.5, 'title': 'Zweites', 'url': 'https://x.org/b', 'img': 'https://x.org/b.jpg'},
|
|
|
|
|
{'startTime': 0, 'title': 'Erstes'},
|
|
|
|
|
{'startTime': 50, 'title': 'Nur Bild', 'toc': False},
|
|
|
|
|
{'startTime': 'kaputt', 'title': 'Ungültig'},
|
|
|
|
|
{'startTime': 120, 'title': 'Link', 'url': 'javascript:alert(1)'},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeedExtrasParsingTests(TestCase):
|
|
|
|
|
"""What feedparser (6.0.x) hands us for these tags, and what we store."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
|
|
|
|
|
|
|
|
|
|
def refresh(self, rss):
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
with patch('podcasts.views._fetch_feed', lambda url, **kw: _ok(rss)):
|
Podcasts: Kapitel, Shownotes und Transkripte für die Apps
- Feeds liefern jetzt Podlove-Kapitel (psc:chapters), Podcasting-2.0-Kapitel
(podcast:chapters, JSON-URL) und Transkripte (podcast:transcript, auch
mehrere); als Beschreibung wird content:encoded bevorzugt.
- Bekannte Folgen werden beim Aktualisieren nachgezogen, aber nur die Zeilen,
die sich wirklich geändert haben.
- Neuer Endpunkt GET /podcasts/episodes/<id>/ mit bereinigten Shownotes,
Zeitmarken, Kapiteln, Transkripten und Fortschritt. JSON-Kapitel werden
einmal serverseitig geholt (nur öffentliche Adressen, max. 1 MB) und an
der Folge gespeichert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 07:56:44 +02:00
|
|
|
_refresh_feed(self.feed)
|
|
|
|
|
|
|
|
|
|
def test_feedparser_keys(self):
|
|
|
|
|
import podcasts.views # noqa: F401 -- installs the feedparser extension
|
|
|
|
|
rss = _rich_rss([('a', PSC + TRANSCRIPTS + '<podcast:chapters url="https://x.org/c.json" '
|
|
|
|
|
'type="application/json+chapters"/>')])
|
|
|
|
|
entry = _parse(rss).entries[0]
|
|
|
|
|
self.assertEqual(entry['podcast_chapters']['url'], 'https://x.org/c.json')
|
|
|
|
|
self.assertEqual([t['url'] for t in entry['podcast_transcripts']],
|
|
|
|
|
['https://example.org/a.vtt', 'https://example.org/a.srt', 'javascript:alert(1)'])
|
|
|
|
|
self.assertEqual(entry['psc_chapters']['chapters'][1]['start'], '00:01:02.500')
|
|
|
|
|
|
|
|
|
|
def test_other_prefix_for_the_podcast_namespace(self):
|
|
|
|
|
ns = NS.replace('xmlns:podcast=', 'xmlns:pc=')
|
|
|
|
|
rss = _rich_rss([('a', '<pc:transcript url="https://example.org/a.vtt" type="text/vtt"/>'
|
|
|
|
|
'<pc:chapters url="https://x.org/c.json" type="application/json+chapters"/>')], ns)
|
|
|
|
|
self.refresh(rss)
|
|
|
|
|
ep = PodcastEpisode.objects.get()
|
|
|
|
|
self.assertEqual(ep.chapters_url, 'https://x.org/c.json')
|
|
|
|
|
self.assertEqual(ep.transcripts, [{'url': 'https://example.org/a.vtt', 'type': 'text/vtt', 'language': ''}])
|
|
|
|
|
|
|
|
|
|
def test_psc_chapters(self):
|
|
|
|
|
self.refresh(_rich_rss([('a', PSC)]))
|
|
|
|
|
ep = PodcastEpisode.objects.get()
|
|
|
|
|
self.assertEqual(ep.chapters, [
|
|
|
|
|
{'start': 0.0, 'title': 'Intro'},
|
|
|
|
|
{'start': 62.5, 'title': 'Teil & zwei', 'url': 'https://x.org/2', 'img': 'https://x.org/2.jpg'},
|
|
|
|
|
{'start': 725.0, 'title': 'Kurz'},
|
|
|
|
|
{'start': 3723.0, 'title': 'Drei'},
|
|
|
|
|
])
|
|
|
|
|
self.assertEqual(ep.chapters_url, '')
|
|
|
|
|
|
|
|
|
|
def test_transcripts_and_chapters_url(self):
|
|
|
|
|
self.refresh(_rich_rss([('a', TRANSCRIPTS + '<podcast:chapters url="https://x.org/c.json" '
|
|
|
|
|
'type="application/json+chapters"/>')]))
|
|
|
|
|
ep = PodcastEpisode.objects.get()
|
|
|
|
|
self.assertEqual(ep.transcripts, [
|
|
|
|
|
{'url': 'https://example.org/a.vtt', 'type': 'text/vtt', 'language': 'de'},
|
|
|
|
|
{'url': 'https://example.org/a.srt', 'type': 'application/srt', 'language': ''},
|
|
|
|
|
])
|
|
|
|
|
self.assertEqual(ep.chapters_url, 'https://x.org/c.json')
|
|
|
|
|
self.assertEqual(ep.chapters, [])
|
|
|
|
|
self.assertIsNone(ep.chapters_fetched_at)
|
|
|
|
|
|
|
|
|
|
def test_content_encoded_is_preferred(self):
|
|
|
|
|
self.refresh(_rich_rss([
|
|
|
|
|
('a', '<description>Kurz</description>'
|
|
|
|
|
'<content:encoded><![CDATA[<p>Lang <a href="https://x.org">Link</a></p>]]></content:encoded>'),
|
|
|
|
|
('b', '<description>Nur kurz</description>'),
|
|
|
|
|
]))
|
|
|
|
|
self.assertIn('Lang', PodcastEpisode.objects.get(guid='a').description)
|
|
|
|
|
self.assertEqual(PodcastEpisode.objects.get(guid='b').description, 'Nur kurz')
|
|
|
|
|
|
|
|
|
|
def test_existing_episodes_are_updated(self):
|
|
|
|
|
self.refresh(_rich_rss([('a', '<description>Alt</description>'), ('b', '<description>Gleich</description>')]))
|
|
|
|
|
a, b = PodcastEpisode.objects.get(guid='a'), PodcastEpisode.objects.get(guid='b')
|
|
|
|
|
rss = _rich_rss([
|
|
|
|
|
('a', '<description>Neu</description>' + PSC + TRANSCRIPTS),
|
|
|
|
|
('b', '<description>Gleich</description>'),
|
|
|
|
|
])
|
|
|
|
|
with patch('podcasts.views.PodcastEpisode.objects.bulk_update',
|
|
|
|
|
wraps=PodcastEpisode.objects.bulk_update) as bulk:
|
|
|
|
|
self.refresh(rss)
|
|
|
|
|
# Only the episode that changed is written.
|
|
|
|
|
self.assertEqual([e.guid for e in bulk.call_args.args[0]], ['a'])
|
|
|
|
|
a.refresh_from_db()
|
|
|
|
|
self.assertEqual(a.description, 'Neu')
|
|
|
|
|
self.assertEqual(len(a.chapters), 4)
|
|
|
|
|
self.assertEqual(len(a.transcripts), 2)
|
|
|
|
|
self.assertEqual(PodcastEpisode.objects.count(), 2)
|
|
|
|
|
# Nothing changed: nothing written.
|
|
|
|
|
with patch('podcasts.views.PodcastEpisode.objects.bulk_update') as bulk:
|
|
|
|
|
self.refresh(rss)
|
|
|
|
|
bulk.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_fetched_chapters_survive_a_refresh_until_the_url_changes(self):
|
|
|
|
|
tag = '<podcast:chapters url="https://x.org/{}.json" type="application/json+chapters"/>'
|
|
|
|
|
self.refresh(_rich_rss([('a', tag.format('c1'))]))
|
|
|
|
|
fetched = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
|
|
|
|
PodcastEpisode.objects.update(chapters=[{'start': 0.0, 'title': 'X'}], chapters_fetched_at=fetched)
|
|
|
|
|
self.refresh(_rich_rss([('a', tag.format('c1'))]))
|
|
|
|
|
ep = PodcastEpisode.objects.get()
|
|
|
|
|
self.assertEqual(ep.chapters, [{'start': 0.0, 'title': 'X'}])
|
|
|
|
|
self.assertEqual(ep.chapters_fetched_at, fetched)
|
|
|
|
|
self.refresh(_rich_rss([('a', tag.format('c2'))]))
|
|
|
|
|
ep.refresh_from_db()
|
|
|
|
|
self.assertEqual((ep.chapters_url, ep.chapters, ep.chapters_fetched_at), ('https://x.org/c2.json', [], None))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ShownotesTests(TestCase):
|
|
|
|
|
def test_sanitizer(self):
|
|
|
|
|
from .shownotes import sanitize_html
|
|
|
|
|
dirty = (
|
|
|
|
|
'<p onclick="steal()">Hallo <a href="https://x.org/a?b=1&c=2" onmouseover="x()" target="_top">Link</a>'
|
|
|
|
|
'<script>alert(1)</script><style>p{}</style><iframe src="https://evil.org">drin</iframe>'
|
|
|
|
|
'<a href="javascript:alert(1)">böse</a><a href=" JaVa\tScript:alert(1)">auch</a>'
|
|
|
|
|
'<img src="data:image/png;base64,AAA"><img src="https://x.org/i.png" onerror="x()">'
|
|
|
|
|
'<unknown>Text bleibt</unknown><b>offen'
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
sanitize_html(dirty),
|
|
|
|
|
'<p>Hallo <a href="https://x.org/a?b=1&c=2" rel="nofollow noopener noreferrer">Link</a>'
|
|
|
|
|
'<a rel="nofollow noopener noreferrer">böse</a><a rel="nofollow noopener noreferrer">auch</a>'
|
|
|
|
|
'<img src="https://x.org/i.png">Text bleibt<b>offen</b></p>',
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(sanitize_html('1 < 2 && <i>3</i>'), '1 < 2 && <i>3</i>')
|
|
|
|
|
self.assertEqual(sanitize_html('<a href="mailto:a@b.org">Mail</a>'),
|
|
|
|
|
'<a href="mailto:a@b.org" rel="nofollow noopener noreferrer">Mail</a>')
|
|
|
|
|
|
|
|
|
|
def test_timestamps(self):
|
|
|
|
|
from .shownotes import extract_timestamps
|
|
|
|
|
notes = (
|
|
|
|
|
'<p>Kapitel:<br>00:00 Intro<br>(12:34) - Thema eins<br>1:02:03 Zum Schluss</p>'
|
|
|
|
|
'<ul><li>[05:07] Liste</li><li>Um 20:15 geht es los</li><li>99:99 kaputt</li></ul>'
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(extract_timestamps(notes), [
|
|
|
|
|
{'seconds': 0, 'label': 'Intro'},
|
|
|
|
|
{'seconds': 754, 'label': 'Thema eins'},
|
|
|
|
|
{'seconds': 3723, 'label': 'Zum Schluss'},
|
|
|
|
|
{'seconds': 307, 'label': 'Liste'},
|
|
|
|
|
])
|
|
|
|
|
self.assertEqual(len(extract_timestamps(notes, duration_seconds=1000)), 3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeResponse:
|
|
|
|
|
def __init__(self, body=b'', status=200, headers=None):
|
|
|
|
|
self.body, self.status_code, self.headers = body, status, headers or {}
|
|
|
|
|
self.raw = None
|
|
|
|
|
|
|
|
|
|
def iter_content(self, chunk_size=1):
|
|
|
|
|
yield self.body
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EpisodeDetailTests(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://example.org/feed', title='Feed',
|
|
|
|
|
artwork_url='https://example.org/art.jpg')
|
|
|
|
|
self.ep = PodcastEpisode.objects.create(
|
|
|
|
|
feed=self.feed, guid='a', title='A', audio_url='https://example.org/a.mp3', duration_seconds=3600,
|
|
|
|
|
pub_date=datetime(2026, 1, 5, 10, tzinfo=timezone.utc),
|
|
|
|
|
description='<p>Hallo<script>alert(1)</script><br>01:02 Thema <a href="https://x.org">x</a></p>',
|
|
|
|
|
transcripts=[{'url': 'https://example.org/a.vtt', 'type': 'text/vtt', 'language': 'de'}],
|
|
|
|
|
chapters_url='https://x.org/c.json',
|
|
|
|
|
)
|
|
|
|
|
self.requests = []
|
|
|
|
|
|
|
|
|
|
def fake_get(self, url, **kwargs):
|
|
|
|
|
self.requests.append(url)
|
|
|
|
|
return _FakeResponse(json.dumps(CHAPTERS_JSON).encode())
|
|
|
|
|
|
|
|
|
|
def get(self, ep=None):
|
|
|
|
|
with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.get', self.fake_get):
|
|
|
|
|
return self.client.get(f'/podcasts/episodes/{(ep or self.ep).id}/')
|
|
|
|
|
|
|
|
|
|
def test_detail(self):
|
|
|
|
|
EpisodeProgress.objects.create(user=self.user, episode=self.ep, position_seconds=42)
|
|
|
|
|
r = self.get()
|
|
|
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
|
data = r.json()
|
|
|
|
|
self.assertEqual(data['description'], '<p>Hallo<br>01:02 Thema <a href="https://x.org" '
|
|
|
|
|
'rel="nofollow noopener noreferrer">x</a></p>')
|
|
|
|
|
self.assertEqual(data['timestamps'], [{'seconds': 62, 'label': 'Thema x'}])
|
|
|
|
|
self.assertEqual(data['transcripts'], self.ep.transcripts)
|
|
|
|
|
self.assertEqual(data['feed'], {'id': self.feed.id, 'title': 'Feed',
|
|
|
|
|
'artwork_url': 'https://example.org/art.jpg'})
|
|
|
|
|
self.assertEqual((data['position_seconds'], data['played'], data['in_queue']), (42, False, False))
|
|
|
|
|
self.assertEqual(data['pub_date'], '2026-01-05T10:00:00+00:00')
|
|
|
|
|
self.assertEqual(data['chapters'], [
|
|
|
|
|
{'start': 0.0, 'title': 'Erstes'},
|
|
|
|
|
{'start': 95.5, 'title': 'Zweites', 'url': 'https://x.org/b', 'img': 'https://x.org/b.jpg'},
|
|
|
|
|
{'start': 120.0, 'title': 'Link'},
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
def test_chapters_are_fetched_once(self):
|
|
|
|
|
self.get()
|
|
|
|
|
self.get()
|
|
|
|
|
self.assertEqual(self.requests, ['https://x.org/c.json'])
|
|
|
|
|
self.ep.refresh_from_db()
|
|
|
|
|
self.assertEqual(len(self.ep.chapters), 3)
|
|
|
|
|
self.assertIsNotNone(self.ep.chapters_fetched_at)
|
|
|
|
|
|
|
|
|
|
def test_inline_chapters_need_no_fetch(self):
|
|
|
|
|
self.ep.chapters = [{'start': 0.0, 'title': 'Inline'}]
|
|
|
|
|
self.ep.save()
|
|
|
|
|
self.assertEqual(self.get().json()['chapters'], [{'start': 0.0, 'title': 'Inline'}])
|
|
|
|
|
self.assertEqual(self.requests, [])
|
|
|
|
|
|
|
|
|
|
def test_redirect_is_followed_and_rechecked(self):
|
|
|
|
|
def fake_get(url, **kwargs):
|
|
|
|
|
self.requests.append(url)
|
|
|
|
|
if url.endswith('c.json'):
|
|
|
|
|
return _FakeResponse(status=302, headers={'Location': '/real.json'})
|
|
|
|
|
return _FakeResponse(json.dumps(CHAPTERS_JSON).encode())
|
|
|
|
|
with patch('podcasts.chapters.assert_safe_url') as check, patch('podcasts.chapters.requests.get', fake_get):
|
|
|
|
|
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/')
|
|
|
|
|
self.assertEqual(len(r.json()['chapters']), 3)
|
|
|
|
|
self.assertEqual([c.args[0] for c in check.call_args_list], ['https://x.org/c.json', 'https://x.org/real.json'])
|
|
|
|
|
|
|
|
|
|
def test_private_chapters_url_is_refused_and_remembered(self):
|
|
|
|
|
self.ep.chapters_url = 'http://127.0.0.1/c.json'
|
|
|
|
|
self.ep.save()
|
|
|
|
|
with patch('podcasts.chapters.requests.get', self.fake_get):
|
|
|
|
|
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/')
|
|
|
|
|
self.assertEqual(r.json()['chapters'], [])
|
|
|
|
|
self.assertEqual(self.requests, [])
|
|
|
|
|
self.ep.refresh_from_db()
|
|
|
|
|
self.assertIsNotNone(self.ep.chapters_fetched_at)
|
|
|
|
|
|
|
|
|
|
def test_oversized_or_broken_chapters(self):
|
|
|
|
|
from . import chapters
|
|
|
|
|
with patch('podcasts.chapters.assert_safe_url'), patch(
|
|
|
|
|
'podcasts.chapters.requests.get',
|
|
|
|
|
lambda url, **kw: _FakeResponse(b'x' * (chapters.CHAPTERS_MAX_BYTES + 1)),
|
|
|
|
|
):
|
|
|
|
|
with self.assertRaises(chapters.ChaptersInvalid):
|
|
|
|
|
chapters.fetch('https://x.org/big.json')
|
|
|
|
|
with patch('podcasts.chapters.assert_safe_url'), patch(
|
|
|
|
|
'podcasts.chapters.requests.get', lambda url, **kw: _FakeResponse(b'{"chapters": 3}'),
|
|
|
|
|
):
|
|
|
|
|
with self.assertRaises(chapters.ChaptersInvalid):
|
|
|
|
|
chapters.fetch('https://x.org/odd.json')
|
|
|
|
|
|
|
|
|
|
def test_server_error_is_retried_later(self):
|
|
|
|
|
with patch('podcasts.chapters.assert_safe_url'), patch(
|
|
|
|
|
'podcasts.chapters.requests.get', lambda url, **kw: _FakeResponse(status=503),
|
|
|
|
|
):
|
|
|
|
|
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/')
|
|
|
|
|
self.assertEqual(r.json()['chapters'], [])
|
|
|
|
|
self.ep.refresh_from_db()
|
|
|
|
|
self.assertIsNone(self.ep.chapters_fetched_at)
|
|
|
|
|
|
|
|
|
|
def test_other_users_episode_is_not_found(self):
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
feed = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/b', title='B')
|
|
|
|
|
ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3',
|
|
|
|
|
chapters_url='https://x.org/c.json')
|
|
|
|
|
self.assertEqual(self.get(ep).status_code, 404)
|
|
|
|
|
self.assertEqual(self.requests, [])
|
|
|
|
|
|
|
|
|
|
def test_login_required(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.get().status_code, 401)
|
Podcasts: Favoriten, Folgenliste mit Filtern und Verlauf, Hörstatistik, OPML-Export
- EpisodeProgress.favorite (Migration 0006) und POST episodes/<id>/favorite/
(setzen oder umschalten; ändert updated_at nicht, damit Verlauf und
Statistik nicht verfälscht werden)
- GET episodes/: alle Folgen aller Abos, Filter all/unplayed/in_progress/
played/favorites/history, feed_id, q, offset/limit/total; zwei Abfragen
pro Seite, ohne Shownotes
- Folgendetails liefern zusätzlich favorite und last_played_at
- GET stats/: Hörzeit gesamt und pro Podcast, optional nur die letzten N Tage
- GET feeds/export.opml: Abos als OPML 2.0 (lässt sich wieder importieren)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 08:12:27 +02:00
|
|
|
|
|
|
|
|
def test_favorite_and_last_played_at(self):
|
|
|
|
|
r = self.get().json()
|
|
|
|
|
self.assertEqual((r['favorite'], r['last_played_at']), (False, None))
|
|
|
|
|
p = EpisodeProgress.objects.create(user=self.user, episode=self.ep, position_seconds=5, favorite=True)
|
|
|
|
|
EpisodeProgress.objects.filter(pk=p.pk).update(updated_at=datetime(2026, 2, 3, 8, tzinfo=timezone.utc))
|
|
|
|
|
r = self.get().json()
|
|
|
|
|
self.assertEqual((r['favorite'], r['last_played_at']), (True, '2026-02-03T08:00:00+00:00'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Favorites, episode list, statistics, OPML export
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _at(month, day):
|
|
|
|
|
return datetime(2026, month, day, 9, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _progress(user, episode, updated_at, **fields):
|
|
|
|
|
p = EpisodeProgress.objects.create(user=user, episode=episode, **fields)
|
|
|
|
|
# auto_now would overwrite updated_at on save(); update() doesn't.
|
|
|
|
|
EpisodeProgress.objects.filter(pk=p.pk).update(updated_at=updated_at)
|
|
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FavoriteTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
feed = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed', title='Feed')
|
|
|
|
|
self.ep = PodcastEpisode.objects.create(feed=feed, guid='a', title='A', audio_url='https://example.org/a.mp3')
|
|
|
|
|
|
|
|
|
|
def post(self, body=None, ep=None):
|
|
|
|
|
return self.client.post(f'/podcasts/episodes/{(ep or self.ep).id}/favorite/',
|
|
|
|
|
'' if body is None else json.dumps(body), content_type='application/json')
|
|
|
|
|
|
|
|
|
|
def test_toggle_creates_the_progress_row(self):
|
|
|
|
|
self.assertFalse(EpisodeProgress.objects.exists())
|
|
|
|
|
self.assertEqual(self.post().json(), {'ok': True, 'favorite': True})
|
|
|
|
|
p = EpisodeProgress.objects.get(user=self.user, episode=self.ep)
|
|
|
|
|
self.assertTrue(p.favorite)
|
|
|
|
|
self.assertEqual((p.played, p.dismissed, p.position_seconds), (False, False, 0))
|
|
|
|
|
self.assertEqual(self.post({}).json(), {'ok': True, 'favorite': False})
|
|
|
|
|
self.assertFalse(EpisodeProgress.objects.get(pk=p.pk).favorite)
|
|
|
|
|
|
|
|
|
|
def test_set_explicitly(self):
|
|
|
|
|
self.assertEqual(self.post({'favorite': True}).json()['favorite'], True)
|
|
|
|
|
self.assertEqual(self.post({'favorite': True}).json()['favorite'], True)
|
|
|
|
|
self.assertTrue(EpisodeProgress.objects.get().favorite)
|
|
|
|
|
self.assertEqual(self.post({'favorite': False}).json()['favorite'], False)
|
|
|
|
|
self.assertEqual(self.post({'favorite': False}).json()['favorite'], False)
|
|
|
|
|
self.assertFalse(EpisodeProgress.objects.get().favorite)
|
|
|
|
|
|
|
|
|
|
def test_starring_keeps_the_listening_time(self):
|
|
|
|
|
p = _progress(self.user, self.ep, _at(2, 1), position_seconds=30)
|
|
|
|
|
self.post({'favorite': True})
|
|
|
|
|
p.refresh_from_db()
|
|
|
|
|
self.assertTrue(p.favorite)
|
|
|
|
|
self.assertEqual((p.position_seconds, p.updated_at), (30, _at(2, 1)))
|
|
|
|
|
|
|
|
|
|
def test_owner_only(self):
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
feed = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/b', title='B')
|
|
|
|
|
ep = PodcastEpisode.objects.create(feed=feed, guid='x', title='X', audio_url='https://example.org/x.mp3')
|
|
|
|
|
self.assertEqual(self.post({'favorite': True}, ep).status_code, 404)
|
|
|
|
|
self.assertFalse(EpisodeProgress.objects.exists())
|
|
|
|
|
self.assertEqual(self.client.post('/podcasts/episodes/999999/favorite/', '{}',
|
|
|
|
|
content_type='application/json').status_code, 404)
|
|
|
|
|
|
|
|
|
|
def test_login_and_post_required(self):
|
|
|
|
|
self.assertEqual(self.client.get(f'/podcasts/episodes/{self.ep.id}/favorite/').status_code, 405)
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.post({'favorite': True}).status_code, 401)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EpisodeListTests(TestCase):
|
|
|
|
|
"""GET /podcasts/episodes/: all feeds, filters, history, search, pages."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
|
|
|
|
|
artwork_url='https://example.org/a.jpg')
|
|
|
|
|
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
|
|
|
|
|
|
|
|
|
|
def ep(feed, guid, title, pub_date, **kw):
|
|
|
|
|
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=title, pub_date=pub_date,
|
|
|
|
|
audio_url=f'https://example.org/{guid}.mp3',
|
|
|
|
|
description='x' * 1000, **kw)
|
|
|
|
|
|
|
|
|
|
self.a1 = ep(self.fa, 'a1', 'A1', _at(1, 1), duration_seconds=600)
|
|
|
|
|
self.a2 = ep(self.fa, 'a2', 'A2', _at(1, 2))
|
|
|
|
|
self.a3 = ep(self.fa, 'a3', 'A3', _at(1, 3))
|
|
|
|
|
self.a4 = ep(self.fa, 'a4', 'A4 ohne Datum', None)
|
|
|
|
|
self.b1 = ep(self.fb, 'b1', 'Bonus Talk', _at(1, 4))
|
|
|
|
|
self.b2 = ep(self.fb, 'b2', 'B2', _at(1, 5))
|
|
|
|
|
_progress(self.user, self.a1, _at(2, 1), played=True)
|
|
|
|
|
_progress(self.user, self.a2, _at(2, 3), position_seconds=100)
|
|
|
|
|
_progress(self.user, self.b1, _at(2, 2), position_seconds=50)
|
|
|
|
|
_progress(self.user, self.a3, _at(2, 5), favorite=True)
|
|
|
|
|
_progress(self.user, self.b2, _at(2, 4), dismissed=True) # e.g. back catalogue of a new subscription
|
|
|
|
|
PodcastQueue.objects.create(user=self.user, episode=self.a2, position=0)
|
|
|
|
|
# Someone else's listening must not show up.
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
|
|
|
|
|
xb = PodcastEpisode.objects.create(feed=fbob, guid='a1', title='A1', audio_url='https://example.org/a1.mp3',
|
|
|
|
|
pub_date=_at(1, 9))
|
|
|
|
|
_progress(bob, xb, _at(3, 1), played=True, favorite=True)
|
|
|
|
|
_progress(bob, self.a3, _at(3, 1), played=True, position_seconds=10) # can't happen, but must not leak
|
|
|
|
|
|
|
|
|
|
def get(self, **params):
|
|
|
|
|
r = self.client.get('/podcasts/episodes/', params)
|
|
|
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
|
return r.json()
|
|
|
|
|
|
|
|
|
|
def ids(self, **params):
|
|
|
|
|
return [e['id'] for e in self.get(**params)['episodes']]
|
|
|
|
|
|
|
|
|
|
def test_all_newest_first_without_date_last(self):
|
|
|
|
|
data = self.get()
|
|
|
|
|
self.assertEqual([e['id'] for e in data['episodes']],
|
|
|
|
|
[e.id for e in (self.b2, self.b1, self.a3, self.a2, self.a1, self.a4)])
|
|
|
|
|
self.assertEqual((data['offset'], data['limit'], data['total']), (0, 100, 6))
|
|
|
|
|
self.assertEqual(self.ids(filter='all'), [e['id'] for e in data['episodes']])
|
|
|
|
|
|
|
|
|
|
def test_episode_fields(self):
|
|
|
|
|
eps = {e['id']: e for e in self.get()['episodes']}
|
|
|
|
|
self.assertEqual(eps[self.a2.id], {
|
|
|
|
|
'id': self.a2.id, 'title': 'A2', 'audio_url': 'https://example.org/a2.mp3', 'duration_seconds': 0,
|
|
|
|
|
'pub_date': '2026-01-02T09:00:00+00:00', 'artwork_url': '',
|
|
|
|
|
'feed__id': self.fa.id, 'feed__title': 'Alpha', 'feed__artwork_url': 'https://example.org/a.jpg',
|
|
|
|
|
'position_seconds': 100, 'played': False, 'favorite': False, 'in_queue': True,
|
|
|
|
|
'last_played_at': '2026-02-03T09:00:00+00:00',
|
|
|
|
|
})
|
|
|
|
|
a4 = eps[self.a4.id]
|
|
|
|
|
self.assertEqual((a4['pub_date'], a4['last_played_at'], a4['position_seconds'], a4['played'],
|
|
|
|
|
a4['favorite'], a4['in_queue']), (None, None, 0, False, False, False))
|
|
|
|
|
self.assertEqual(eps[self.a3.id]['favorite'], True)
|
|
|
|
|
self.assertEqual(eps[self.a3.id]['played'], False) # bob's row doesn't count
|
|
|
|
|
self.assertEqual(eps[self.a1.id]['played'], True)
|
|
|
|
|
|
|
|
|
|
def test_unplayed(self):
|
|
|
|
|
# Dismissed episodes (b2) are still listed.
|
|
|
|
|
self.assertEqual(self.ids(filter='unplayed'), [e.id for e in (self.b2, self.b1, self.a3, self.a2, self.a4)])
|
|
|
|
|
|
|
|
|
|
def test_in_progress_by_publication_date(self):
|
|
|
|
|
self.assertEqual(self.ids(filter='in_progress'), [self.b1.id, self.a2.id])
|
|
|
|
|
|
|
|
|
|
def test_played(self):
|
|
|
|
|
self.assertEqual(self.ids(filter='played'), [self.a1.id])
|
|
|
|
|
|
|
|
|
|
def test_favorites(self):
|
|
|
|
|
self.assertEqual(self.ids(filter='favorites'), [self.a3.id])
|
|
|
|
|
|
|
|
|
|
def test_history_last_listened_first(self):
|
|
|
|
|
data = self.get(filter='history')
|
|
|
|
|
self.assertEqual([e['id'] for e in data['episodes']], [self.a2.id, self.b1.id, self.a1.id])
|
|
|
|
|
self.assertEqual([e['last_played_at'] for e in data['episodes']],
|
|
|
|
|
['2026-02-03T09:00:00+00:00', '2026-02-02T09:00:00+00:00', '2026-02-01T09:00:00+00:00'])
|
|
|
|
|
self.assertEqual(data['total'], 3)
|
|
|
|
|
# Listening again moves an episode to the top.
|
|
|
|
|
self.client.post('/podcasts/progress/save/', json.dumps({'episode_id': self.a1.id, 'position_seconds': 20}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
self.assertEqual(self.ids(filter='history'), [self.a1.id, self.a2.id, self.b1.id])
|
|
|
|
|
|
|
|
|
|
def test_feed_id_and_q(self):
|
|
|
|
|
self.assertEqual(self.ids(feed_id=self.fb.id), [self.b2.id, self.b1.id])
|
|
|
|
|
self.assertEqual(self.ids(q='BONUS'), [self.b1.id])
|
|
|
|
|
self.assertEqual(self.ids(q='ohne dat'), [self.a4.id])
|
|
|
|
|
self.assertEqual(self.ids(filter='history', feed_id=self.fa.id), [self.a2.id, self.a1.id])
|
|
|
|
|
self.assertEqual(self.ids(filter='in_progress', q='a2'), [self.a2.id])
|
|
|
|
|
data = self.get(feed_id=self.fb.id, q='nothing')
|
|
|
|
|
self.assertEqual((data['episodes'], data['total']), ([], 0))
|
|
|
|
|
bobs_feed = PodcastFeed.objects.get(user__username='bob')
|
|
|
|
|
self.assertEqual(self.ids(feed_id=bobs_feed.id), [])
|
|
|
|
|
|
|
|
|
|
def test_pages(self):
|
|
|
|
|
data = self.get(offset=1, limit=2)
|
|
|
|
|
self.assertEqual([e['id'] for e in data['episodes']], [self.b1.id, self.a3.id])
|
|
|
|
|
self.assertEqual((data['offset'], data['limit'], data['total']), (1, 2, 6))
|
|
|
|
|
data = self.get(offset=5, limit=2)
|
|
|
|
|
self.assertEqual(([e['id'] for e in data['episodes']], data['total']), ([self.a4.id], 6))
|
|
|
|
|
data = self.get(offset=10)
|
|
|
|
|
self.assertEqual((data['episodes'], data['total']), ([], 6))
|
|
|
|
|
self.assertEqual(self.get(limit=10000)['limit'], 500)
|
|
|
|
|
self.assertEqual(self.get(offset=-3)['offset'], 0)
|
|
|
|
|
data = self.get(filter='history', limit=1, offset=1)
|
|
|
|
|
self.assertEqual(([e['id'] for e in data['episodes']], data['total']), ([self.b1.id], 3))
|
|
|
|
|
|
|
|
|
|
def test_query_count_does_not_grow_with_the_page(self):
|
|
|
|
|
from .views import _episode_list_data
|
|
|
|
|
for i in range(20):
|
|
|
|
|
e = PodcastEpisode.objects.create(feed=self.fb, guid=f'n{i}', title=f'N{i}',
|
|
|
|
|
audio_url=f'https://example.org/n{i}.mp3', pub_date=_at(1, 10))
|
|
|
|
|
_progress(self.user, e, _at(2, 10), position_seconds=i + 1)
|
|
|
|
|
PodcastQueue.objects.create(user=self.user, episode=e, position=i + 1)
|
|
|
|
|
with self.assertNumQueries(2):
|
|
|
|
|
episodes, total = _episode_list_data(self.user, 'history')
|
|
|
|
|
self.assertEqual((len(episodes), total), (23, 23))
|
|
|
|
|
|
|
|
|
|
def test_bad_parameters(self):
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/episodes/', {'filter': 'new'}).status_code, 400)
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/episodes/', {'limit': 'ten'}).status_code, 400)
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/episodes/', {'feed_id': 'x'}).status_code, 400)
|
|
|
|
|
|
|
|
|
|
def test_login_required(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/episodes/').status_code, 401)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StatsTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
self.fa = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/a', title='Alpha',
|
|
|
|
|
artwork_url='https://example.org/a.jpg')
|
|
|
|
|
self.fb = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/b', title='Beta')
|
|
|
|
|
self.fc = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/c', title='Gamma')
|
|
|
|
|
|
|
|
|
|
def ep(feed, guid, duration):
|
|
|
|
|
return PodcastEpisode.objects.create(feed=feed, guid=guid, title=guid, duration_seconds=duration,
|
|
|
|
|
audio_url=f'https://example.org/{guid}.mp3')
|
|
|
|
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
_progress(self.user, ep(self.fa, 'a1', 3600), now, played=True, position_seconds=50) # 3600
|
|
|
|
|
_progress(self.user, ep(self.fa, 'a2', 1000), now, position_seconds=300) # 300, started
|
|
|
|
|
_progress(self.user, ep(self.fa, 'a3', 0), now, played=True, position_seconds=120) # 120: no length
|
|
|
|
|
_progress(self.user, ep(self.fb, 'b1', 900), now - timedelta(days=30), position_seconds=200) # 200, started
|
|
|
|
|
_progress(self.user, ep(self.fc, 'c1', 500), now, dismissed=True, favorite=True) # 0
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
fbob = PodcastFeed.objects.create(user=bob, rss_url='https://example.org/a', title='Alpha')
|
|
|
|
|
_progress(bob, ep(fbob, 'x', 999), now, played=True)
|
|
|
|
|
|
|
|
|
|
def test_all_time(self):
|
|
|
|
|
r = self.client.get('/podcasts/stats/')
|
|
|
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
|
self.assertEqual(r.json(), {
|
|
|
|
|
'total_seconds': 4220,
|
|
|
|
|
'episodes_played': 2,
|
|
|
|
|
'episodes_started': 2,
|
|
|
|
|
'since': None,
|
|
|
|
|
'feeds': [
|
|
|
|
|
{'feed_id': self.fa.id, 'title': 'Alpha', 'artwork_url': 'https://example.org/a.jpg',
|
|
|
|
|
'seconds': 4020, 'episodes_played': 2},
|
|
|
|
|
{'feed_id': self.fb.id, 'title': 'Beta', 'artwork_url': '', 'seconds': 200, 'episodes_played': 0},
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def test_last_days(self):
|
|
|
|
|
before = datetime.now(timezone.utc)
|
|
|
|
|
data = self.client.get('/podcasts/stats/', {'days': 7}).json()
|
|
|
|
|
self.assertEqual((data['total_seconds'], data['episodes_played'], data['episodes_started']), (4020, 2, 1))
|
|
|
|
|
self.assertEqual([f['feed_id'] for f in data['feeds']], [self.fa.id])
|
|
|
|
|
since = datetime.fromisoformat(data['since'])
|
|
|
|
|
self.assertAlmostEqual((before - since).total_seconds(), 7 * 86400, delta=60)
|
|
|
|
|
data = self.client.get('/podcasts/stats/', {'days': 60}).json()
|
|
|
|
|
self.assertEqual(data['total_seconds'], 4220)
|
|
|
|
|
self.assertEqual([f['feed_id'] for f in data['feeds']], [self.fa.id, self.fb.id])
|
|
|
|
|
|
|
|
|
|
def test_empty(self):
|
|
|
|
|
EpisodeProgress.objects.filter(user=self.user).delete()
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/stats/').json(), {
|
|
|
|
|
'total_seconds': 0, 'episodes_played': 0, 'episodes_started': 0, 'since': None, 'feeds': [],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def test_bad_days_and_login(self):
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/stats/', {'days': 'week'}).status_code, 400)
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/stats/', {'days': -1}).status_code, 400)
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/stats/').status_code, 401)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OpmlExportTests(TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/feed?a=1&b=2',
|
|
|
|
|
title='Tom & Jerry "Live" <Folge>', link='https://example.org/?x=1&y=2')
|
|
|
|
|
PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/plain', title="Bob's Podcast")
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
PodcastFeed.objects.create(user=bob, rss_url='https://example.org/secret', title='Secret')
|
|
|
|
|
|
|
|
|
|
def test_export(self):
|
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
r = self.client.get('/podcasts/feeds/export.opml')
|
|
|
|
|
self.assertEqual(r.status_code, 200)
|
|
|
|
|
self.assertEqual(r['Content-Type'], 'text/x-opml; charset=utf-8')
|
|
|
|
|
self.assertEqual(r['Content-Disposition'], 'attachment; filename="diora-podcasts.opml"')
|
|
|
|
|
raw = r.content.decode('utf-8')
|
|
|
|
|
self.assertIn('Tom & Jerry "Live" <Folge>', raw)
|
|
|
|
|
self.assertIn('xmlUrl="https://example.org/feed?a=1&b=2"', raw)
|
|
|
|
|
self.assertNotIn('Secret', raw)
|
|
|
|
|
root = ET.fromstring(r.content)
|
|
|
|
|
self.assertEqual(root.tag, 'opml')
|
|
|
|
|
self.assertEqual(root.get('version'), '2.0')
|
|
|
|
|
self.assertEqual(root.findtext('head/title'), 'diora Podcasts')
|
|
|
|
|
outlines = [o.attrib for o in root.iter('outline')]
|
|
|
|
|
self.assertEqual(outlines, [
|
|
|
|
|
{'type': 'rss', 'text': "Bob's Podcast", 'title': "Bob's Podcast",
|
|
|
|
|
'xmlUrl': 'https://example.org/plain', 'htmlUrl': ''},
|
|
|
|
|
{'type': 'rss', 'text': 'Tom & Jerry "Live" <Folge>', 'title': 'Tom & Jerry "Live" <Folge>',
|
|
|
|
|
'xmlUrl': 'https://example.org/feed?a=1&b=2', 'htmlUrl': 'https://example.org/?x=1&y=2'},
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
def test_round_trip_through_import(self):
|
|
|
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
|
|
|
opml = self.client.get('/podcasts/feeds/export.opml').content
|
|
|
|
|
carol = User.objects.create_user(username='carol', password='pw12345678')
|
|
|
|
|
self.client.force_login(carol)
|
|
|
|
|
r = self.client.post('/podcasts/feeds/import/',
|
|
|
|
|
{'file': SimpleUploadedFile('diora-podcasts.opml', opml, 'text/x-opml')})
|
|
|
|
|
self.assertEqual(r.json(), {'ok': True, 'added': 2, 'skipped': 0})
|
|
|
|
|
mine = lambda u: set(PodcastFeed.objects.filter(user=u).values_list('title', 'rss_url'))
|
|
|
|
|
self.assertEqual(mine(carol), mine(self.user))
|
|
|
|
|
# Importing the same file again adds nothing.
|
|
|
|
|
r = self.client.post('/podcasts/feeds/import/',
|
|
|
|
|
{'file': SimpleUploadedFile('diora-podcasts.opml', opml, 'text/x-opml')})
|
|
|
|
|
self.assertEqual(r.json(), {'ok': True, 'added': 0, 'skipped': 2})
|
|
|
|
|
|
|
|
|
|
def test_no_feeds(self):
|
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
PodcastFeed.objects.filter(user=self.user).delete()
|
|
|
|
|
root = ET.fromstring(self.client.get('/podcasts/feeds/export.opml').content)
|
|
|
|
|
self.assertEqual(list(root.iter('outline')), [])
|
|
|
|
|
|
|
|
|
|
def test_login_required(self):
|
|
|
|
|
self.client.logout()
|
|
|
|
|
self.assertEqual(self.client.get('/podcasts/feeds/export.opml').status_code, 401)
|
Podcasts: Feeds bedingt abrufen, Fehler speichern, Umzüge folgen, Backoff
- Bedingter Abruf: ETag/Last-Modified werden nach einer 200 gespeichert und
als If-None-Match/If-Modified-Since mitgeschickt. Bei 304 wird nichts
geparst und keine Folge geschrieben, nur last_refreshed_at aktualisiert und
der Fehler gelöscht. Teilen sich mehrere Abos eine URL, gehen die Validatoren
nur mit, wenn alle schon einmal abgerufen wurden und dieselben haben.
- Fehler (Netz, HTTP >= 400, kein Feed) landen in last_error, last_error_at
und error_count; ein erfolgreicher Abruf setzt sie zurück. Feed-Liste und
/podcasts/state/ liefern die drei Felder mit.
- Dauerhafte Weiterleitungen (301/308 am Anfang der Kette) ändern rss_url,
außer der Nutzer hat die neue URL schon abonniert; 302/307 ändern nichts.
- Backoff im stündlichen Job: ab 3 Fehlern in Folge nur alle 2, 4, 8, 16,
höchstens 24 Stunden. „Alle aktualisieren“ und --feed-id versuchen es
trotzdem (max_age gilt weiter).
- _fetch_feed liefert jetzt ein FeedFetch-Objekt bzw. wirft FeedFetchError.
- Migration 0007_feed_http_cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:38 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Feed refresh: conditional GET, errors, permanent redirects, backoff
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class _Hop:
|
|
|
|
|
def __init__(self, url, status):
|
|
|
|
|
self.url, self.status_code = url, status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _HttpResponse:
|
|
|
|
|
"""Just enough of a requests.Response for _fetch_feed."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, url, status=200, content=b'', headers=None, history=()):
|
|
|
|
|
self.url, self.status_code, self.content = url, status, content
|
|
|
|
|
self.headers = headers or {}
|
|
|
|
|
self.history = list(history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FetchFeedTests(TestCase):
|
|
|
|
|
"""_fetch_feed itself, with requests.get faked."""
|
|
|
|
|
|
|
|
|
|
RSS = _rss([('a', 'Mon, 05 Jan 2026 10:00:00 GMT')]).encode()
|
|
|
|
|
|
|
|
|
|
def fetch(self, response, url='https://example.org/feed', **kw):
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def fake_get(u, **kwargs):
|
|
|
|
|
calls.append(kwargs['headers'])
|
|
|
|
|
if isinstance(response, Exception):
|
|
|
|
|
raise response
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
from .views import _fetch_feed
|
|
|
|
|
with patch('podcasts.views.requests.get', fake_get):
|
|
|
|
|
result = _fetch_feed(url, **kw)
|
|
|
|
|
return result, calls[0]
|
|
|
|
|
|
|
|
|
|
def error(self, response):
|
|
|
|
|
from .views import FeedFetchError
|
|
|
|
|
with self.assertRaises(FeedFetchError) as cm:
|
|
|
|
|
self.fetch(response)
|
|
|
|
|
return str(cm.exception)
|
|
|
|
|
|
|
|
|
|
def test_200_returns_parsed_feed_and_validators(self):
|
|
|
|
|
r, headers = self.fetch(_HttpResponse('https://example.org/feed', content=self.RSS, headers={
|
|
|
|
|
'ETag': '"v1"', 'Last-Modified': 'Mon, 05 Jan 2026 10:00:00 GMT'}))
|
|
|
|
|
self.assertEqual(r.status, 200)
|
|
|
|
|
self.assertEqual([e.title for e in r.parsed.entries], ['a'])
|
|
|
|
|
self.assertEqual((r.etag, r.last_modified), ('"v1"', 'Mon, 05 Jan 2026 10:00:00 GMT'))
|
|
|
|
|
self.assertEqual(r.permanent_url, 'https://example.org/feed')
|
|
|
|
|
self.assertNotIn('If-None-Match', headers)
|
|
|
|
|
self.assertNotIn('If-Modified-Since', headers)
|
|
|
|
|
|
|
|
|
|
def test_conditional_request_and_304(self):
|
|
|
|
|
r, headers = self.fetch(_HttpResponse('https://example.org/feed', status=304),
|
|
|
|
|
etag='"v1"', last_modified='Mon, 05 Jan 2026 10:00:00 GMT')
|
|
|
|
|
self.assertEqual(headers['If-None-Match'], '"v1"')
|
|
|
|
|
self.assertEqual(headers['If-Modified-Since'], 'Mon, 05 Jan 2026 10:00:00 GMT')
|
|
|
|
|
self.assertEqual(r.status, 304)
|
|
|
|
|
self.assertIsNone(r.parsed)
|
|
|
|
|
|
|
|
|
|
def test_304_without_validators_is_an_error(self):
|
|
|
|
|
self.assertEqual(self.error(_HttpResponse('https://example.org/feed', status=304)), 'HTTP 304')
|
|
|
|
|
|
|
|
|
|
def test_errors(self):
|
|
|
|
|
import requests
|
|
|
|
|
self.assertEqual(self.error(_HttpResponse('https://example.org/feed', status=404)), 'HTTP 404')
|
|
|
|
|
self.assertEqual(self.error(_HttpResponse('https://example.org/feed', status=503)), 'HTTP 503')
|
|
|
|
|
self.assertEqual(self.error(requests.Timeout()), 'timeout')
|
|
|
|
|
self.assertEqual(self.error(requests.ConnectionError()), 'connection failed')
|
|
|
|
|
self.assertEqual(self.error(requests.TooManyRedirects()), 'too many redirects')
|
|
|
|
|
self.assertEqual(self.error(_HttpResponse('https://example.org/feed', content=b'<html><body><p>Hi')),
|
|
|
|
|
'not a feed')
|
|
|
|
|
self.assertEqual(self.error(_HttpResponse('https://example.org/feed', content=b'\x00\xff garbage')),
|
|
|
|
|
'not a feed')
|
|
|
|
|
|
|
|
|
|
def test_permanent_redirect_chain(self):
|
|
|
|
|
r, _ = self.fetch(_HttpResponse('https://c.org/feed', content=self.RSS, history=[
|
|
|
|
|
_Hop('https://example.org/feed', 301), _Hop('https://b.org/feed', 308)]))
|
|
|
|
|
self.assertEqual(r.permanent_url, 'https://c.org/feed')
|
|
|
|
|
self.assertEqual(r.final_url, 'https://c.org/feed')
|
|
|
|
|
|
|
|
|
|
def test_temporary_hop_ends_the_permanent_part(self):
|
|
|
|
|
r, _ = self.fetch(_HttpResponse('https://c.org/feed', content=self.RSS, history=[
|
|
|
|
|
_Hop('https://example.org/feed', 301), _Hop('https://b.org/feed', 302)]))
|
|
|
|
|
self.assertEqual(r.permanent_url, 'https://b.org/feed')
|
|
|
|
|
for status in (302, 307):
|
|
|
|
|
r, _ = self.fetch(_HttpResponse('https://c.org/feed', content=self.RSS, history=[
|
|
|
|
|
_Hop('https://example.org/feed', status), _Hop('https://b.org/feed', 301)]))
|
|
|
|
|
self.assertEqual(r.permanent_url, 'https://example.org/feed')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeedRefreshTests(TestCase):
|
|
|
|
|
"""What a refresh stores: validators, 304s, errors, moved feeds."""
|
|
|
|
|
|
|
|
|
|
V = {'etag': '"v1"', 'last_modified': 'Mon, 05 Jan 2026 10:00:00 GMT'}
|
|
|
|
|
|
|
|
|
|
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://example.org/feed', title='Feed',
|
|
|
|
|
last_refreshed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
|
|
|
http_etag=self.V['etag'], http_last_modified=self.V['last_modified'],
|
|
|
|
|
)
|
|
|
|
|
PodcastEpisode.objects.create(feed=self.feed, guid='old', title='Old', audio_url='https://example.org/old.mp3')
|
|
|
|
|
self.calls = []
|
|
|
|
|
self.answer = None
|
|
|
|
|
|
|
|
|
|
def fake_fetch(self, url, **kw):
|
|
|
|
|
self.calls.append((url, kw))
|
|
|
|
|
if isinstance(self.answer, Exception):
|
|
|
|
|
raise self.answer
|
|
|
|
|
return self.answer
|
|
|
|
|
|
|
|
|
|
def refresh(self, feeds=None):
|
|
|
|
|
from .views import _refresh_feeds
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
return _refresh_feeds(feeds or [self.feed])
|
|
|
|
|
|
|
|
|
|
def test_304_touches_no_episodes(self):
|
|
|
|
|
from .views import FeedFetch
|
|
|
|
|
self.feed.error_count, self.feed.last_error = 2, 'timeout'
|
|
|
|
|
self.feed.last_error_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
|
|
|
|
self.feed.save()
|
|
|
|
|
self.answer = FeedFetch(304, None, **self.V)
|
|
|
|
|
with patch('podcasts.views.PodcastEpisode.objects.get_or_create') as create, \
|
|
|
|
|
patch('podcasts.views.PodcastEpisode.objects.bulk_update') as update:
|
|
|
|
|
self.assertEqual(self.refresh(), (1, 0, 0))
|
|
|
|
|
create.assert_not_called()
|
|
|
|
|
update.assert_not_called()
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/feed', self.V)])
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertGreater(self.feed.last_refreshed_at, datetime(2026, 1, 1, tzinfo=timezone.utc))
|
|
|
|
|
self.assertEqual((self.feed.error_count, self.feed.last_error, self.feed.last_error_at), (0, '', None))
|
|
|
|
|
self.assertEqual((self.feed.http_etag, self.feed.http_last_modified), (self.V['etag'], self.V['last_modified']))
|
|
|
|
|
self.assertEqual(self.feed.episodes.count(), 1)
|
|
|
|
|
|
|
|
|
|
def test_200_stores_new_validators(self):
|
|
|
|
|
self.answer = _ok(_rss([('new', 'Mon, 05 Jan 2026 10:00:00 GMT')]), etag='"v2"', last_modified='')
|
|
|
|
|
self.assertEqual(self.refresh(), (1, 0, 1))
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual((self.feed.http_etag, self.feed.http_last_modified), ('"v2"', ''))
|
|
|
|
|
|
|
|
|
|
def test_first_fetch_is_never_conditional(self):
|
|
|
|
|
self.feed.last_refreshed_at = None
|
|
|
|
|
self.feed.save()
|
|
|
|
|
self.answer = _ok(_rss([]))
|
|
|
|
|
self.refresh()
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/feed', {})])
|
|
|
|
|
# Nothing stored yet either: unconditional.
|
|
|
|
|
self.calls.clear()
|
|
|
|
|
new = PodcastFeed.objects.create(user=self.user, rss_url='https://example.org/n', title='N')
|
|
|
|
|
self.refresh([new])
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/n', {})])
|
|
|
|
|
|
|
|
|
|
def test_shared_url_validators_only_when_all_agree(self):
|
|
|
|
|
from .views import FeedFetch
|
|
|
|
|
bob = User.objects.create_user(username='bob', password='pw12345678')
|
|
|
|
|
bobs = PodcastFeed.objects.create(
|
|
|
|
|
user=bob, rss_url='https://example.org/feed', title='Feed',
|
|
|
|
|
last_refreshed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
|
|
|
http_etag=self.V['etag'], http_last_modified=self.V['last_modified'],
|
|
|
|
|
)
|
|
|
|
|
self.answer = FeedFetch(304, None, **self.V)
|
|
|
|
|
self.assertEqual(self.refresh([self.feed, bobs]), (2, 0, 0))
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/feed', self.V)])
|
|
|
|
|
|
|
|
|
|
# Bob's copy has other validators: one full download for both.
|
|
|
|
|
bobs.http_etag = '"v0"'
|
|
|
|
|
bobs.save()
|
|
|
|
|
self.calls.clear()
|
|
|
|
|
self.answer = _ok(_rss([('new', 'Mon, 05 Jan 2026 10:00:00 GMT')]), etag='"v2"')
|
|
|
|
|
self.assertEqual(self.refresh([self.feed, bobs]), (2, 0, 2))
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/feed', {})])
|
|
|
|
|
self.assertEqual(set(PodcastFeed.objects.values_list('http_etag', flat=True)), {'"v2"'})
|
|
|
|
|
|
|
|
|
|
# Carol just subscribed (never fetched): a full download as well.
|
|
|
|
|
carol = User.objects.create_user(username='carol', password='pw12345678')
|
|
|
|
|
carols = PodcastFeed.objects.create(user=carol, rss_url='https://example.org/feed', title='Feed',
|
|
|
|
|
http_etag='"v2"')
|
|
|
|
|
self.calls.clear()
|
|
|
|
|
self.refresh([self.feed, PodcastFeed.objects.get(pk=bobs.pk), carols])
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/feed', {})])
|
|
|
|
|
self.assertEqual(PodcastEpisode.objects.filter(feed=carols).count(), 1)
|
|
|
|
|
|
|
|
|
|
def test_errors_are_counted_and_cleared(self):
|
|
|
|
|
from .views import FeedFetchError
|
|
|
|
|
self.answer = FeedFetchError('HTTP 404')
|
|
|
|
|
self.assertEqual(self.refresh(), (0, 1, 0))
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.refresh([self.feed]), (0, 1, 0))
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual((self.feed.last_error, self.feed.error_count), ('HTTP 404', 2))
|
|
|
|
|
self.assertIsNotNone(self.feed.last_error_at)
|
|
|
|
|
# Unchanged otherwise: still the last good refresh.
|
|
|
|
|
self.assertEqual(self.feed.last_refreshed_at, datetime(2026, 1, 1, tzinfo=timezone.utc))
|
|
|
|
|
|
|
|
|
|
feeds = self.client.get('/podcasts/feeds/').json()['feeds']
|
|
|
|
|
self.assertEqual(feeds[0]['last_error'], 'HTTP 404')
|
|
|
|
|
self.assertEqual(feeds[0]['error_count'], 2)
|
|
|
|
|
self.assertEqual(feeds[0]['last_error_at'], self.feed.last_error_at.isoformat())
|
|
|
|
|
state = self.client.get('/podcasts/state/').json()['feeds']
|
|
|
|
|
self.assertEqual(state[0]['error_count'], 2)
|
|
|
|
|
|
|
|
|
|
self.answer = _ok(_rss([('new', 'Mon, 05 Jan 2026 10:00:00 GMT')]))
|
|
|
|
|
self.assertEqual(self.refresh([self.feed]), (1, 0, 1))
|
|
|
|
|
feeds = self.client.get('/podcasts/feeds/').json()['feeds']
|
|
|
|
|
self.assertEqual((feeds[0]['last_error'], feeds[0]['last_error_at'], feeds[0]['error_count']), ('', None, 0))
|
|
|
|
|
|
|
|
|
|
def test_refresh_now_reports_and_stores_the_error(self):
|
|
|
|
|
from .views import FeedFetchError
|
|
|
|
|
self.answer = FeedFetchError('not a feed')
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
r = self.client.post('/podcasts/feeds/refresh/', json.dumps({'feed_id': self.feed.id}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
self.assertEqual(r.status_code, 502)
|
|
|
|
|
self.assertEqual(r.json()['error'], 'not a feed')
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/feed', self.V)])
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual((self.feed.last_error, self.feed.error_count), ('not a feed', 1))
|
|
|
|
|
|
|
|
|
|
def test_failed_subscription_is_not_kept(self):
|
|
|
|
|
from .views import FeedFetchError
|
|
|
|
|
self.answer = FeedFetchError('HTTP 404')
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
r = self.client.post('/podcasts/feeds/add/', json.dumps({'rss_url': 'https://example.org/gone'}),
|
|
|
|
|
content_type='application/json')
|
|
|
|
|
self.assertEqual(r.status_code, 400)
|
|
|
|
|
self.assertIn('HTTP 404', r.json()['error'])
|
|
|
|
|
self.assertEqual(self.calls, [('https://example.org/gone', {})])
|
|
|
|
|
self.assertFalse(PodcastFeed.objects.filter(rss_url='https://example.org/gone').exists())
|
|
|
|
|
|
|
|
|
|
def test_permanent_redirect_moves_the_subscription(self):
|
|
|
|
|
self.answer = _ok(_rss([('new', 'Mon, 05 Jan 2026 10:00:00 GMT')]),
|
|
|
|
|
final_url='https://new.org/feed', permanent_url='https://new.org/feed')
|
|
|
|
|
self.assertEqual(self.refresh(), (1, 0, 1))
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.feed.rss_url, 'https://new.org/feed')
|
|
|
|
|
self.assertEqual(self.feed.episodes.count(), 2)
|
|
|
|
|
|
|
|
|
|
def test_permanent_redirect_to_a_url_the_user_has_already(self):
|
|
|
|
|
other = PodcastFeed.objects.create(user=self.user, rss_url='https://new.org/feed', title='Other')
|
|
|
|
|
self.answer = _ok(_rss([('new', 'Mon, 05 Jan 2026 10:00:00 GMT')]), etag='"v2"',
|
|
|
|
|
final_url='https://new.org/feed', permanent_url='https://new.org/feed')
|
|
|
|
|
self.assertEqual(self.refresh(), (1, 0, 1))
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.feed.rss_url, 'https://example.org/feed')
|
|
|
|
|
self.assertEqual((self.feed.error_count, self.feed.http_etag), (0, '"v2"'))
|
|
|
|
|
self.assertEqual(PodcastFeed.objects.get(pk=other.pk).rss_url, 'https://new.org/feed')
|
|
|
|
|
|
|
|
|
|
def test_temporary_redirect_keeps_the_url(self):
|
|
|
|
|
self.answer = _ok(_rss([]), final_url='https://tmp.org/feed', permanent_url='https://example.org/feed')
|
|
|
|
|
self.refresh()
|
|
|
|
|
self.feed.refresh_from_db()
|
|
|
|
|
self.assertEqual(self.feed.rss_url, 'https://example.org/feed')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeedBackoffTests(TestCase):
|
|
|
|
|
"""The hourly job tries a feed failing 3+ times in a row less often."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
from django.utils import timezone as dj_tz
|
|
|
|
|
self.user = User.objects.create_user(username='alice', password='pw12345678')
|
|
|
|
|
self.client.force_login(self.user)
|
|
|
|
|
now = dj_tz.now()
|
|
|
|
|
long_ago = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
# (name, error_count, hours since the last error) -> due in the hourly job?
|
|
|
|
|
self.cases = {
|
|
|
|
|
'ok': (0, None, True),
|
|
|
|
|
'two': (2, 0.2, True),
|
|
|
|
|
'three-early': (3, 1, False), # waits 2 h
|
|
|
|
|
'three-due': (3, 2.5, True),
|
|
|
|
|
'four-early': (4, 3, False), # waits 4 h
|
|
|
|
|
'four-due': (4, 4.5, True),
|
|
|
|
|
'six-early': (6, 15, False), # waits 16 h
|
|
|
|
|
'six-due': (6, 17, True),
|
|
|
|
|
'ten-early': (10, 23, False), # capped at 24 h
|
|
|
|
|
'ten-due': (10, 25, True),
|
|
|
|
|
}
|
|
|
|
|
self.feeds = {}
|
|
|
|
|
for name, (count, hours, _) in self.cases.items():
|
|
|
|
|
self.feeds[name] = PodcastFeed.objects.create(
|
|
|
|
|
user=self.user, rss_url=f'https://example.org/{name}', title=name,
|
|
|
|
|
last_refreshed_at=long_ago, error_count=count, last_error='timeout' if count else '',
|
|
|
|
|
last_error_at=now - timedelta(hours=hours) if hours is not None else None,
|
|
|
|
|
)
|
|
|
|
|
self.fetched = []
|
|
|
|
|
|
|
|
|
|
def fake_fetch(self, url, **kw):
|
|
|
|
|
self.fetched.append(url.rsplit('/', 1)[1])
|
|
|
|
|
return _ok(_rss([]))
|
|
|
|
|
|
|
|
|
|
def run_command(self, *args):
|
|
|
|
|
from io import StringIO
|
|
|
|
|
from django.core.management import call_command
|
|
|
|
|
out = StringIO()
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
call_command('refresh_feeds', *args, stdout=out)
|
|
|
|
|
return out.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_hourly_job_skips_feeds_in_backoff(self):
|
|
|
|
|
out = self.run_command()
|
|
|
|
|
due = {name for name, (_, _, is_due) in self.cases.items() if is_due}
|
|
|
|
|
self.assertEqual(set(self.fetched), due)
|
|
|
|
|
self.assertIn(f'{len(due)} feeds refreshed, 0 failed', out)
|
|
|
|
|
|
|
|
|
|
def test_explicit_feed_id_ignores_backoff(self):
|
|
|
|
|
self.run_command('--feed-id', str(self.feeds['ten-early'].id))
|
|
|
|
|
self.assertEqual(self.fetched, ['ten-early'])
|
|
|
|
|
|
|
|
|
|
def test_user_refresh_all_tries_them_but_respects_max_age(self):
|
|
|
|
|
from django.utils import timezone as dj_tz
|
|
|
|
|
recent = self.feeds['three-early']
|
|
|
|
|
recent.last_refreshed_at = dj_tz.now() - timedelta(seconds=60)
|
|
|
|
|
recent.save()
|
|
|
|
|
with patch('podcasts.views._fetch_feed', self.fake_fetch):
|
|
|
|
|
r = self.client.post('/podcasts/feeds/refresh-all/', '{}', content_type='application/json')
|
|
|
|
|
self.assertEqual(set(self.fetched), set(self.cases) - {'three-early'})
|
|
|
|
|
self.assertEqual(r.json()['refreshed'], len(self.cases) - 1)
|
|
|
|
|
|
|
|
|
|
def test_backoff_hours(self):
|
|
|
|
|
from .views import _backoff_hours
|
|
|
|
|
self.assertEqual([_backoff_hours(n) for n in range(0, 9)], [0, 0, 0, 2, 4, 8, 16, 24, 24])
|