diff --git a/podcasts/chapters.py b/podcasts/chapters.py
index 3888489..d0632c5 100644
--- a/podcasts/chapters.py
+++ b/podcasts/chapters.py
@@ -1,30 +1,39 @@
-"""Podcast chapters: Podlove Simple Chapters from the feed, and Podcasting 2.0
-JSON chapters fetched from the URL a feed points to.
+"""Podcast chapters: Podlove Simple Chapters from the feed, Podcasting 2.0
+JSON chapters fetched from the URL a feed points to, and the CHAP frames of
+the ID3v2 tag at the start of the audio file.
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.
+The JSON file and the audio file live on servers the feed's author chose, so
+fetching them 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. Transcripts (transcripts.py) are fetched the same
+way, through `fetch_bytes`.
"""
import json
import re
+import time
from urllib.parse import urljoin
import requests
from accounts.webdav import WebDAVError, WebDAVInputError, _assert_peer_is_safe, _read_capped, assert_safe_url
+from . import id3
+
MAX_CHAPTERS = 500
MAX_TITLE = 500
MAX_URL = 1000
CHAPTERS_TIMEOUT = 10 # seconds
CHAPTERS_MAX_BYTES = 1024 * 1024
CHAPTERS_MAX_REDIRECTS = 3
+ID3_MAX_BYTES = 4 * 1024 * 1024 # of the audio file's start; a larger tag is read this far
+ID3_DEADLINE = 30 # seconds for reading it, however slowly it trickles in
+ID3_CHUNK = 64 * 1024
_SCHEMES = ('http://', 'https://')
@@ -114,8 +123,14 @@ 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`.
+# Generic names for the two kinds of failure, for other fetches (transcripts).
+FetchUnavailable = ChaptersUnavailable
+FetchInvalid = ChaptersInvalid
+
+
+def _open(url, headers):
+ """GET `url` with every redirect hop checked; the streamed response of
+ the final hop, which the caller has to close.
Raises ChaptersInvalid for permanent problems and ChaptersUnavailable for
ones that may go away (network trouble, server errors).
@@ -126,10 +141,7 @@ def fetch_json(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'},
- )
+ r = requests.get(url, timeout=CHAPTERS_TIMEOUT, allow_redirects=False, stream=True, headers=headers)
except requests.RequestException as e:
raise ChaptersUnavailable(e.__class__.__name__)
try:
@@ -139,28 +151,94 @@ def fetch_json(url):
if not location:
raise ChaptersInvalid('redirect without a location')
url = urljoin(url, location)
+ r.close()
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(str(e))
+ except Exception:
+ r.close()
+ raise
+ return r
raise ChaptersInvalid('too many redirects')
+def fetch_bytes(url, max_bytes, accept='*/*', agent='diora podcast chapters'):
+ """The body at `url`, at most `max_bytes` of it (more is ChaptersInvalid)."""
+ r = _open(url, {'User-Agent': agent, 'Accept': accept})
+ try:
+ return _read_capped(r, max_bytes, lambda: ChaptersInvalid('too large'))
+ except WebDAVError as e: # transfer broke off in _read_capped
+ raise ChaptersUnavailable(str(e))
+ finally:
+ r.close()
+
+
+def fetch_json(url):
+ """Download and parse the JSON at `url`."""
+ body = fetch_bytes(url, CHAPTERS_MAX_BYTES, accept='application/json')
+ try:
+ return json.loads(body)
+ except ValueError:
+ raise ChaptersInvalid('not JSON')
+
+
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')
+
+
+# ---------------------------------------------------------------------------
+# ID3v2 chapters (CHAP frames at the start of the audio file)
+# ---------------------------------------------------------------------------
+
+def read_id3_tag(url):
+ """The ID3v2 tag at the start of the file at `url` (at most ID3_MAX_BYTES
+ of it), or b'' if the file doesn't start with one.
+
+ Asks for just that part with a Range request. Servers that ignore Range
+ and send the whole file are read only as far as needed, then dropped.
+ """
+ r = _open(url, {
+ 'User-Agent': 'diora podcast chapters',
+ 'Range': f'bytes=0-{ID3_MAX_BYTES - 1}',
+ 'Accept-Encoding': 'identity',
+ })
+ data = bytearray()
+ need = None # bytes of the tag, known once its header is in
+ deadline = time.monotonic() + ID3_DEADLINE
+ try:
+ for chunk in r.iter_content(chunk_size=ID3_CHUNK):
+ data += chunk
+ if need is None and len(data) >= id3.HEADER_SIZE:
+ size = id3.tag_size(bytes(data[:id3.HEADER_SIZE]))
+ if size is None:
+ return b''
+ need = min(size, ID3_MAX_BYTES)
+ if need is not None and len(data) >= need:
+ break
+ if time.monotonic() > deadline:
+ raise ChaptersUnavailable('too slow')
+ except requests.RequestException as e:
+ raise ChaptersUnavailable(e.__class__.__name__)
+ finally:
+ r.close()
+ if need is None:
+ return b'' # shorter than a tag header
+ return bytes(data[:need]) # may be less if the file ended early
+
+
+def fetch_id3(url):
+ """Normalised chapters from the ID3v2 tag of the audio file at `url`;
+ [] if it has none."""
+ return _finish(
+ _chapter(start_ms / 1000, title, link)
+ for start_ms, _end_ms, title, link in id3.chapters(read_id3_tag(url))
+ )
diff --git a/podcasts/id3.py b/podcasts/id3.py
new file mode 100644
index 0000000..641c8c6
--- /dev/null
+++ b/podcasts/id3.py
@@ -0,0 +1,281 @@
+"""Chapters embedded in the ID3v2 tag at the start of an MP3 file.
+
+Written from the ID3v2.3.0 and ID3v2.4.0 specifications and the ID3v2
+Chapter Frame Addendum (CHAP and CTOC frames). Only what chapters need is
+read: the tag header, an optional extended header (skipped), and the frames.
+Of those, CHAP frames give the chapters, with the title from an embedded TIT2
+frame and a link from WXXX or another W*** frame; CTOC frames, when there
+are any, give the order (which matters only for chapters starting at the
+same time, as callers sort by start). Pictures (APIC) and everything else are
+skipped.
+
+Pure parsing, no I/O: the caller hands over the first bytes of the file
+(`tag_size` says how many are needed).
+"""
+
+import re
+import struct
+import zlib
+
+HEADER_SIZE = 10
+MAX_FRAME_BYTES = 4 * 1024 * 1024 # a decompressed frame can't grow past this
+
+_FRAME_ID = re.compile(rb'[A-Z0-9]{4}\Z')
+
+# Header flags
+_UNSYNC = 0x80
+_EXTENDED = 0x40
+_FOOTER = 0x10
+
+
+def _synchsafe(raw):
+ """Integer from bytes that carry 7 bits each (the high bit is always 0)."""
+ value = 0
+ for b in raw:
+ value = (value << 7) | (b & 0x7F)
+ return value
+
+
+def tag_size(header):
+ """Bytes the ID3v2 tag at the start of a file takes up, header and footer
+ included, from the file's first 10 bytes; None if there's no such tag."""
+ if len(header) < HEADER_SIZE or header[:3] != b'ID3':
+ return None
+ major, revision, flags = header[3], header[4], header[5]
+ if major == 0xFF or revision == 0xFF or any(b & 0x80 for b in header[6:10]):
+ return None
+ size = HEADER_SIZE + _synchsafe(header[6:10])
+ if major >= 4 and flags & _FOOTER:
+ size += HEADER_SIZE
+ return size
+
+
+def _deunsync(data):
+ """Undo unsynchronisation: every $FF $00 was an $FF before."""
+ return data.replace(b'\xff\x00', b'\xff')
+
+
+# ---------------------------------------------------------------------------
+# Frames
+# ---------------------------------------------------------------------------
+
+def _plausible_frame_start(buf, at):
+ """True if a frame (or padding, or the end of `buf`) can begin at `at`."""
+ if at == len(buf):
+ return True
+ if at > len(buf):
+ return False
+ head = buf[at:at + 4]
+ return bool(_FRAME_ID.match(head)) or not head.strip(b'\x00')
+
+
+def _v24_frame_size(buf, pos, raw):
+ """Size of a v2.4 frame. The spec says synchsafe; some writers (older
+ iTunes among them) store a plain integer, so where the two differ, take
+ the one after which the next frame starts."""
+ plain = int.from_bytes(raw, 'big')
+ if any(b & 0x80 for b in raw):
+ return plain # can't be synchsafe
+ safe = _synchsafe(raw)
+ if safe == plain or _plausible_frame_start(buf, pos + HEADER_SIZE + safe):
+ return safe
+ if _plausible_frame_start(buf, pos + HEADER_SIZE + plain):
+ return plain
+ return safe
+
+
+def _frame_payload(data, fmt, major, unsync_all):
+ """The frame's content after its format flags are dealt with, or None if
+ it can't be read (encrypted, broken compression)."""
+ if major == 3:
+ compressed, encrypted = fmt & 0x80, fmt & 0x40
+ # Added after the frame header, in flag order: decompressed size (4),
+ # encryption method (1), group id (1).
+ skip = (4 if compressed else 0) + (1 if encrypted else 0) + (1 if fmt & 0x20 else 0)
+ unsync = False # v2.3 unsynchronises the whole tag, undone already
+ else:
+ compressed, encrypted = fmt & 0x08, fmt & 0x04
+ # Group id (1), encryption method (1), data length indicator (4).
+ skip = (1 if fmt & 0x40 else 0) + (1 if encrypted else 0) + (4 if fmt & 0x01 else 0)
+ unsync = unsync_all or fmt & 0x02
+ if encrypted:
+ return None
+ data = data[skip:]
+ if unsync:
+ data = _deunsync(data)
+ if compressed:
+ try:
+ data = zlib.decompressobj().decompress(data, MAX_FRAME_BYTES)
+ except zlib.error:
+ return None
+ return data
+
+
+def _frames(buf, pos, major, unsync_all=False):
+ """(frame id, content) for each complete frame in buf[pos:]."""
+ while pos + HEADER_SIZE <= len(buf):
+ frame_id = buf[pos:pos + 4]
+ if not _FRAME_ID.match(frame_id):
+ return # padding, or something we can't make sense of
+ raw_size = buf[pos + 4:pos + 8]
+ size = _v24_frame_size(buf, pos, raw_size) if major >= 4 else int.from_bytes(raw_size, 'big')
+ fmt = buf[pos + 9]
+ start = pos + HEADER_SIZE
+ if start + size > len(buf):
+ return # cut off (the caller read only part of a large tag)
+ payload = _frame_payload(buf[start:start + size], fmt, major, unsync_all)
+ if payload is not None:
+ yield frame_id.decode('ascii'), payload
+ pos = start + size
+
+
+# ---------------------------------------------------------------------------
+# Frame contents
+# ---------------------------------------------------------------------------
+
+_CODECS = {0: 'latin-1', 1: 'utf-16', 2: 'utf-16-be', 3: 'utf-8'}
+
+
+def _decode(raw, encoding):
+ codec = _CODECS.get(encoding)
+ if codec is None:
+ return ''
+ if encoding in (1, 2) and len(raw) % 2:
+ raw = raw[:-1]
+ return raw.decode(codec, errors='replace')
+
+
+def _text_frame(data):
+ """The (first) string of a T*** frame."""
+ if not data:
+ return ''
+ text = _decode(data[1:], data[0])
+ # v2.4 allows several strings, separated by NUL; the first is the title.
+ return text.split('\x00')[0].replace('', '').strip()
+
+
+def _wxxx_frame(data):
+ """The URL of a WXXX frame: encoding, description, NUL, URL (latin-1)."""
+ if not data:
+ return ''
+ encoding, rest = data[0], data[1:]
+ if encoding in (1, 2): # the description ends with a two-byte NUL
+ i = 0
+ while i + 1 < len(rest) and not (rest[i] == 0 and rest[i + 1] == 0):
+ i += 2
+ url = rest[i + 2:]
+ else:
+ i = rest.find(b'\x00')
+ url = rest[i + 1:] if i >= 0 else b''
+ return url.split(b'\x00')[0].decode('latin-1').strip()
+
+
+def _url_frame(data):
+ """The URL of any other W*** frame, which is nothing but the URL."""
+ return data.split(b'\x00')[0].decode('latin-1').strip()
+
+
+def _chap(data, major):
+ """(element id, start ms, end ms, title, url) of a CHAP frame, or None."""
+ nul = data.find(b'\x00')
+ if nul < 0 or len(data) < nul + 17:
+ return None
+ element_id = data[:nul]
+ start_ms, end_ms, _start_offset, _end_offset = struct.unpack('>IIII', data[nul + 1:nul + 17])
+ title = url = ''
+ # Embedded frames, in the same format as the tag's own. Unsynchronisation
+ # of the whole tag has already been undone on `data`.
+ for frame_id, payload in _frames(data, nul + 17, major):
+ if frame_id == 'TIT2' and not title:
+ title = _text_frame(payload)
+ elif frame_id == 'WXXX' and not url:
+ url = _wxxx_frame(payload)
+ elif frame_id.startswith('W') and not url:
+ url = _url_frame(payload)
+ return element_id, start_ms, end_ms, title, url
+
+
+def _ctoc(data):
+ """(element id, top level?, child element ids) of a CTOC frame, or None."""
+ nul = data.find(b'\x00')
+ if nul < 0 or len(data) < nul + 3:
+ return None
+ element_id, flags, count = data[:nul], data[nul + 1], data[nul + 2]
+ children, pos = [], nul + 3
+ for _ in range(count):
+ end = data.find(b'\x00', pos)
+ if end < 0:
+ break
+ children.append(data[pos:end])
+ pos = end + 1
+ return element_id, bool(flags & 0x02), children
+
+
+def _toc_order(chaps, tocs):
+ """Element ids of the chapters the table of contents lists, in its order
+ (top-level CTOCs first, nested ones walked depth first); [] if there's no
+ usable table of contents."""
+ if not tocs:
+ return []
+ nested = {child for _, _, children in tocs for child in children}
+ roots = [t for t in tocs if t[1]] or [t for t in tocs if t[0] not in nested]
+ by_id = {t[0]: t for t in tocs}
+ order, seen = [], set()
+
+ def walk(toc):
+ if toc[0] in seen:
+ return # a loop
+ seen.add(toc[0])
+ for child in toc[2]:
+ if child in chaps:
+ if child not in order:
+ order.append(child)
+ elif child in by_id:
+ walk(by_id[child])
+
+ for root in roots:
+ walk(root)
+ return order
+
+
+def chapters(data):
+ """[(start ms, end ms, title, url)] of the chapters in the ID3v2 tag at
+ the start of `data`, in table-of-contents order where there is one, else
+ in tag order.
+ [] if there is no tag or it has no chapters; a tag cut short yields the
+ chapters in the part that's there."""
+ size = tag_size(data)
+ if size is None:
+ return []
+ major, flags = data[3], data[5]
+ if major not in (3, 4):
+ return [] # v2.2 has no chapter frames
+ body = data[HEADER_SIZE:min(len(data), size)]
+ unsync = bool(flags & _UNSYNC)
+ if major == 3 and unsync:
+ body = _deunsync(body)
+ pos = 0
+ if flags & _EXTENDED and len(body) >= 4:
+ if major == 3: # size of what follows the size field
+ pos = 4 + int.from_bytes(body[:4], 'big')
+ else: # synchsafe size of the whole extended header
+ pos = _synchsafe(body[:4])
+
+ chaps, order, tocs = {}, [], []
+ for frame_id, payload in _frames(body, pos, major, unsync_all=(major == 4 and unsync)):
+ if frame_id == 'CHAP':
+ chap = _chap(payload, major)
+ if chap and chap[0] not in chaps:
+ chaps[chap[0]] = chap
+ order.append(chap[0])
+ elif frame_id == 'CTOC':
+ toc = _ctoc(payload)
+ if toc:
+ tocs.append(toc)
+
+ # The table of contents' order first; chapters it doesn't list are kept
+ # (after it) rather than dropped, since writers don't always list all.
+ toc = _toc_order(chaps, tocs)
+ listed = set(toc)
+ order = toc + [e for e in order if e not in listed]
+ return [chaps[e][1:] for e in order]
diff --git a/podcasts/migrations/0009_episode_transcript_cache.py b/podcasts/migrations/0009_episode_transcript_cache.py
new file mode 100644
index 0000000..3032e1f
--- /dev/null
+++ b/podcasts/migrations/0009_episode_transcript_cache.py
@@ -0,0 +1,25 @@
+# Generated by Django 6.1 on 2026-10-02 07:44
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('podcasts', '0008_feed_http_cache'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='EpisodeTranscript',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sources', models.JSONField(default=list)),
+ ('data', models.JSONField(blank=True, null=True)),
+ ('error', models.CharField(blank=True, max_length=20)),
+ ('fetched_at', models.DateTimeField(auto_now=True)),
+ ('episode', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='transcript_cache', to='podcasts.podcastepisode')),
+ ],
+ ),
+ ]
diff --git a/podcasts/models.py b/podcasts/models.py
index 32382a4..90eb0c5 100644
--- a/podcasts/models.py
+++ b/podcasts/models.py
@@ -124,9 +124,11 @@ class PodcastEpisode(models.Model):
# Podcasting 2.0 : 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.
+ # the feed, or once fetched: the JSON at chapters_url, else (or if that
+ # has none) the CHAP frames of the audio file's ID3v2 tag.
chapters = models.JSONField(default=list, blank=True)
- # When chapters_url was fetched into `chapters`; null = not yet.
+ # When chapters were fetched into `chapters` (from chapters_url or the
+ # audio file); null = not yet.
chapters_fetched_at = models.DateTimeField(null=True, blank=True)
# [{url, type, language}] from .
transcripts = models.JSONField(default=list, blank=True)
@@ -139,6 +141,27 @@ class PodcastEpisode(models.Model):
return self.title
+class EpisodeTranscript(models.Model):
+ """An episode's transcript, fetched and parsed once (transcripts.py).
+
+ A table of its own rather than fields on PodcastEpisode: a parsed
+ transcript runs to hundreds of KB, and episode lists load whole rows.
+ """
+ episode = models.OneToOneField(PodcastEpisode, on_delete=models.CASCADE, related_name='transcript_cache')
+ # URLs of the transcripts it was made from, best first. When the feed
+ # offers different ones, the cache is stale.
+ sources = models.JSONField(default=list)
+ # {"segments": [...], "source": {...}}, or null with an error.
+ data = models.JSONField(null=True, blank=True)
+ # '' when data is there; else why there's none for good: 'unsupported'
+ # (no format with timing), 'invalid' (missing, broken, too large).
+ error = models.CharField(max_length=20, blank=True)
+ fetched_at = models.DateTimeField(auto_now=True)
+
+ def __str__(self):
+ return f'Transcript of {self.episode}'
+
+
# An episode counts as played this close to its end (AntennaPod's default),
# the same rule as the apps' "als gehört zählen".
PLAYED_BEFORE_END_SECONDS = 30
diff --git a/podcasts/tests.py b/podcasts/tests.py
index 3a12bb6..836e2e5 100644
--- a/podcasts/tests.py
+++ b/podcasts/tests.py
@@ -497,6 +497,7 @@ class EpisodeDetailTests(TestCase):
'rel="nofollow noopener noreferrer">x
')
self.assertEqual(data['timestamps'], [{'seconds': 62, 'label': 'Thema x'}])
self.assertEqual(data['transcripts'], self.ep.transcripts)
+ self.assertIs(data['has_transcript'], True)
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))
@@ -534,6 +535,7 @@ class EpisodeDetailTests(TestCase):
def test_private_chapters_url_is_refused_and_remembered(self):
self.ep.chapters_url = 'http://127.0.0.1/c.json'
+ self.ep.audio_url = 'http://127.0.0.1/a.mp3' # the ID3 fallback is refused too
self.ep.save()
with patch('podcasts.chapters.requests.get', self.fake_get):
r = self.client.get(f'/podcasts/episodes/{self.ep.id}/')
@@ -1586,3 +1588,473 @@ class FeedBackoffTests(TestCase):
def test_backoff_hours(self):
from .views import _backoff_hours
self.assertEqual([_backoff_hours(n) for n in range(0, 9)], [0, 0, 0, 2, 4, 8, 16, 24, 24])
+
+
+# ---------------------------------------------------------------------------
+# ID3v2 chapters (CHAP frames in the audio file)
+# ---------------------------------------------------------------------------
+
+def _ss(n):
+ """Synchsafe 4-byte integer."""
+ return bytes([(n >> 21) & 0x7F, (n >> 14) & 0x7F, (n >> 7) & 0x7F, n & 0x7F])
+
+
+def _frame(frame_id, data, v=3, fmt=0, plain_size=False):
+ size = _ss(len(data)) if v == 4 and not plain_size else len(data).to_bytes(4, 'big')
+ return frame_id.encode() + size + bytes([0, fmt]) + data
+
+
+def _text(s, enc=3):
+ codec = {0: 'latin-1', 1: 'utf-16', 3: 'utf-8'}[enc]
+ return bytes([enc]) + s.encode(codec) + (b'\0\0' if enc == 1 else b'\0')
+
+
+def _chap(element_id, start_ms, end_ms, subframes=b''):
+ import struct
+ return element_id + b'\0' + struct.pack('>IIII', start_ms, end_ms, 0xFFFFFFFF, 0xFFFFFFFF) + subframes
+
+
+def _ctoc(element_id, children, top=True):
+ return element_id + b'\0' + bytes([0x03 if top else 0x01, len(children)]) + b''.join(c + b'\0' for c in children)
+
+
+def _unsync(data):
+ """ID3 unsynchronisation: a $00 after every $FF followed by %111xxxxx or $00 (or at the end)."""
+ out = bytearray()
+ for i, b in enumerate(data):
+ out.append(b)
+ if b == 0xFF and (i + 1 == len(data) or data[i + 1] == 0 or data[i + 1] >= 0xE0):
+ out.append(0)
+ return bytes(out)
+
+
+def _tag(body, v=3, flags=0, padding=64):
+ body = body + b'\0' * padding
+ return b'ID3' + bytes([v, 0, flags]) + _ss(len(body)) + body
+
+
+_APIC = b'\0image/jpeg\0\x03\0' + b'\xff\xd8\xff\xe0' + b'p' * 300
+
+# A v2.3 tag as podcast tools write it: title, cover, table of contents, chapters.
+ID3V23 = _tag(
+ _frame('TIT2', _text('Folge 1'))
+ + _frame('APIC', _APIC * 10)
+ + _frame('CTOC', _ctoc(b'toc', [b'ch1', b'ch2', b'ch3']))
+ + _frame('CHAP', _chap(b'ch1', 0, 60000, _frame('TIT2', _text('Intro', 0))))
+ + _frame('CHAP', _chap(b'ch2', 60000, 125500,
+ _frame('TIT2', _text('Zweiter Teil – ü', 1))
+ + _frame('WXXX', b'\x00Link\x00https://x.org/2')
+ + _frame('APIC', _APIC)))
+ + _frame('CHAP', _chap(b'ch3', 125500, 200000))
+)
+ID3V23_CHAPTERS = [
+ {'start': 0.0, 'title': 'Intro'},
+ {'start': 60.0, 'title': 'Zweiter Teil – ü', 'url': 'https://x.org/2'},
+ {'start': 125.5, 'title': ''},
+]
+
+
+class ID3ParserTests(TestCase):
+ def chapters(self, data):
+ from .chapters import _chapter, _finish
+ from .id3 import chapters
+ return _finish(_chapter(s / 1000, t, u) for s, _e, t, u in chapters(data))
+
+ def test_v23(self):
+ self.assertEqual(self.chapters(ID3V23), ID3V23_CHAPTERS)
+
+ def test_v23_unsynchronised_with_extended_header(self):
+ ext = (6).to_bytes(4, 'big') + b'\0\0' + b'\0\0\0\0'
+ frames = (
+ _frame('CHAP', _chap(b'a', 0xFFE0, 70000, _frame('TIT2', _text('Ünsynchron', 1))))
+ + _frame('CHAP', _chap(b'b', 70000, 80000, _frame('TIT2', _text('Zwei')) + _frame('WOAF', b'https://x.org/w')))
+ )
+ body = _unsync(ext + frames + b'\0' * 16)
+ self.assertNotEqual(body, ext + frames + b'\0' * 16) # there really was something to unsynchronise
+ tag = b'ID3' + bytes([3, 0, 0xC0]) + _ss(len(body)) + body
+ self.assertEqual(self.chapters(tag), [
+ {'start': 65.504, 'title': 'Ünsynchron'},
+ {'start': 70.0, 'title': 'Zwei', 'url': 'https://x.org/w'},
+ ])
+
+ def test_v24_synchsafe_sizes_toc_and_frame_flags(self):
+ # The cover makes the CHAP frame longer than 127 bytes, where synchsafe
+ # and plain sizes differ.
+ long_chap = _chap(b'c2', 30000, 60000, _frame('APIC', _APIC, v=4) + _frame('TIT2', _text('Mit Bild'), v=4))
+ self.assertGreater(len(long_chap), 127)
+ # Unsynchronised frame with a data length indicator (flags %00000011).
+ raw = _chap(b'c3', 0x0001FFE0, 200000, _frame('TIT2', _text('ÿÿ Flags', 0), v=4))
+ flagged = b'CHAP' + _ss(4 + len(_unsync(raw))) + bytes([0, 0x03]) + _ss(len(raw)) + _unsync(raw)
+ tag = _tag(
+ _frame('CTOC', _ctoc(b'top', [b'sub', b'c3']), v=4)
+ + _frame('CTOC', _ctoc(b'sub', [b'c2', b'c1'], top=False), v=4)
+ + _frame('CHAP', _chap(b'c1', 0, 30000, _frame('TIT2', _text('Eins'), v=4)), v=4)
+ + _frame('CHAP', long_chap, v=4)
+ + flagged
+ + _frame('CHAP', _chap(b'extra', 0, 1000, _frame('TIT2', _text('Nicht im TOC'), v=4)), v=4),
+ v=4,
+ )
+ from .id3 import chapters
+ # Table-of-contents order (nested first), then what it doesn't list.
+ self.assertEqual([c[2] for c in chapters(tag)], ['Mit Bild', 'Eins', 'ÿÿ Flags', 'Nicht im TOC'])
+ self.assertEqual(self.chapters(tag), [
+ {'start': 0.0, 'title': 'Eins'},
+ {'start': 0.0, 'title': 'Nicht im TOC'},
+ {'start': 30.0, 'title': 'Mit Bild'},
+ {'start': 131.04, 'title': 'ÿÿ Flags'},
+ ])
+
+ def test_v24_with_plain_frame_sizes(self):
+ # Some writers put v2.3-style sizes into v2.4 tags.
+ long_chap = _chap(b'c1', 5000, 60000, _frame('APIC', _APIC, v=4, plain_size=True)
+ + _frame('TIT2', _text('Plain'), v=4, plain_size=True))
+ tag = _tag(_frame('CHAP', long_chap, v=4, plain_size=True)
+ + _frame('CHAP', _chap(b'c2', 60000, 70000, _frame('TIT2', _text('Danach'), v=4)), v=4), v=4)
+ self.assertEqual(self.chapters(tag), [{'start': 5.0, 'title': 'Plain'}, {'start': 60.0, 'title': 'Danach'}])
+
+ def test_no_tag_or_no_chapters(self):
+ from .id3 import chapters, tag_size
+ self.assertEqual(chapters(b'\xff\xfb\x90\x00' * 100), [])
+ self.assertEqual(chapters(_tag(_frame('TIT2', _text('Nur Titel')))), [])
+ self.assertIsNone(tag_size(b'ID3\x03\x00\x00\x80\x00\x00\x00')) # not synchsafe
+ self.assertEqual(tag_size(_tag(b'', padding=300)), 310)
+
+
+class _StreamResponse(_FakeResponse):
+ """Hands out the body in the chunks asked for, counting what was taken."""
+
+ def __init__(self, body=b'', status=200, headers=None):
+ super().__init__(body, status, headers)
+ self.sent = 0
+
+ def iter_content(self, chunk_size=1):
+ for i in range(0, len(self.body), chunk_size):
+ chunk = self.body[i:i + chunk_size]
+ self.sent += len(chunk)
+ yield chunk
+
+
+class ID3ChapterFetchTests(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')
+ self.ep = PodcastEpisode.objects.create(feed=self.feed, guid='a', title='A',
+ audio_url='https://example.org/a.mp3')
+ self.calls = []
+ self.responses = {}
+
+ def fake_get(self, url, **kwargs):
+ self.calls.append((url, kwargs.get('headers') or {}))
+ r = self.responses[url]
+ return r() if callable(r) else r
+
+ def get(self):
+ with patch('podcasts.chapters.assert_safe_url'), patch('podcasts.chapters.requests.get', self.fake_get):
+ return self.client.get(f'/podcasts/episodes/{self.ep.id}/')
+
+ def test_range_request_answered_with_206(self):
+ from .chapters import ID3_MAX_BYTES
+ self.responses['https://example.org/a.mp3'] = lambda: _StreamResponse(ID3V23 + b'\xff\xfb' * 100, status=206)
+ self.assertEqual(self.get().json()['chapters'], ID3V23_CHAPTERS)
+ self.assertEqual(self.calls[0][1]['Range'], f'bytes=0-{ID3_MAX_BYTES - 1}')
+ # Cached: the second detail doesn't touch the file again.
+ self.assertEqual(self.get().json()['chapters'], ID3V23_CHAPTERS)
+ self.assertEqual(len(self.calls), 1)
+ self.ep.refresh_from_db()
+ self.assertEqual(self.ep.chapters, ID3V23_CHAPTERS)
+ self.assertIsNotNone(self.ep.chapters_fetched_at)
+
+ def test_server_ignoring_range_is_read_only_as_far_as_the_tag(self):
+ from .chapters import ID3_CHUNK
+ whole_file = _StreamResponse(ID3V23 + b'\xff\xfb' * (3 * 1024 * 1024), status=200)
+ self.responses['https://example.org/a.mp3'] = whole_file
+ self.assertEqual(self.get().json()['chapters'], ID3V23_CHAPTERS)
+ self.assertLessEqual(whole_file.sent, ID3_CHUNK)
+
+ def test_tag_larger_than_the_cap(self):
+ from .chapters import ID3_CHUNK, ID3_MAX_BYTES
+ tag = _tag(
+ _frame('CHAP', _chap(b'ch1', 0, 1000, _frame('TIT2', _text('Vorne'))))
+ + _frame('APIC', b'\0image/png\0\x03\0' + b'p' * (ID3_MAX_BYTES + 1024))
+ + _frame('CHAP', _chap(b'ch2', 1000, 2000, _frame('TIT2', _text('Hinter der Grenze')))),
+ )
+ response = _StreamResponse(tag + b'\xff\xfb' * 1024, status=206)
+ self.responses['https://example.org/a.mp3'] = response
+ self.assertEqual(self.get().json()['chapters'], [{'start': 0.0, 'title': 'Vorne'}])
+ self.assertLessEqual(response.sent, ID3_MAX_BYTES + ID3_CHUNK)
+
+ def test_file_without_tag_is_remembered(self):
+ self.responses['https://example.org/a.mp3'] = lambda: _StreamResponse(b'\0\0\0\x20ftypM4A ' + b'x' * 5000)
+ self.assertEqual(self.get().json()['chapters'], [])
+ self.assertEqual(self.get().json()['chapters'], [])
+ self.assertEqual(len(self.calls), 1)
+ self.ep.refresh_from_db()
+ self.assertIsNotNone(self.ep.chapters_fetched_at)
+
+ def test_empty_json_chapters_fall_back_to_the_file(self):
+ self.ep.chapters_url = 'https://x.org/c.json'
+ self.ep.save()
+ self.responses['https://x.org/c.json'] = _FakeResponse(b'{"chapters": []}')
+ self.responses['https://example.org/a.mp3'] = _StreamResponse(ID3V23, status=206)
+ self.assertEqual(self.get().json()['chapters'], ID3V23_CHAPTERS)
+ self.assertEqual([c[0] for c in self.calls], ['https://x.org/c.json', 'https://example.org/a.mp3'])
+
+ def test_json_chapters_win(self):
+ self.ep.chapters_url = 'https://x.org/c.json'
+ self.ep.save()
+ self.responses['https://x.org/c.json'] = _FakeResponse(json.dumps(CHAPTERS_JSON).encode())
+ self.assertEqual(len(self.get().json()['chapters']), 3)
+ self.assertEqual([c[0] for c in self.calls], ['https://x.org/c.json'])
+
+ def test_redirect_is_followed_and_rechecked(self):
+ self.responses['https://example.org/a.mp3'] = _FakeResponse(
+ status=302, headers={'Location': 'https://cdn.example.org/a.mp3'})
+ self.responses['https://cdn.example.org/a.mp3'] = _StreamResponse(ID3V23, status=206)
+ with patch('podcasts.chapters.assert_safe_url') as check, patch('podcasts.chapters.requests.get', self.fake_get):
+ r = self.client.get(f'/podcasts/episodes/{self.ep.id}/')
+ self.assertEqual(r.json()['chapters'], ID3V23_CHAPTERS)
+ self.assertEqual([c.args[0] for c in check.call_args_list],
+ ['https://example.org/a.mp3', 'https://cdn.example.org/a.mp3'])
+ self.assertEqual(self.calls[1][1]['Range'], self.calls[0][1]['Range'])
+
+ def test_temporary_failure_is_not_cached(self):
+ self.responses['https://example.org/a.mp3'] = _FakeResponse(status=503)
+ self.assertEqual(self.get().json()['chapters'], [])
+ self.ep.refresh_from_db()
+ self.assertIsNone(self.ep.chapters_fetched_at)
+ self.responses['https://example.org/a.mp3'] = _StreamResponse(ID3V23, status=206)
+ self.assertEqual(self.get().json()['chapters'], ID3V23_CHAPTERS)
+
+ 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.calls, [])
+
+
+class ChapterCacheRefreshTests(TestCase):
+ 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, **kw: _ok(rss)):
+ _refresh_feed(self.feed)
+
+ def test_chapters_from_the_file_survive_a_refresh(self):
+ self.refresh(_rich_rss([('a', '')]))
+ fetched = datetime(2026, 1, 2, tzinfo=timezone.utc)
+ PodcastEpisode.objects.update(chapters=[{'start': 0.0, 'title': 'ID3'}], chapters_fetched_at=fetched)
+ self.refresh(_rich_rss([('a', '')]))
+ ep = PodcastEpisode.objects.get()
+ self.assertEqual((ep.chapters, ep.chapters_fetched_at), ([{'start': 0.0, 'title': 'ID3'}], fetched))
+ # A chapters URL turning up means fetching that.
+ self.refresh(_rich_rss([('a', '')]))
+ ep.refresh_from_db()
+ self.assertEqual((ep.chapters, ep.chapters_fetched_at), ([], None))
+
+ def test_inline_chapters_going_away_means_a_fetch(self):
+ self.refresh(_rich_rss([('a', PSC)]))
+ self.assertEqual(len(PodcastEpisode.objects.get().chapters), 4)
+ self.refresh(_rich_rss([('a', '')]))
+ ep = PodcastEpisode.objects.get()
+ self.assertEqual((ep.chapters, ep.chapters_fetched_at), ([], None))
+
+
+# ---------------------------------------------------------------------------
+# Transcripts
+# ---------------------------------------------------------------------------
+
+VTT = (
+ 'WEBVTT\r\nKind: captions\r\n\r\n'
+ 'NOTE Ein Kommentar\r\nüber zwei Zeilen\r\n\r\n'
+ 'STYLE\r\n::cue { color: red }\r\n\r\n'
+ '1\r\n00:00.000 --> 00:02.000\r\nHallo und\r\n\r\n'
+ 'intro-2\r\n00:02.000 --> 00:04.500 align:start position:10%\r\nwillkommen & so.\r\n\r\n'
+ '00:00:04.500 --> 00:00:06.000\r\nDanke!\r\nGern.\r\n\r\n'
+ '01:00:00.000 --> 01:00:02.000\r\nEnde <00:00:01.000>gut\r\n'
+)
+
+SRT = (
+ '1\n00:00:01,000 --> 00:00:03,500\nErste Zeile\nzweite Zeile\n\n'
+ '2\n00:00:03,500 --> 00:00:05,000\nweiter.\n\n'
+ '3\n00:00:40,000 --> 00:00:42,000\nNach der Pause\n'
+ '4\n00:00:42,000 --> 00:00:43,000\nohne Leerzeile davor\n'
+)
+
+TRANSCRIPT_JSON = {
+ 'version': '1.0.0',
+ 'segments': [
+ {'speaker': 'Anna', 'startTime': 0, 'endTime': 0.5, 'body': 'Hallo'},
+ {'speaker': 'Anna', 'startTime': 0.5, 'endTime': 1.2, 'body': 'zusammen.'},
+ {'speaker': 'Ben', 'startTime': 1.3, 'endTime': 2, 'body': 'Hi'},
+ {'speaker': '', 'startTime': 2.5, 'body': 'ohne Ende'},
+ {'startTime': 3, 'endTime': 4, 'body': 'und & weiter'},
+ {'startTime': 'kaputt', 'body': 'weg'},
+ 'kein Objekt',
+ ],
+}
+
+
+class TranscriptParsingTests(TestCase):
+ def test_vtt(self):
+ from .transcripts import merge, parse
+ self.assertEqual(merge(parse(VTT.encode(), 'vtt')), [
+ {'start': 0.0, 'end': 4.5, 'speaker': 'Anna', 'text': 'Hallo und willkommen & so.'},
+ {'start': 4.5, 'end': 6.0, 'speaker': 'Ben', 'text': 'Danke!'},
+ {'start': 4.5, 'end': 6.0, 'speaker': 'Anna', 'text': 'Gern.'},
+ {'start': 3600.0, 'end': 3602.0, 'speaker': None, 'text': 'Ende gut'},
+ ])
+
+ def test_srt(self):
+ from .transcripts import merge, parse
+ self.assertEqual(merge(parse(SRT.encode('cp1252'), 'srt')), [
+ {'start': 1.0, 'end': 5.0, 'speaker': None, 'text': 'Erste Zeile zweite Zeile weiter.'},
+ {'start': 40.0, 'end': 43.0, 'speaker': None, 'text': 'Nach der Pause ohne Leerzeile davor'},
+ ])
+
+ def test_json(self):
+ from .transcripts import merge, parse
+ self.assertEqual(merge(parse(json.dumps(TRANSCRIPT_JSON).encode(), 'json')), [
+ {'start': 0.0, 'end': 1.2, 'speaker': 'Anna', 'text': 'Hallo zusammen.'},
+ {'start': 1.3, 'end': 2.0, 'speaker': 'Ben', 'text': 'Hi'},
+ {'start': 2.5, 'end': 4.0, 'speaker': None, 'text': 'ohne Ende und & weiter'},
+ ])
+
+ def test_merging(self):
+ from .transcripts import merge
+ # One-second fragments without a sentence end: cut at 30 s.
+ words = [(float(i), i + 1.0, 'A', f'w{i}') for i in range(40)]
+ self.assertEqual([(s['start'], s['end']) for s in merge(words)], [(0.0, 30.0), (30.0, 40.0)])
+ # A sentence end closes a segment once it's 5 s long, not before.
+ frags = [(0.0, 1.0, None, 'Ja.'), (1.0, 6.0, None, 'Genau, so ist es.'), (6.0, 7.0, None, 'Weiter')]
+ self.assertEqual([s['text'] for s in merge(frags)], ['Ja. Genau, so ist es.', 'Weiter'])
+
+ def test_html_is_unsupported(self):
+ from .transcripts import Unsupported, parse
+ with self.assertRaises(Unsupported):
+ parse(b'Hallo
', 'vtt')
+
+ def test_choice(self):
+ from .transcripts import candidates
+ ts = [
+ {'url': 'https://x.org/a.srt', 'type': 'application/x-subrip', 'language': 'de'},
+ {'url': 'https://x.org/a.html', 'type': 'text/html', 'language': 'de'},
+ {'url': 'https://x.org/en.json', 'type': 'application/json', 'language': 'en'},
+ {'url': 'https://x.org/a.vtt', 'type': 'text/vtt', 'language': 'de-DE'},
+ {'url': 'https://x.org/fr.json', 'type': 'application/json', 'language': 'fr'},
+ {'url': 'https://x.org/b.vtt', 'type': '', 'language': ''},
+ ]
+ self.assertEqual([t['url'] for t in candidates(ts)], [
+ 'https://x.org/a.vtt', 'https://x.org/b.vtt', 'https://x.org/a.srt',
+ 'https://x.org/en.json', 'https://x.org/fr.json',
+ ])
+
+
+class TranscriptEndpointTests(TestCase):
+ VTT_T = {'url': 'https://example.org/a.vtt', 'type': 'text/vtt', 'language': 'de'}
+ JSON_T = {'url': 'https://example.org/a.json', 'type': 'application/json', 'language': 'de'}
+ HTML_T = {'url': 'https://example.org/a.html', 'type': 'text/html', 'language': 'de'}
+
+ 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')
+ self.ep = PodcastEpisode.objects.create(
+ feed=self.feed, guid='a', title='A', audio_url='https://example.org/a.mp3',
+ chapters=[{'start': 0.0, 'title': 'Inline'}], # no chapter fetch in these tests
+ transcripts=[self.VTT_T, self.JSON_T, self.HTML_T],
+ )
+ self.requests = []
+ self.responses = {
+ self.VTT_T['url']: lambda: _FakeResponse(VTT.encode()),
+ self.JSON_T['url']: lambda: _FakeResponse(json.dumps(TRANSCRIPT_JSON).encode()),
+ }
+
+ def fake_get(self, url, **kwargs):
+ self.requests.append(url)
+ return self.responses[url]()
+
+ def get(self, ep=None, what='transcript/'):
+ 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}/{what}')
+
+ def test_best_transcript_is_fetched_once(self):
+ r = self.get()
+ self.assertEqual(r.status_code, 200)
+ data = r.json()
+ self.assertEqual(data['source'], self.JSON_T)
+ self.assertEqual(data['segments'][0], {'start': 0.0, 'end': 1.2, 'speaker': 'Anna', 'text': 'Hallo zusammen.'})
+ self.assertEqual(self.get().json(), data)
+ self.assertEqual(self.requests, [self.JSON_T['url']])
+ self.assertTrue(self.get(what='').json()['has_transcript'])
+
+ def test_falls_back_to_the_next_one(self):
+ self.responses[self.JSON_T['url']] = lambda: _FakeResponse(status=404)
+ data = self.get().json()
+ self.assertEqual(data['source'], self.VTT_T)
+ self.assertEqual(data['segments'][1]['speaker'], 'Ben')
+ self.get()
+ self.assertEqual(self.requests, [self.JSON_T['url'], self.VTT_T['url']])
+
+ def test_temporary_failure_is_not_cached(self):
+ self.responses[self.JSON_T['url']] = lambda: _FakeResponse(status=503)
+ self.responses[self.VTT_T['url']] = lambda: _FakeResponse(status=503)
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (502, {'error': 'unavailable'}))
+ # The better one failing for now: the other one is shown, but not kept.
+ self.responses[self.VTT_T['url']] = lambda: _FakeResponse(VTT.encode())
+ self.assertEqual(self.get().json()['source'], self.VTT_T)
+ self.responses[self.JSON_T['url']] = lambda: _FakeResponse(json.dumps(TRANSCRIPT_JSON).encode())
+ self.assertEqual(self.get().json()['source'], self.JSON_T)
+ self.get()
+ self.assertEqual(self.requests.count(self.JSON_T['url']), 3)
+
+ def test_html_only_is_unsupported(self):
+ self.ep.transcripts = [self.HTML_T]
+ self.ep.save()
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'unsupported'}))
+ self.assertEqual(self.requests, [])
+ self.assertFalse(self.get(what='').json()['has_transcript'])
+
+ def test_html_served_under_another_type(self):
+ self.ep.transcripts = [self.VTT_T]
+ self.ep.save()
+ self.responses[self.VTT_T['url']] = lambda: _FakeResponse(b'Hallo')
+ self.assertTrue(self.get(what='').json()['has_transcript'])
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'unsupported'}))
+ self.assertEqual(self.get().status_code, 404)
+ self.assertEqual(self.requests, [self.VTT_T['url']])
+ self.assertFalse(self.get(what='').json()['has_transcript'])
+
+ def test_no_transcript(self):
+ self.ep.transcripts = []
+ self.ep.save()
+ r = self.get()
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'no transcript'}))
+ self.assertFalse(self.get(what='').json()['has_transcript'])
+
+ def test_new_transcripts_in_the_feed_replace_the_cache(self):
+ self.ep.transcripts = [self.VTT_T]
+ self.ep.save()
+ self.assertEqual(self.get().json()['source'], self.VTT_T)
+ self.ep.transcripts = [self.VTT_T, self.JSON_T]
+ self.ep.save()
+ self.assertEqual(self.get().json()['source'], self.JSON_T)
+ self.assertEqual(self.requests, [self.VTT_T['url'], self.JSON_T['url']])
+
+ 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',
+ transcripts=[self.VTT_T])
+ r = self.get(ep)
+ self.assertEqual((r.status_code, r.json()), (404, {'error': 'not found'}))
+ self.assertEqual(self.requests, [])
+
+ def test_login_required(self):
+ self.client.logout()
+ self.assertEqual(self.get().status_code, 401)
diff --git a/podcasts/transcripts.py b/podcasts/transcripts.py
new file mode 100644
index 0000000..d7dc368
--- /dev/null
+++ b/podcasts/transcripts.py
@@ -0,0 +1,282 @@
+"""Podcast transcripts (): pick the best one an episode
+offers, fetch it with the same guards as the chapters, and turn it into
+readable segments:
+
+ {"segments": [{"start": 1.5, "end": 9.25, "speaker": "Anna" | None, "text": "…"}],
+ "source": {"url": "…", "type": "text/vtt", "language": "de"}}
+
+Formats: Podcasting 2.0 JSON, WebVTT and SubRip (SRT). HTML transcripts have
+no timing and aren't supported. Cues are often a few words each; consecutive
+ones of the same speaker are merged up to a sentence end or about 30 s.
+"""
+
+import html
+import json
+import re
+
+from . import chapters as fetching
+
+MAX_BYTES = 2 * 1024 * 1024
+MAX_SEGMENTS = 20_000
+MAX_TEXT = 5_000 # characters of one segment
+MAX_SPEAKER = 200
+
+# Merging
+MERGE_MAX_SECONDS = 30 # a segment never spans more than this
+MERGE_MIN_SECONDS = 5 # ... and ends at a sentence end once it's this long
+MERGE_MAX_GAP = 5 # silence that always starts a new segment
+
+JSON, VTT, SRT, HTML = 'json', 'vtt', 'srt', 'html'
+_KIND_RANK = {JSON: 0, VTT: 1, SRT: 2}
+_ACCEPT = {JSON: 'application/json', VTT: 'text/vtt', SRT: 'application/x-subrip'}
+
+
+class Unsupported(Exception):
+ """The transcript is in a format without timing (HTML) or an unknown one."""
+
+
+def kind(transcript):
+ """json, vtt, srt, html or '' (unknown) from the type, else the URL."""
+ t = (transcript.get('type') or '').lower().split(';')[0].strip()
+ if 'json' in t:
+ return JSON
+ if 'vtt' in t:
+ return VTT
+ if 'srt' in t or 'subrip' in t:
+ return SRT
+ if 'html' in t:
+ return HTML
+ path = (transcript.get('url') or '').lower().split('?')[0].split('#')[0]
+ for ext, k in (('.json', JSON), ('.vtt', VTT), ('.srt', SRT), ('.html', HTML), ('.htm', HTML)):
+ if path.endswith(ext):
+ return k
+ return ''
+
+
+def _language_rank(language):
+ lang = (language or '').lower().split('-')[0].split('_')[0]
+ if lang in ('de', ''): # no language given: most likely the episode's own
+ return 0
+ if lang == 'en':
+ return 1
+ return 2
+
+
+def candidates(transcripts):
+ """The episode's transcripts in a format we read, best first: German (or
+ no language given) before English before others, then JSON before VTT
+ before SRT."""
+ usable = [(i, t) for i, t in enumerate(transcripts or []) if kind(t) in _KIND_RANK]
+ usable.sort(key=lambda it: (_language_rank(it[1].get('language')), _KIND_RANK[kind(it[1])], it[0]))
+ return [t for _, t in usable]
+
+
+# ---------------------------------------------------------------------------
+# Parsers: each returns fragments [(start, end, speaker, text)]
+# ---------------------------------------------------------------------------
+
+def _decode(body):
+ if body.startswith((b'\xff\xfe', b'\xfe\xff')):
+ return body.decode('utf-16', errors='replace')
+ try:
+ return body.decode('utf-8-sig')
+ except UnicodeDecodeError:
+ return body.decode('cp1252', errors='replace')
+
+
+def _clean(text):
+ text = re.sub(r'<[^>]*>', ' ', text)
+ return ' '.join(html.unescape(text).split())
+
+
+def _seconds(value):
+ try:
+ value = float(value)
+ except (TypeError, ValueError):
+ return None
+ if value != value or value < 0 or value == float('inf'):
+ return None
+ return value
+
+
+def parse_json(text):
+ """Podcasting 2.0 JSON: {"segments": [{"startTime", "endTime", "speaker", "body"}]}."""
+ try:
+ data = json.loads(text)
+ except ValueError:
+ raise fetching.FetchInvalid('not JSON')
+ segments = data.get('segments') if isinstance(data, dict) else None
+ if not isinstance(segments, list):
+ raise fetching.FetchInvalid('no segments list')
+ out = []
+ for s in segments:
+ if not isinstance(s, dict):
+ continue
+ start = _seconds(s.get('startTime'))
+ if start is None:
+ continue
+ end = _seconds(s.get('endTime'))
+ speaker = s.get('speaker')
+ speaker = ' '.join(speaker.split()) if isinstance(speaker, str) else None
+ body = s.get('body')
+ out.append((start, end, speaker or None, _clean(body) if isinstance(body, str) else ''))
+ return out
+
+
+# 00:01:02.500 / 01:02.500 (VTT), 00:01:02,500 (SRT)
+_TIME = r'(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?'
+_TIMING = re.compile(r'^\s*' + _TIME + r'\s*-->\s*' + _TIME + r'(?:\s|$)')
+_VOICE = re.compile(r']*)?(?:\s+([^>]*))?>', re.IGNORECASE)
+
+
+def _time(hours, minutes, seconds, fraction):
+ return int(hours or 0) * 3600 + int(minutes) * 60 + int(seconds) + (int(fraction.ljust(3, '0')) / 1000 if fraction else 0)
+
+
+def _cue_fragments(start, end, lines):
+ """Split a cue's text at its voice tags (…) into fragments."""
+ text = '\n'.join(lines)
+ out = []
+ speaker, pos = None, 0
+ for m in _VOICE.finditer(text):
+ out.append((start, end, speaker, _clean(text[pos:m.start()])))
+ speaker = ' '.join((m.group(1) or '').split())[:MAX_SPEAKER] or None
+ pos = m.end()
+ out.append((start, end, speaker, _clean(text[pos:])))
+ return [f for f in out if f[3]]
+
+
+def parse_cues(text):
+ """WebVTT or SRT. Lines before a cue's timing line (VTT cue ids, SRT
+ numbers) are dropped, as is everything outside cues (the WEBVTT header,
+ NOTE, STYLE and REGION blocks)."""
+ cues, current = [], None
+ for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n'):
+ m = _TIMING.match(line)
+ if m:
+ if current is not None and current[2] and current[2][-1].strip().isdigit():
+ current[2].pop() # an SRT number with no blank line before it
+ g = m.groups()
+ current = (_time(*g[:4]), _time(*g[4:]), [])
+ cues.append(current)
+ elif not line.strip():
+ current = None
+ elif current is not None:
+ current[2].append(line)
+ out = []
+ for start, end, lines in cues:
+ out.extend(_cue_fragments(start, end, lines))
+ return out
+
+
+def parse(body, declared_kind):
+ """Fragments from a downloaded transcript; the content decides the format
+ when it's obvious, the declared type otherwise."""
+ text = _decode(body)
+ head = text.lstrip()[:200].lower()
+ if head.startswith(('= MERGE_MIN_SECONDS)
+ ):
+ last['text'] += ' ' + text
+ last['end'] = max(last['end'], end)
+ continue
+ if len(segments) >= MAX_SEGMENTS:
+ break
+ segments.append({'start': start, 'end': end, 'speaker': speaker, 'text': text[:MAX_TEXT]})
+ for s in segments:
+ s['start'], s['end'] = round(s['start'], 3), round(s['end'], 3)
+ return segments
+
+
+# ---------------------------------------------------------------------------
+# Fetching
+# ---------------------------------------------------------------------------
+
+def fetch(transcript):
+ """The parsed transcript {"segments", "source"} from one of the episode's
+ entries. Raises Unsupported, FetchInvalid or
+ FetchUnavailable."""
+ k = kind(transcript)
+ if k not in _KIND_RANK:
+ raise Unsupported(transcript.get('type') or 'unknown type')
+ body = fetching.fetch_bytes(
+ transcript['url'], MAX_BYTES, accept=f'{_ACCEPT[k]}, */*;q=0.1', agent='diora podcast transcripts',
+ )
+ segments = merge(parse(body, k))
+ if not segments:
+ raise fetching.FetchInvalid('no segments')
+ return {
+ 'segments': segments,
+ 'source': {
+ 'url': transcript['url'],
+ 'type': transcript.get('type') or '',
+ 'language': transcript.get('language') or '',
+ },
+ }
+
+
+def load(transcripts):
+ """(parsed transcript, final) from the best candidate that works.
+
+ final is False when a better candidate failed for now only, so the
+ result shouldn't be kept for good. Raises FetchUnavailable when nothing
+ worked and something may work later, Unsupported when no candidate is in
+ a usable format, FetchInvalid otherwise.
+ """
+ retry_later = False
+ unsupported_only = True
+ for t in candidates(transcripts):
+ try:
+ return fetch(t), not retry_later
+ except fetching.FetchUnavailable:
+ retry_later = True
+ unsupported_only = False
+ except Unsupported:
+ pass
+ except fetching.FetchInvalid:
+ unsupported_only = False
+ if retry_later:
+ raise fetching.FetchUnavailable('try again later')
+ if unsupported_only:
+ raise Unsupported('no usable transcript')
+ raise fetching.FetchInvalid('no usable transcript')
diff --git a/podcasts/urls.py b/podcasts/urls.py
index f0941de..ededb88 100644
--- a/podcasts/urls.py
+++ b/podcasts/urls.py
@@ -16,6 +16,7 @@ urlpatterns = [
path('feeds//episodes/', views.feed_episodes, name='podcast_feed_episodes'),
path('episodes/', views.episode_list, name='podcast_episode_list'),
path('episodes//', views.episode_detail, name='podcast_episode_detail'),
+ path('episodes//transcript/', views.episode_transcript, name='podcast_episode_transcript'),
path('episodes//favorite/', views.set_favorite, name='podcast_set_favorite'),
path('stats/', views.podcast_stats, name='podcast_stats'),
path('queue/', views.queue_get, name='podcast_queue_get'),
diff --git a/podcasts/views.py b/podcasts/views.py
index 7e0df46..39d890a 100644
--- a/podcasts/views.py
+++ b/podcasts/views.py
@@ -21,7 +21,10 @@ 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, counts_as_played
+from . import transcripts as transcripts_mod
+from .models import (
+ EpisodeProgress, EpisodeTranscript, PodcastEpisode, PodcastFeed, PodcastQueue, PodcastSettings, counts_as_played,
+)
from .shownotes import extract_timestamps, sanitize_html
feedext.install()
@@ -231,8 +234,10 @@ def _entry_transcripts(entry):
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.
+ Inline (Podlove) chapters win. Without them, fetched chapters (from
+ chapters_url, or from the audio file when there's no URL) stay cached for
+ as long as the URL stays the same; inline chapters going away means a
+ fresh fetch.
"""
changed = []
if episode.description != description:
@@ -242,8 +247,8 @@ def _apply_extras(episode, description, chapters_url, psc_chapters, 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 = psc_chapters, None
+ elif chapters_url == episode.chapters_url and episode.chapters_fetched_at:
chapters, fetched_at = episode.chapters, episode.chapters_fetched_at
else:
chapters, fetched_at = [], None
@@ -947,20 +952,29 @@ def feed_episodes(request, pk):
# ---------------------------------------------------------------------------
def _load_chapters(episode):
- """Fetch the JSON chapters of `episode` once and cache them on it.
+ """Fetch the chapters the feed doesn't carry inline, once, and cache them
+ on `episode`: the JSON at chapters_url, else (no URL, or nothing there)
+ the CHAP frames in the ID3v2 tag at the start of the audio file.
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
+ chapters = []
try:
- chapters = chapters_mod.fetch(url)
+ if url:
+ try:
+ chapters = chapters_mod.fetch(url)
+ except chapters_mod.ChaptersInvalid as e:
+ log.info('chapters of episode %s unusable: %s', episode.id, e)
+ if not chapters and episode.audio_url:
+ try:
+ chapters = chapters_mod.fetch_id3(episode.audio_url)
+ except chapters_mod.ChaptersInvalid as e:
+ log.info('ID3 chapters of episode %s unusable: %s', episode.id, e)
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(
@@ -981,7 +995,7 @@ def episode_detail(request, pk):
return JsonResponse({'error': 'not found'}, status=404)
chapters = ep.chapters
- if not chapters and ep.chapters_url and ep.chapters_fetched_at is None:
+ if not chapters and ep.chapters_fetched_at is None:
chapters = _load_chapters(ep)
progress = EpisodeProgress.objects.filter(user=request.user, episode=ep).first()
@@ -992,6 +1006,7 @@ def episode_detail(request, pk):
'timestamps': extract_timestamps(ep.description, ep.duration_seconds),
'chapters': chapters,
'transcripts': ep.transcripts,
+ 'has_transcript': _has_transcript(ep),
'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,
@@ -1007,6 +1022,65 @@ def episode_detail(request, pk):
})
+def _transcript_sources(ep):
+ return [t['url'] for t in transcripts_mod.candidates(ep.transcripts)]
+
+
+def _has_transcript(ep):
+ """True if the episode offers a transcript we can read, and it hasn't
+ turned out to be unusable."""
+ sources = _transcript_sources(ep)
+ if not sources:
+ return False
+ cached = EpisodeTranscript.objects.filter(episode=ep).values_list('sources', 'error').first()
+ return not (cached and cached[0] == sources and cached[1])
+
+
+@csrf_exempt
+@require_http_methods(['GET'])
+def episode_transcript(request, pk):
+ """The episode's transcript as {"segments": [{start, end, speaker, text}],
+ "source": {url, type, language}}, fetched and parsed on first use, then
+ kept. 404 {"error": "no transcript" | "unsupported" | "invalid"} when
+ there's none to show; 502 {"error": "unavailable"} when it couldn't be
+ loaded right now."""
+ if not request.user.is_authenticated:
+ return JsonResponse({'error': 'authentication required'}, status=401)
+ try:
+ ep = PodcastEpisode.objects.get(pk=pk, feed__user=request.user)
+ except PodcastEpisode.DoesNotExist:
+ return JsonResponse({'error': 'not found'}, status=404)
+ if not ep.transcripts:
+ return JsonResponse({'error': 'no transcript'}, status=404)
+ sources = _transcript_sources(ep)
+ if not sources:
+ return JsonResponse({'error': 'unsupported'}, status=404)
+
+ cached = EpisodeTranscript.objects.filter(episode=ep).first()
+ if cached is not None and cached.sources == sources:
+ data, error = cached.data, cached.error
+ else:
+ try:
+ data, final = transcripts_mod.load(ep.transcripts)
+ error = ''
+ except chapters_mod.FetchUnavailable as e:
+ log.info('transcript of episode %s not available: %s', ep.id, e)
+ return JsonResponse({'error': 'unavailable'}, status=502)
+ except transcripts_mod.Unsupported as e:
+ log.info('transcript of episode %s unsupported: %s', ep.id, e)
+ data, error, final = None, 'unsupported', True
+ except chapters_mod.FetchInvalid as e:
+ log.info('transcript of episode %s unusable: %s', ep.id, e)
+ data, error, final = None, 'invalid', True
+ if final:
+ EpisodeTranscript.objects.update_or_create(
+ episode=ep, defaults={'sources': sources, 'data': data, 'error': error},
+ )
+ if error:
+ return JsonResponse({'error': error}, status=404)
+ return JsonResponse(data)
+
+
# ---------------------------------------------------------------------------
# Favorites
# ---------------------------------------------------------------------------