"""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')