- Ohne Kapitel im Feed (kein PSC, keine chapters_url oder dort nichts)
liest die Folgen-Detailansicht einmalig den ID3v2-Tag am Anfang der
Audiodatei (Range-Anfrage, höchstens 4 MB, Weiterleitungen mit
SSRF-Prüfung) und übernimmt dessen CHAP-Frames (v2.3/v2.4,
Unsynchronisation, erweiterter Header, Synchsafe-Größen, CTOC-Reihenfolge).
Eigener Parser nach der ID3-Spezifikation; Ergebnis wird wie die
JSON-Kapitel zwischengespeichert, vorübergehende Fehler nicht.
- Neu: GET /podcasts/episodes/<id>/transcript/ holt das beste Transkript
(JSON vor VTT vor SRT, Deutsch bzw. ohne Sprachangabe vor Englisch),
zerlegt es in Abschnitte {start, end, speaker, text}, fasst kurze
Schnipsel desselben Sprechers bis zum Satzende bzw. ~30 s zusammen und
speichert das Ergebnis (eigene Tabelle EpisodeTranscript). HTML: 404
{"error": "unsupported"}.
- Folgen-Detail liefert zusätzlich has_transcript.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
9.8 KiB
Python
281 lines
9.8 KiB
Python
"""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]
|