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>
This commit is contained in:
parent
a1e17081c5
commit
f4b8bcc891
8 changed files with 938 additions and 2 deletions
166
podcasts/chapters.py
Normal file
166
podcasts/chapters.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Podcast chapters: Podlove Simple Chapters from the feed, and Podcasting 2.0
|
||||
JSON chapters fetched from the URL a feed points to.
|
||||
|
||||
Both end up in one shape, a list sorted by start time:
|
||||
[{"start": 62.5, "title": "…", "url": "https://…", "img": "https://…"}]
|
||||
url and img only when present.
|
||||
|
||||
The JSON file lives on a server the feed's author chose, so fetching it is an
|
||||
SSRF surface like the WebDAV import, and it reuses that module's guards:
|
||||
public addresses only (checked before connecting and again on the connected
|
||||
socket), redirects followed by hand with each hop re-checked, and a size cap.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from accounts.webdav import WebDAVError, WebDAVInputError, _assert_peer_is_safe, _read_capped, assert_safe_url
|
||||
|
||||
MAX_CHAPTERS = 500
|
||||
MAX_TITLE = 500
|
||||
MAX_URL = 1000
|
||||
CHAPTERS_TIMEOUT = 10 # seconds
|
||||
CHAPTERS_MAX_BYTES = 1024 * 1024
|
||||
CHAPTERS_MAX_REDIRECTS = 3
|
||||
|
||||
_SCHEMES = ('http://', 'https://')
|
||||
|
||||
|
||||
def _clean_url(value):
|
||||
value = (value or '').strip() if isinstance(value, str) else ''
|
||||
if value.lower().startswith(_SCHEMES) and len(value) <= MAX_URL:
|
||||
return value
|
||||
return ''
|
||||
|
||||
|
||||
def _chapter(start, title, url='', img=''):
|
||||
"""One normalised chapter, or None if the start isn't a usable time."""
|
||||
try:
|
||||
start = float(start)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if start != start or start < 0 or start == float('inf'): # NaN, negative, inf
|
||||
return None
|
||||
ch = {'start': round(start, 3), 'title': ' '.join(str(title or '').split())[:MAX_TITLE]}
|
||||
url, img = _clean_url(url), _clean_url(img)
|
||||
if url:
|
||||
ch['url'] = url
|
||||
if img:
|
||||
ch['img'] = img
|
||||
return ch
|
||||
|
||||
|
||||
def _finish(chapters):
|
||||
chapters = [c for c in chapters if c is not None]
|
||||
chapters.sort(key=lambda c: c['start'])
|
||||
return chapters[:MAX_CHAPTERS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Podlove Simple Chapters (inline in the feed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# "1:02:03.500", "01:02:03", "02:03", "123.5" (normal play time, as PSC uses)
|
||||
_NPT = re.compile(r'^\s*(?:(?:(\d+):)?(\d{1,2}):)?(\d+(?:\.\d+)?)\s*$')
|
||||
|
||||
|
||||
def parse_npt(value):
|
||||
"""Seconds (float) from a PSC start time, or None."""
|
||||
m = _NPT.match(str(value or ''))
|
||||
if not m:
|
||||
return None
|
||||
hours, minutes, seconds = m.groups()
|
||||
return int(hours or 0) * 3600 + int(minutes or 0) * 60 + float(seconds)
|
||||
|
||||
|
||||
def from_psc(entry):
|
||||
"""Chapters from feedparser's entry["psc_chapters"], [] if there are none."""
|
||||
raw = (entry.get('psc_chapters') or {}).get('chapters') or []
|
||||
return _finish(
|
||||
_chapter(parse_npt(c.get('start')), c.get('title'), c.get('href'), c.get('image'))
|
||||
for c in raw
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Podcasting 2.0 JSON chapters (fetched)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def from_json(data):
|
||||
"""Normalise a JSON chapters document (https://github.com/Podcastindex-org/
|
||||
podcast-namespace/blob/main/chapters/jsonChapters.md)."""
|
||||
raw = data.get('chapters') if isinstance(data, dict) else None
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError('no chapters list')
|
||||
chapters = []
|
||||
for c in raw:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
# toc: false = "don't show this one to the user" (e.g. art changes).
|
||||
if c.get('toc') is False:
|
||||
continue
|
||||
chapters.append(_chapter(c.get('startTime'), c.get('title'), c.get('url'), c.get('img')))
|
||||
return _finish(chapters)
|
||||
|
||||
|
||||
class ChaptersUnavailable(Exception):
|
||||
"""The chapters can't be loaded right now; worth trying again later."""
|
||||
|
||||
|
||||
class ChaptersInvalid(Exception):
|
||||
"""The chapters URL is unusable for good (blocked, missing, not chapters)."""
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
"""Download and parse the JSON at `url`.
|
||||
|
||||
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
|
||||
ones that may go away (network trouble, server errors).
|
||||
"""
|
||||
for _ in range(CHAPTERS_MAX_REDIRECTS + 1):
|
||||
try:
|
||||
assert_safe_url(url)
|
||||
except WebDAVInputError as e:
|
||||
raise ChaptersInvalid(str(e))
|
||||
try:
|
||||
r = requests.get(
|
||||
url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True,
|
||||
headers={'User-Agent': 'diora podcast chapters', 'Accept': 'application/json'},
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
raise ChaptersUnavailable(e.__class__.__name__)
|
||||
try:
|
||||
_assert_peer_is_safe(r)
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
location = r.headers.get('Location')
|
||||
if not location:
|
||||
raise ChaptersInvalid('redirect without a location')
|
||||
url = urljoin(url, location)
|
||||
continue
|
||||
if r.status_code >= 500 or r.status_code == 429:
|
||||
raise ChaptersUnavailable(f'HTTP {r.status_code}')
|
||||
if r.status_code >= 400:
|
||||
raise ChaptersInvalid(f'HTTP {r.status_code}')
|
||||
body = _read_capped(r, CHAPTERS_MAX_BYTES, lambda: ChaptersInvalid('too large'))
|
||||
except WebDAVInputError as e:
|
||||
raise ChaptersInvalid(str(e))
|
||||
except WebDAVError as e: # transfer broke off in _read_capped
|
||||
raise ChaptersUnavailable(str(e))
|
||||
finally:
|
||||
r.close()
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError:
|
||||
raise ChaptersInvalid('not JSON')
|
||||
raise ChaptersInvalid('too many redirects')
|
||||
|
||||
|
||||
def fetch(url):
|
||||
"""Normalised chapters from a Podcasting 2.0 chapters URL."""
|
||||
try:
|
||||
return from_json(fetch_json(url))
|
||||
except ValueError:
|
||||
raise ChaptersInvalid('not a chapters document')
|
||||
43
podcasts/feedext.py
Normal file
43
podcasts/feedext.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Teach feedparser the Podcasting 2.0 tags we read.
|
||||
|
||||
feedparser 6 doesn't know the podcast namespace. Unknown tags still come
|
||||
through, but only under whatever prefix the feed happened to declare, and an
|
||||
unknown tag with attributes is stored as a single dict, so a second
|
||||
<podcast:transcript> overwrites the first. Registering the namespace fixes the
|
||||
prefix ("podcast_…" for every feed); the handler below keeps every
|
||||
transcript in a list, entry["podcast_transcripts"].
|
||||
|
||||
podcast:chapters is left to feedparser's default handling, which gives
|
||||
entry["podcast_chapters"] = {"url": …, "type": …} (one per item, as the spec
|
||||
says).
|
||||
"""
|
||||
|
||||
from feedparser.mixin import _FeedParserMixin as FeedParserMixin
|
||||
from feedparser.util import FeedParserDict
|
||||
|
||||
PODCAST_NAMESPACES = (
|
||||
'https://podcastindex.org/namespace/1.0',
|
||||
# The URL the namespace was announced under; some early feeds still use it.
|
||||
'https://github.com/podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
|
||||
)
|
||||
|
||||
|
||||
def _start_podcast_transcript(self, attrs_d):
|
||||
context = self._get_context()
|
||||
context.setdefault('podcast_transcripts', []).append(FeedParserDict(attrs_d))
|
||||
|
||||
|
||||
def _end_podcast_transcript(self):
|
||||
pass
|
||||
|
||||
|
||||
def install():
|
||||
"""Idempotent; called when podcasts.views is imported."""
|
||||
for uri in PODCAST_NAMESPACES:
|
||||
FeedParserMixin.namespaces.setdefault(uri, 'podcast')
|
||||
# Filled lazily from `namespaces` by the first parser; once it has
|
||||
# been, later additions have to go in here directly.
|
||||
if FeedParserMixin._matchnamespaces:
|
||||
FeedParserMixin._matchnamespaces.setdefault(uri.lower(), 'podcast')
|
||||
FeedParserMixin._start_podcast_transcript = _start_podcast_transcript
|
||||
FeedParserMixin._end_podcast_transcript = _end_podcast_transcript
|
||||
33
podcasts/migrations/0005_episode_chapters_transcripts.py
Normal file
33
podcasts/migrations/0005_episode_chapters_transcripts.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 6.1 on 2026-10-02 05:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('podcasts', '0004_podcastsettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='podcastepisode',
|
||||
name='chapters',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='podcastepisode',
|
||||
name='chapters_fetched_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='podcastepisode',
|
||||
name='chapters_url',
|
||||
field=models.URLField(blank=True, max_length=1000),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='podcastepisode',
|
||||
name='transcripts',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
|
|
@ -34,6 +34,15 @@ class PodcastEpisode(models.Model):
|
|||
season_number = models.IntegerField(null=True, blank=True)
|
||||
artwork_url = models.URLField(max_length=1000, blank=True)
|
||||
discovered_at = models.DateTimeField(auto_now_add=True)
|
||||
# Podcasting 2.0 <podcast:chapters url=…>: a JSON file, loaded on demand.
|
||||
chapters_url = models.URLField(max_length=1000, blank=True)
|
||||
# [{start: seconds, title, url?, img?}], from Podlove Simple Chapters in
|
||||
# the feed, or the JSON at chapters_url once it has been fetched.
|
||||
chapters = models.JSONField(default=list, blank=True)
|
||||
# When chapters_url was fetched into `chapters`; null = not yet.
|
||||
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
|
||||
# [{url, type, language}] from <podcast:transcript>.
|
||||
transcripts = models.JSONField(default=list, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('feed', 'guid')
|
||||
|
|
|
|||
209
podcasts/shownotes.py
Normal file
209
podcasts/shownotes.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Shownotes HTML: an allowlist sanitizer and timestamp extraction.
|
||||
|
||||
Feed HTML is written by whoever runs the podcast, so it is untrusted. feedparser
|
||||
already cleans it once while parsing, but the apps render what this server hands
|
||||
out in web views, so we clean it again on the way out with a deliberately small
|
||||
allowlist: unknown tags are dropped (their text kept), script-like tags are
|
||||
dropped with their content, and only a handful of attributes survive, with
|
||||
links limited to http(s)/mailto.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
ALLOWED_TAGS = {
|
||||
'a', 'abbr', 'b', 'blockquote', 'br', 'cite', 'code', 'dd', 'del', 'div', 'dl', 'dt',
|
||||
'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img',
|
||||
'ins', 'li', 'mark', 'ol', 'p', 'pre', 'q', 's', 'small', 'span', 'strong', 'sub',
|
||||
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u', 'ul',
|
||||
}
|
||||
VOID_TAGS = {'br', 'hr', 'img'}
|
||||
# Dropped together with everything inside them.
|
||||
DROP_CONTENT_TAGS = {
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template', 'svg',
|
||||
'math', 'head', 'title', 'frameset', 'frame', 'applet', 'audio', 'video', 'canvas',
|
||||
'select', 'textarea', 'button',
|
||||
}
|
||||
ALLOWED_ATTRS = {
|
||||
'a': {'href', 'title'},
|
||||
'img': {'src', 'alt', 'title', 'width', 'height'},
|
||||
'td': {'colspan', 'rowspan'},
|
||||
'th': {'colspan', 'rowspan'},
|
||||
'abbr': {'title'},
|
||||
'time': {'datetime'},
|
||||
'ol': {'start'},
|
||||
}
|
||||
URL_ATTRS = {'href', 'src'}
|
||||
LINK_SCHEMES = {'http', 'https', 'mailto'}
|
||||
IMAGE_SCHEMES = {'http', 'https'}
|
||||
|
||||
_CONTROL_AND_SPACE = re.compile(r'[\x00-\x20\x7f]+')
|
||||
_SCHEME = re.compile(r'^([a-z][a-z0-9+.\-]*):')
|
||||
|
||||
|
||||
def _safe_url(value, schemes):
|
||||
"""The URL if it is absolute with an allowed scheme, else None.
|
||||
|
||||
Browsers ignore whitespace and control characters inside a scheme
|
||||
("java\\tscript:"), so those are removed before looking at it.
|
||||
"""
|
||||
compact = _CONTROL_AND_SPACE.sub('', value or '')
|
||||
m = _SCHEME.match(compact.lower())
|
||||
if not m or m.group(1) not in schemes:
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
class _Sanitizer(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.out = []
|
||||
self.open = [] # allowed tags currently open, outermost first
|
||||
self.dropping = 0 # depth inside DROP_CONTENT_TAGS
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self._start(tag, attrs, closed=False)
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
self._start(tag, attrs, closed=True)
|
||||
|
||||
def _start(self, tag, attrs, closed):
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
if not closed:
|
||||
self.dropping += 1
|
||||
return
|
||||
if self.dropping or tag not in ALLOWED_TAGS:
|
||||
return
|
||||
kept = []
|
||||
allowed = ALLOWED_ATTRS.get(tag, set())
|
||||
for name, value in attrs:
|
||||
name = name.lower()
|
||||
if name not in allowed or value is None:
|
||||
continue
|
||||
if name in URL_ATTRS:
|
||||
value = _safe_url(value, IMAGE_SCHEMES if tag == 'img' else LINK_SCHEMES)
|
||||
if value is None:
|
||||
continue
|
||||
kept.append((name, value))
|
||||
if tag == 'img' and not any(n == 'src' for n, _ in kept):
|
||||
return
|
||||
if tag == 'a':
|
||||
kept.append(('rel', 'nofollow noopener noreferrer'))
|
||||
attr_text = ''.join(f' {n}="{html.escape(v, quote=True)}"' for n, v in kept)
|
||||
self.out.append(f'<{tag}{attr_text}>')
|
||||
if tag not in VOID_TAGS and not closed:
|
||||
self.open.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
if self.dropping:
|
||||
self.dropping -= 1
|
||||
return
|
||||
if self.dropping or tag not in self.open:
|
||||
return
|
||||
# Close everything opened inside it too, so the output stays nested.
|
||||
while self.open:
|
||||
t = self.open.pop()
|
||||
self.out.append(f'</{t}>')
|
||||
if t == tag:
|
||||
break
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.dropping:
|
||||
self.out.append(html.escape(data, quote=False))
|
||||
|
||||
def result(self):
|
||||
self.close()
|
||||
while self.open:
|
||||
self.out.append(f'</{self.open.pop()}>')
|
||||
return ''.join(self.out)
|
||||
|
||||
|
||||
def sanitize_html(text):
|
||||
"""Return `text` reduced to the allowlist above."""
|
||||
if not text:
|
||||
return ''
|
||||
s = _Sanitizer()
|
||||
s.feed(text)
|
||||
return s.result()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plain text and timestamps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCK_TAGS = {
|
||||
'br', 'p', 'div', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote',
|
||||
'pre', 'dd', 'dt', 'hr', 'table', 'ul', 'ol', 'figure', 'figcaption',
|
||||
}
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.out = []
|
||||
self.dropping = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
self.dropping += 1
|
||||
elif tag in _BLOCK_TAGS:
|
||||
self.out.append('\n')
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
if tag in _BLOCK_TAGS:
|
||||
self.out.append('\n')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
self.dropping = max(self.dropping - 1, 0)
|
||||
elif tag in _BLOCK_TAGS:
|
||||
self.out.append('\n')
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.dropping:
|
||||
self.out.append(data)
|
||||
|
||||
|
||||
def html_to_text(text):
|
||||
"""Shownotes as plain text, one line per block/line break."""
|
||||
if not text:
|
||||
return ''
|
||||
p = _TextExtractor()
|
||||
p.feed(text)
|
||||
p.close()
|
||||
return ''.join(p.out)
|
||||
|
||||
|
||||
# A time at the start of a line, optionally in brackets, then the label:
|
||||
# "12:34 Intro", "(1:02:03) - Fragen", "[00:05] Begrüßung".
|
||||
_TIMESTAMP_LINE = re.compile(
|
||||
r'^[\s\-*•·]*[(\[]?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:\.\d+)?[)\]]?(?=\s|$|[-–—:|])'
|
||||
r'\s*[-–—:|]?\s*(.*)$'
|
||||
)
|
||||
MAX_TIMESTAMPS = 300
|
||||
|
||||
|
||||
def extract_timestamps(text, duration_seconds=0):
|
||||
"""[{seconds, label}] for lines of the shownotes that start with a time.
|
||||
|
||||
Only line starts are looked at: a time in the middle of a sentence is just
|
||||
as likely to be a time of day.
|
||||
"""
|
||||
result = []
|
||||
for line in html_to_text(text).splitlines():
|
||||
m = _TIMESTAMP_LINE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
hours, minutes, seconds, label = m.groups()
|
||||
minutes, seconds = int(minutes), int(seconds)
|
||||
if seconds >= 60 or (hours is not None and minutes >= 60):
|
||||
continue
|
||||
total = int(hours or 0) * 3600 + minutes * 60 + seconds
|
||||
if duration_seconds and total > duration_seconds:
|
||||
continue
|
||||
result.append({'seconds': total, 'label': ' '.join(label.split())[:300]})
|
||||
if len(result) >= MAX_TIMESTAMPS:
|
||||
break
|
||||
return result
|
||||
|
|
@ -228,3 +228,308 @@ class RefreshAllTests(TestCase):
|
|||
self.assertEqual([e['id'] for e in data['inbox']], [ep.id])
|
||||
self.assertTrue(data['inbox'][0]['in_queue'])
|
||||
self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front'})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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):
|
||||
with patch('podcasts.views._fetch_feed', lambda url: _parse(rss)):
|
||||
_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)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ urlpatterns = [
|
|||
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
||||
path('feeds/<int:pk>/set-auto-queue/', views.set_auto_queue, name='podcast_set_auto_queue'),
|
||||
path('feeds/<int:pk>/episodes/', views.feed_episodes, name='podcast_feed_episodes'),
|
||||
path('episodes/<int:pk>/', views.episode_detail, name='podcast_episode_detail'),
|
||||
path('queue/', views.queue_get, name='podcast_queue_get'),
|
||||
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
|
||||
|
|
|
|||
|
|
@ -14,13 +14,20 @@ from django.utils import timezone
|
|||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from . import chapters as chapters_mod
|
||||
from . import feedext
|
||||
from .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
||||
from .shownotes import extract_timestamps, sanitize_html
|
||||
|
||||
feedext.install()
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
FEED_TIMEOUT = 20 # seconds per feed download
|
||||
NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
|
||||
FEED_WORKERS = 8 # feeds downloaded at the same time
|
||||
MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
|
||||
MAX_TRANSCRIPTS = 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -116,6 +123,78 @@ def _parse_duration(raw):
|
|||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shownotes, chapters and transcripts of a feed entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _entry_description(entry):
|
||||
"""Shownotes HTML: content:encoded when the feed has it (that's where the
|
||||
full notes usually are), else the description/summary."""
|
||||
for c in entry.get('content') or []:
|
||||
value = (c.get('value') or '').strip()
|
||||
if value:
|
||||
return value[:MAX_DESCRIPTION]
|
||||
return (entry.get('summary') or entry.get('description') or '')[:MAX_DESCRIPTION]
|
||||
|
||||
|
||||
def _entry_chapters_url(entry):
|
||||
url = ((entry.get('podcast_chapters') or {}).get('url') or '').strip()
|
||||
return url[:1000] if url.lower().startswith(('http://', 'https://')) else ''
|
||||
|
||||
|
||||
def _entry_transcripts(entry):
|
||||
raw = entry.get('podcast_transcripts')
|
||||
if raw is None and entry.get('podcast_transcript'):
|
||||
raw = [entry['podcast_transcript']] # feedparser without feedext
|
||||
transcripts = []
|
||||
for t in raw or []:
|
||||
url = (t.get('url') or '').strip()
|
||||
if not url.lower().startswith(('http://', 'https://')) or len(url) > 1000:
|
||||
continue
|
||||
transcripts.append({
|
||||
'url': url,
|
||||
'type': (t.get('type') or '')[:100],
|
||||
'language': (t.get('language') or '')[:35],
|
||||
})
|
||||
if len(transcripts) >= MAX_TRANSCRIPTS:
|
||||
break
|
||||
return transcripts
|
||||
|
||||
|
||||
def _apply_extras(episode, description, chapters_url, psc_chapters, transcripts):
|
||||
"""Copy what the feed says now onto `episode`; True if anything changed.
|
||||
|
||||
Inline (Podlove) chapters win. Without them, chapters fetched from
|
||||
chapters_url stay cached for as long as the URL stays the same.
|
||||
"""
|
||||
changed = []
|
||||
if episode.description != description:
|
||||
episode.description = description
|
||||
changed.append('description')
|
||||
if episode.transcripts != transcripts:
|
||||
episode.transcripts = transcripts
|
||||
changed.append('transcripts')
|
||||
if psc_chapters:
|
||||
chapters, fetched_at = psc_chapters, episode.chapters_fetched_at
|
||||
elif chapters_url and chapters_url == episode.chapters_url and episode.chapters_fetched_at:
|
||||
chapters, fetched_at = episode.chapters, episode.chapters_fetched_at
|
||||
else:
|
||||
chapters, fetched_at = [], None
|
||||
if episode.chapters_url != chapters_url:
|
||||
episode.chapters_url = chapters_url
|
||||
changed.append('chapters_url')
|
||||
if episode.chapters != chapters:
|
||||
episode.chapters = chapters
|
||||
changed.append('chapters')
|
||||
if episode.chapters_fetched_at != fetched_at:
|
||||
episode.chapters_fetched_at = fetched_at
|
||||
changed.append('chapters_fetched_at')
|
||||
return bool(changed)
|
||||
|
||||
|
||||
EXTRA_FIELDS = ['description', 'transcripts', 'chapters_url', 'chapters', 'chapters_fetched_at']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared feed refresh helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -190,8 +269,18 @@ def _refresh_feed(feed_obj, parsed=None):
|
|||
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
||||
new_count = 0
|
||||
new_episodes_list = []
|
||||
entries = parsed.entries[:max_ep]
|
||||
|
||||
for entry in parsed.entries[:max_ep]:
|
||||
# Episodes we already have, in one query, so shownotes/chapters/transcripts
|
||||
# that changed in the feed can be brought up to date (only those rows).
|
||||
guids = [str(e.get('id') or e.get('guid') or '')[:1000] for e in entries]
|
||||
existing = {
|
||||
ep.guid: ep
|
||||
for ep in feed_obj.episodes.filter(guid__in=[g for g in guids if g]).only('id', 'guid', *EXTRA_FIELDS)
|
||||
}
|
||||
changed_episodes = {}
|
||||
|
||||
for entry in entries:
|
||||
# Find audio enclosure
|
||||
audio_url = ''
|
||||
for enc in entry.get('enclosures', []):
|
||||
|
|
@ -205,7 +294,16 @@ def _refresh_feed(feed_obj, parsed=None):
|
|||
guid = entry.get('id') or entry.get('guid') or audio_url
|
||||
guid = str(guid)[:1000]
|
||||
title = entry.get('title', 'Untitled')[:500]
|
||||
description = entry.get('summary', entry.get('description', ''))
|
||||
description = _entry_description(entry)
|
||||
chapters_url = _entry_chapters_url(entry)
|
||||
psc_chapters = chapters_mod.from_psc(entry)
|
||||
transcripts = _entry_transcripts(entry)
|
||||
|
||||
known = existing.get(guid)
|
||||
if known is not None:
|
||||
if _apply_extras(known, description, chapters_url, psc_chapters, transcripts):
|
||||
changed_episodes[known.id] = known
|
||||
continue
|
||||
|
||||
duration_raw = entry.get('itunes_duration', 0)
|
||||
duration_seconds = _parse_duration(duration_raw)
|
||||
|
|
@ -242,11 +340,20 @@ def _refresh_feed(feed_obj, parsed=None):
|
|||
'episode_number': ep_number,
|
||||
'season_number': season_number,
|
||||
'artwork_url': artwork_url,
|
||||
'chapters_url': chapters_url,
|
||||
'chapters': psc_chapters,
|
||||
'transcripts': transcripts,
|
||||
},
|
||||
)
|
||||
existing[guid] = episode_obj # the same guid twice in one feed
|
||||
if created:
|
||||
new_count += 1
|
||||
new_episodes_list.append(episode_obj)
|
||||
elif _apply_extras(episode_obj, description, chapters_url, psc_chapters, transcripts):
|
||||
changed_episodes[episode_obj.id] = episode_obj
|
||||
|
||||
if changed_episodes:
|
||||
PodcastEpisode.objects.bulk_update(list(changed_episodes.values()), EXTRA_FIELDS, batch_size=100)
|
||||
|
||||
if first_fetch and len(new_episodes_list) > NEW_ON_SUBSCRIBE:
|
||||
# Only the newest episodes count as new; the rest stay in the
|
||||
|
|
@ -475,6 +582,69 @@ def feed_episodes(request, pk):
|
|||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# One episode in detail: shownotes, chapters, transcripts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_chapters(episode):
|
||||
"""Fetch the JSON chapters of `episode` once and cache them on it.
|
||||
|
||||
Problems that may go away (timeouts, server errors) aren't cached, so the
|
||||
next request tries again; anything else is remembered as "no chapters".
|
||||
"""
|
||||
url = episode.chapters_url
|
||||
try:
|
||||
chapters = chapters_mod.fetch(url)
|
||||
except chapters_mod.ChaptersUnavailable as e:
|
||||
log.info('chapters of episode %s not available: %s', episode.id, e)
|
||||
return []
|
||||
except chapters_mod.ChaptersInvalid as e:
|
||||
log.info('chapters of episode %s unusable: %s', episode.id, e)
|
||||
chapters = []
|
||||
now = timezone.now()
|
||||
# Only if a refresh hasn't changed the URL in the meantime.
|
||||
PodcastEpisode.objects.filter(pk=episode.pk, chapters_url=url).update(
|
||||
chapters=chapters, chapters_fetched_at=now,
|
||||
)
|
||||
episode.chapters, episode.chapters_fetched_at = chapters, now
|
||||
return chapters
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(['GET'])
|
||||
def episode_detail(request, pk):
|
||||
if not request.user.is_authenticated:
|
||||
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||
try:
|
||||
ep = PodcastEpisode.objects.select_related('feed').get(pk=pk, feed__user=request.user)
|
||||
except PodcastEpisode.DoesNotExist:
|
||||
return JsonResponse({'error': 'not found'}, status=404)
|
||||
|
||||
chapters = ep.chapters
|
||||
if not chapters and ep.chapters_url and ep.chapters_fetched_at is None:
|
||||
chapters = _load_chapters(ep)
|
||||
|
||||
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
|
||||
return JsonResponse({
|
||||
'id': ep.id,
|
||||
'title': ep.title,
|
||||
'description': sanitize_html(ep.description),
|
||||
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
|
||||
'chapters': chapters,
|
||||
'transcripts': ep.transcripts,
|
||||
'feed': {'id': ep.feed.id, 'title': ep.feed.title, 'artwork_url': ep.feed.artwork_url},
|
||||
'audio_url': ep.audio_url,
|
||||
'artwork_url': ep.artwork_url,
|
||||
'duration_seconds': ep.duration_seconds,
|
||||
'pub_date': ep.pub_date.isoformat() if ep.pub_date else None,
|
||||
'episode_number': ep.episode_number,
|
||||
'season_number': ep.season_number,
|
||||
'position_seconds': progress.position_seconds if progress else 0,
|
||||
'played': progress.played if progress else False,
|
||||
'in_queue': PodcastQueue.objects.filter(user=request.user, episode=ep).exists(),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import OPML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue