Compare commits
No commits in common. "fd0a103fb245ab6909b48138e2e25725019fd5ec" and "a1e17081c5603c207b6ed047474c07ed5819b62b" have entirely different histories.
fd0a103fb2
...
a1e17081c5
8 changed files with 9 additions and 949 deletions
|
|
@ -1,166 +0,0 @@
|
||||||
"""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')
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
# 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,15 +34,6 @@ class PodcastEpisode(models.Model):
|
||||||
season_number = models.IntegerField(null=True, blank=True)
|
season_number = models.IntegerField(null=True, blank=True)
|
||||||
artwork_url = models.URLField(max_length=1000, blank=True)
|
artwork_url = models.URLField(max_length=1000, blank=True)
|
||||||
discovered_at = models.DateTimeField(auto_now_add=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:
|
class Meta:
|
||||||
unique_together = ('feed', 'guid')
|
unique_together = ('feed', 'guid')
|
||||||
|
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -227,311 +227,4 @@ class RefreshAllTests(TestCase):
|
||||||
self.assertEqual([q['episode__id'] for q in data['queue']], [ep.id])
|
self.assertEqual([q['episode__id'] for q in data['queue']], [ep.id])
|
||||||
self.assertEqual([e['id'] for e in data['inbox']], [ep.id])
|
self.assertEqual([e['id'] for e in data['inbox']], [ep.id])
|
||||||
self.assertTrue(data['inbox'][0]['in_queue'])
|
self.assertTrue(data['inbox'][0]['in_queue'])
|
||||||
# Shownotes stay out of the one-request state (they can be large).
|
|
||||||
self.assertNotIn('description', data['inbox'][0])
|
|
||||||
self.assertEqual(data['settings'], {'enqueue_position': 'back', 'auto_queue_position': 'front'})
|
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,7 +12,6 @@ urlpatterns = [
|
||||||
path('feeds/<int:pk>/remove/', views.remove_feed, name='podcast_remove_feed'),
|
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>/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('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/', views.queue_get, name='podcast_queue_get'),
|
||||||
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
path('queue/add/', views.queue_add, name='podcast_queue_add'),
|
||||||
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
|
path('queue/remove/', views.queue_remove, name='podcast_queue_remove'),
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,13 @@ from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
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 .models import EpisodeProgress, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings
|
||||||
from .shownotes import extract_timestamps, sanitize_html
|
|
||||||
|
|
||||||
feedext.install()
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
FEED_TIMEOUT = 20 # seconds per feed download
|
FEED_TIMEOUT = 20 # seconds per feed download
|
||||||
NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
|
NEW_ON_SUBSCRIBE = 1 # episodes of a new subscription that count as new
|
||||||
FEED_WORKERS = 8 # feeds downloaded at the same time
|
FEED_WORKERS = 8 # feeds downloaded at the same time
|
||||||
MAX_DESCRIPTION = 50_000 # characters of shownotes HTML kept per episode
|
|
||||||
MAX_TRANSCRIPTS = 10
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -123,78 +116,6 @@ def _parse_duration(raw):
|
||||||
return 0
|
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
|
# Shared feed refresh helper
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -269,18 +190,8 @@ def _refresh_feed(feed_obj, parsed=None):
|
||||||
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
max_ep = getattr(settings, 'PODCAST_MAX_EPISODES_PER_FEED', 200)
|
||||||
new_count = 0
|
new_count = 0
|
||||||
new_episodes_list = []
|
new_episodes_list = []
|
||||||
entries = parsed.entries[:max_ep]
|
|
||||||
|
|
||||||
# Episodes we already have, in one query, so shownotes/chapters/transcripts
|
for entry in parsed.entries[:max_ep]:
|
||||||
# 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
|
# Find audio enclosure
|
||||||
audio_url = ''
|
audio_url = ''
|
||||||
for enc in entry.get('enclosures', []):
|
for enc in entry.get('enclosures', []):
|
||||||
|
|
@ -294,16 +205,7 @@ def _refresh_feed(feed_obj, parsed=None):
|
||||||
guid = entry.get('id') or entry.get('guid') or audio_url
|
guid = entry.get('id') or entry.get('guid') or audio_url
|
||||||
guid = str(guid)[:1000]
|
guid = str(guid)[:1000]
|
||||||
title = entry.get('title', 'Untitled')[:500]
|
title = entry.get('title', 'Untitled')[:500]
|
||||||
description = _entry_description(entry)
|
description = entry.get('summary', entry.get('description', ''))
|
||||||
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_raw = entry.get('itunes_duration', 0)
|
||||||
duration_seconds = _parse_duration(duration_raw)
|
duration_seconds = _parse_duration(duration_raw)
|
||||||
|
|
@ -340,20 +242,11 @@ def _refresh_feed(feed_obj, parsed=None):
|
||||||
'episode_number': ep_number,
|
'episode_number': ep_number,
|
||||||
'season_number': season_number,
|
'season_number': season_number,
|
||||||
'artwork_url': artwork_url,
|
'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:
|
if created:
|
||||||
new_count += 1
|
new_count += 1
|
||||||
new_episodes_list.append(episode_obj)
|
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:
|
if first_fetch and len(new_episodes_list) > NEW_ON_SUBSCRIBE:
|
||||||
# Only the newest episodes count as new; the rest stay in the
|
# Only the newest episodes count as new; the rest stay in the
|
||||||
|
|
@ -582,69 +475,6 @@ 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
|
# Import OPML
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -753,7 +583,7 @@ def podcast_state(request):
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'feeds': _feeds_data(request.user),
|
'feeds': _feeds_data(request.user),
|
||||||
'queue': _queue_data(request.user),
|
'queue': _queue_data(request.user),
|
||||||
'inbox': _inbox_data(request.user, 0, limit, with_description=False),
|
'inbox': _inbox_data(request.user, 0, limit),
|
||||||
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position},
|
'settings': {'enqueue_position': s.enqueue_position, 'auto_queue_position': s.auto_queue_position},
|
||||||
'server_time': timezone.now().isoformat(),
|
'server_time': timezone.now().isoformat(),
|
||||||
})
|
})
|
||||||
|
|
@ -1018,13 +848,7 @@ def inbox(request):
|
||||||
return JsonResponse({'episodes': _inbox_data(request.user, offset, limit), 'offset': offset, 'limit': limit})
|
return JsonResponse({'episodes': _inbox_data(request.user, offset, limit), 'offset': offset, 'limit': limit})
|
||||||
|
|
||||||
|
|
||||||
def _inbox_data(user, offset, limit, with_description=True):
|
def _inbox_data(user, offset, limit):
|
||||||
"""Inbox episodes. Shownotes can be large (up to 50 KB each); the apps'
|
|
||||||
state/ leaves them out and loads one episode's at a time."""
|
|
||||||
fields = ['id', 'title', 'audio_url', 'duration_seconds', 'pub_date', 'artwork_url',
|
|
||||||
'feed__id', 'feed__title', 'feed__artwork_url']
|
|
||||||
if with_description:
|
|
||||||
fields.insert(2, 'description')
|
|
||||||
hidden_ids = set(
|
hidden_ids = set(
|
||||||
EpisodeProgress.objects.filter(
|
EpisodeProgress.objects.filter(
|
||||||
user=user,
|
user=user,
|
||||||
|
|
@ -1038,7 +862,11 @@ def _inbox_data(user, offset, limit, with_description=True):
|
||||||
.exclude(id__in=hidden_ids)
|
.exclude(id__in=hidden_ids)
|
||||||
.select_related('feed')
|
.select_related('feed')
|
||||||
.order_by('-pub_date')
|
.order_by('-pub_date')
|
||||||
.values(*fields)[offset:offset + limit]
|
.values(
|
||||||
|
'id', 'title', 'description', 'audio_url', 'duration_seconds',
|
||||||
|
'pub_date', 'artwork_url',
|
||||||
|
'feed__id', 'feed__title', 'feed__artwork_url',
|
||||||
|
)[offset:offset + limit]
|
||||||
)
|
)
|
||||||
|
|
||||||
progress_map = {
|
progress_map = {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue