diff --git a/README.md b/README.md index ea0efa8..08d8e94 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ feeds or audio files and it lists what repeats. See [`CLI.md`](CLI.md). podcasts. - **Captions.** Optional: run the [`whisper/`](whisper/) container beside it and each download gets captions in the private feed and under the - episode page's player, made on the CPU. + episode page's player, made on the CPU. A show can also list phrases + that end its intro or start its outro; everything past them is cut. - **Stays small.** Downloads are cleaned up a week after they are cut (set per podcast), unless you keep them. diff --git a/WEBAPP.md b/WEBAPP.md index b717772..9f8038f 100644 --- a/WEBAPP.md +++ b/WEBAPP.md @@ -241,7 +241,23 @@ on Kubernetes, with backups and upgrades, see `deployment/README.md`. meanwhile, and captions jobs take a worker thread of their own. When the service has speakers on, the turns it finds are saved beside the captions (`podblock/app/speakers.py`), for `podblock speakers` to set beside the - repeats; they don't go into the captions or the cuts yet. + repeats; they don't go into the captions or the cuts yet. Each word's + time is saved beside the captions too, for intro and outro phrases. +- Intro and outro phrases (`podblock/app/phrases.py`): a podcast can list + phrases that end its intro and phrases that begin its outro. An episode + with a timed transcript is searched for them each time it is cut, + ignoring case and punctuation. An intro phrase heard in the first third + cuts from the start through its first hearing; an outro phrase heard in + the last third cuts from its last hearing to the end. Hearings under 20 + seconds apart count as one, so a chant repeated is cut whole. A phrase + between slashes is a regular expression over the same lower-case words, + matching whole words and at most 30 at a time. Captions place the + cut between words; a feed's transcript, timed by cue, places it by where + the words fall in their cue. Captions arrive after the first cut, so + hearing a phrase in them queues the episode to be cut again, and saving + new phrases queues the ready episodes they cut differently. The timeline + shows these cuts as Intro (phrase) and Outro (phrase), changed only + through the phrases. - Each join gets a chapter saying what was cut and how much: "1:32 ad break cut", or, inside a chapter of the source's, "Interview (after 1:32 ad break cut)". An episode with no chapters of its own gets a "Start" @@ -357,6 +373,9 @@ on Kubernetes, with backups and upgrades, see `deployment/README.md`. episode becomes cleaned up, as retention does it, so it can be downloaded again and cut the same way. Episodes being processed are left alone. Reprocess in the selection bar downloads and cuts the selected again. + With a transcriber set, Transcribe queues captions for the selected even + if they have a transcript: the new captions, with speakers when the + service finds them, replace it, the feed's included, and its files go. ### Filters @@ -492,8 +511,8 @@ on Kubernetes, with backups and upgrades, see `deployment/README.md`. downloaded is left alone. - The Settings tab sets days before clean-up, how often to check the feed (15 minutes to a week), how close two repeats must be to be combined, - oldest first, and whether new episodes download on their own. Settings - are shared. + oldest first, and whether new episodes download on their own, and lists + the show's intro and outro phrases. Settings are shared. ### Look and feel @@ -543,7 +562,8 @@ podcasts + author, description, image_url, link, language, categories (JSON), e followed, refresh_minutes, feed_etag, feed_last_modified, last_refreshed_at, last_error, added_at, public_token, -- guards audio links - retention_days, auto_download, fill_gaps, sort_oldest + retention_days, auto_download, fill_gaps, sort_oldest, + intro_phrases, outro_phrases -- JSON lists (phrases.py) episodes + pub_ts, summary, image_url, link, audio_type, audio_length, feed_duration, season, episode_number, episode_type, explicit, item_xml, -- raw @@ -554,7 +574,8 @@ episodes + pub_ts, summary, image_url, link, audio_type, audio_length, feed_dur peaks_path, -- the waveform source_chapters, transcripts, -- the source's, as found when first cut cut_chapters, -- the chapters written into the cut file - held_by -- the running job working on it + held_by, -- the running job working on it + phrase_cuts, -- the intro and outro its phrases cut, as last cut ready_at, expired_at -- the retention clock jobs(id, kind, episode_id, state, step, progress, error, cancel_requested, attempts, @@ -594,6 +615,7 @@ only looks at fingerprinted ones, so they don't affect it. | `POST /podcasts/{id}/settings` | yes | clean-up days, refresh interval, gap, auto-download, `sort` (newest or oldest) | | `POST /podcasts/{id}/subscribe` | yes | `on` = true or false: put it on your home page or take it off | | `POST /podcasts/{id}/feed-token` | yes | new address for your own private feed | +| `POST /podcasts/{id}/phrases` | yes | the show's `intro` and `outro` phrases, one a line; queues the episodes they cut differently | | `POST /podcasts/{id}/filters` | yes | add a filter: `pattern`, `scope` (podcast or all), `processing` | | `POST /podcasts/{id}/remove` | admin | stop following | | `POST /filters/{id}/remove` | owner or admin | remove a filter | @@ -615,6 +637,7 @@ only looks at fingerprinted ones, so they don't affect it. | `POST /users/{id}/password`, `POST /users/{id}/remove` | admin | new password; remove | | `GET /downloads?sort=` | admin | every download on disk, with sizes | | `POST /downloads/delete` | admin | delete the files of `ids` | +| `POST /downloads/transcribe` | admin | transcribe `ids` again, replacing any transcript they have | ## Future features diff --git a/podblock/app/captions.py b/podblock/app/captions.py index b0c1523..6d80d99 100644 --- a/podblock/app/captions.py +++ b/podblock/app/captions.py @@ -14,6 +14,13 @@ edit can be saved meanwhile. Captions jobs run in a thread of their own (worker.py), so a long transcription doesn't hold up downloads. +Each word's time is saved beside the captions too, for the show's intro and +outro phrases (`phrases.py`); hearing one queues the episode to be cut again. + +An episode can also be transcribed again on purpose (`queue(force=True)`, the +downloads page's Transcribe again): the new captions then replace whatever it +had, its feed's transcripts included. + When the service has speakers turned on, it also says who speaks when; those turns are saved beside the captions (`speakers.py`) for `podblock speakers` to compare with the repeats found. They don't go into the captions yet. @@ -30,7 +37,7 @@ import requests -from . import chapters, speakers +from . import chapters, phrases, speakers from .config import Settings from .db import transaction from .jobs import Context, enqueue_task @@ -52,23 +59,26 @@ class CaptionError(Exception): pass -def wanted(ep: sqlite3.Row, settings: Settings) -> bool: - """Whether an episode should get captions: the service is set, it has a download, and no timed transcript.""" +def wanted(ep: sqlite3.Row, settings: Settings, force: bool = False) -> bool: + """Whether an episode should get captions: the service is set, it has a download, and no timed transcript + (or, with `force`, whatever it has).""" return bool(settings.whisper_url and ep["original_path"] and Path(ep["original_path"]).exists() - and not json.loads(ep["transcripts"] or "[]")) + and (force or not json.loads(ep["transcripts"] or "[]"))) -def queue(db: sqlite3.Connection, settings: Settings, episode_ids: list[int], user_id: int | None) -> list[int]: - """Queue captions for the episodes that want them and don't have a captions job already. Returns those.""" +def queue(db: sqlite3.Connection, settings: Settings, episode_ids: list[int], user_id: int | None, + force: bool = False) -> list[int]: + """Queue captions for the episodes that want them and don't have a captions job already. Returns those. + With `force`, the captions replace any transcript the episode has: its feed's, or earlier captions.""" queued = [] for ep_id in dict.fromkeys(episode_ids): ep = db.execute("SELECT * FROM episodes WHERE id = ?", (ep_id,)).fetchone() - if ep is None or not wanted(ep, settings): + if ep is None or not wanted(ep, settings, force): continue if db.execute(f"SELECT 1 FROM jobs WHERE kind = ? AND state IN ({sql(ACTIVE)}) " "AND json_extract(payload, '$.episode') = ?", (KIND, ep_id)).fetchone(): continue - enqueue_task(db, KIND, user_id, json.dumps({"episode": ep_id})) + enqueue_task(db, KIND, user_id, json.dumps({"episode": ep_id, **({"force": True} if force else {})})) queued.append(ep_id) return queued @@ -198,13 +208,14 @@ def ask(ctx, url: str, what: str, audio: Path, params: dict | None = None) -> di def run(ctx: Context, settings: Settings) -> None: - episode_id = json.loads(ctx.job["payload"] or "{}").get("episode") + payload = json.loads(ctx.job["payload"] or "{}") + episode_id, force = payload.get("episode"), bool(payload.get("force")) if not settings.whisper_url: raise CaptionError("no transcriber is set (PODBLOCK_WHISPER_URL)") ep = ctx.db.execute("SELECT * FROM episodes WHERE id = ?", (episode_id,)).fetchone() if ep is None: raise CaptionError("the episode is gone") - if json.loads(ep["transcripts"] or "[]"): + if json.loads(ep["transcripts"] or "[]") and not force: return # it has one already: from its feed, or an earlier captions job if not ep["original_path"] or not Path(ep["original_path"]).exists(): raise CaptionError("its download is gone; download it again for captions") @@ -215,19 +226,34 @@ def run(ctx: Context, settings: Settings) -> None: stem = settings.media.transcripts(ep) path = stem.with_name(stem.name + ".captions.vtt") _write(path, webvtt(cues(heard["segments"]))) - saved = {"format": "vtt", "path": str(path), "rel": "captions", "made": "whisper", + timed = stem.with_name(stem.name + ".words.json") # each word's time, for finding intro and outro phrases + _write(timed, json.dumps([w for seg in heard["segments"] for w in _words(seg)], separators=(",", ":"))) + saved = {"format": "vtt", "path": str(path), "rel": "captions", "made": "whisper", "words": str(timed), **({"language": heard["language"]} if heard["language"] else {})} if heard["speakers"] is not None: saved["speakers"] = str(speakers.save(stem, heard["speakers"], heard["options"])) with transaction(ctx.db): now = ctx.db.execute("SELECT original_path, transcripts FROM episodes WHERE id = ?", (episode_id,)).fetchone() - if now is None or now["original_path"] != ep["original_path"] or json.loads(now["transcripts"] or "[]"): - chapters.forget({"transcripts": json.dumps([saved])}) # cleaned up meanwhile, or the feed's were read + before = json.loads(ep["transcripts"] or "[]") + if (now is None or now["original_path"] != ep["original_path"] + or json.loads(now["transcripts"] or "[]") != before): # cleaned up meanwhile, or the feed's were read + _forget([saved], keep=json.loads(now["transcripts"] or "[]") if now else []) return ctx.db.execute("UPDATE episodes SET transcripts = ? WHERE id = ?", (json.dumps([saved]), episode_id)) + _forget(before, keep=[saved]) # what these replace log.info("captions for episode %d: %d segments, %s%s", episode_id, len(heard["segments"]), heard["language"] or "language unknown", f", {len({t[2] for t in heard['speakers']})} speakers" if heard["speakers"] else "") + phrases.requeue(ctx.db, ep["podcast_id"], ctx.job["requested_by"], [episode_id], + note="Waiting to be cut again: an intro or outro phrase was heard") + + +def _forget(transcripts: list[dict], keep: list[dict]) -> None: + """Delete these transcripts' files, but not the ones `keep` names: new captions are written over old ones.""" + kept = {t.get(k) for t in keep for k in ("path", "speakers", "words")} + gone = [{k: (t.get(k) if t.get(k) not in kept else None) for k in ("path", "speakers", "words")} + for t in transcripts] + chapters.forget({"transcripts": json.dumps(gone)}) def _write(path: Path, text: str) -> None: diff --git a/podblock/app/chapters.py b/podblock/app/chapters.py index 2eb439e..1f94a00 100644 --- a/podblock/app/chapters.py +++ b/podblock/app/chapters.py @@ -205,18 +205,21 @@ def gather(db: sqlite3.Connection, ep: sqlite3.Row, stem: Path) -> None: def forget(ep: sqlite3.Row | dict) -> None: - """Delete an episode's saved transcripts, and the speaker turns saved with captions, as its download goes.""" + """Delete an episode's saved transcripts, and the speaker turns and word times saved with captions, as its + download goes.""" for t in json.loads(ep["transcripts"] or "[]"): - for path in (t["path"], t.get("speakers")): + for path in (t["path"], t.get("speakers"), t.get("words")): if path: Path(path).unlink(missing_ok=True) # ---- chapters for the cut ------------------------------------------------------ -def cuts(report: dict | None, edits: list) -> list[tuple[float, float, set[str]]]: - """What was cut where: each section of the report with its types, and each stretch cut by hand.""" +def cuts(report: dict | None, edits: list, phrase_cuts: list[dict] = ()) -> list[tuple[float, float, set[str]]]: + """What was cut where: each section of the report with its types, each stretch cut by hand, and each intro + or outro its phrases cut.""" found = [(s["start"], s["end"], set(s["types"])) for s in (report or {}).get("sections", [])] + found += [(p["start"], p["end"], {p["type"]}) for p in phrase_cuts] return found + [(r["start"], r["end"], {"manual"}) for r in edits] @@ -272,7 +275,8 @@ def for_episode(db: sqlite3.Connection, ep: sqlite3.Row) -> list[dict]: return json.loads(ep["cut_chapters"]) saved = db.execute("SELECT report FROM reports WHERE episode_id = ?", (ep["id"],)).fetchone() edits = db.execute("SELECT start, end FROM edits WHERE episode_id = ?", (ep["id"],)).fetchall() - return build(keep_of(ep), cuts(json.loads(saved["report"]) if saved else None, edits), + return build(keep_of(ep), cuts(json.loads(saved["report"]) if saved else None, edits, + json.loads(ep["phrase_cuts"] or "[]")), json.loads(ep["source_chapters"] or "[]")) diff --git a/podblock/app/db.py b/podblock/app/db.py index a8b7a74..a772ec0 100644 --- a/podblock/app/db.py +++ b/podblock/app/db.py @@ -227,6 +227,12 @@ """ ALTER TABLE episodes ADD COLUMN held_by INTEGER; -- the running job working on it: its own, or taken along """, + # 16: phrases that end a show's intro or begin its outro, and the cuts they made in each episode (phrases.py) + """ + ALTER TABLE podcasts ADD COLUMN intro_phrases TEXT; -- JSON list + ALTER TABLE podcasts ADD COLUMN outro_phrases TEXT; -- JSON list + ALTER TABLE episodes ADD COLUMN phrase_cuts TEXT; -- JSON list of {type, start, end, phrase}, as last cut + """, ] diff --git a/podblock/app/phrases.py b/podblock/app/phrases.py new file mode 100644 index 0000000..0f04c00 --- /dev/null +++ b/podblock/app/phrases.py @@ -0,0 +1,263 @@ +"""Intro and outro phrases: words a show says at the edges of its talk, cut along with everything past them. + +Each podcast can list phrases that end its intro ("let's get into it") and +phrases that begin its outro ("that's all for this week"). Once an episode +has a timed transcript (its feed's, or captions from the transcription +service), it is searched for them: + +- an intro phrase heard in the first third cuts from the start to the + phrase's last word, at its first hearing; +- an outro phrase heard in the last third cuts from the phrase's first word + to the end, at its last hearing. + +Hearings less than CHAIN seconds apart count as one, so a chant sung over +and over is cut whole, and so is a host saying the line twice. A phrase +said again mid-episode cuts nothing. Matching ignores case and +punctuation ("Let's get into it!" is "lets get into it"), word for word. + +A phrase between slashes is a regular expression instead, matched against +the same words: lower case, without punctuation, one space apart. It +matches whole words only, and at most MAX_WORDS of them at once, so +`/youth of (the|a) nation/` matches "the youth of the nation" but not +"youth of the nations". + +Captions keep whisper's time for each word (`words.json` beside them), so +cuts land between words; a feed's transcript only times whole cues, so +there each word's time is guessed from where it falls in its cue. + +What is found is saved on the episode (`episodes.phrase_cuts`) each time it +is cut, as [{type: "intro" | "outro", start, end, phrase}], and cut with +the repeats. Captions come after the first cut, so finding a phrase in them +queues the episode to be cut again; so does changing the phrases. +""" + +from __future__ import annotations + +import bisect +import functools +import json +import re +import sqlite3 +from pathlib import Path + +from . import chapters +from .db import transaction +from .jobs import enqueue +from .states import READY + +MAX_PHRASES = 20 # of each kind, per podcast +MAX_CHARS = 100 # in a phrase +MAX_WORDS = 30 # a regular expression's match spans at most this many words +REACH = 1 / 3 # of the episode, from its edge, where a phrase counts +CHAIN = 20.0 # seconds between two hearings of a phrase that still make one run of it, as in a chant +PAD = 0.25 # seconds past a phrase's edge also cut, when the next word isn't nearer: whisper's times run tight +INTRO, OUTRO = "intro", "outro" + + +class PhraseError(ValueError): + """Phrases that can't be saved; the message says why, for the page.""" + + +def tokens(text: str) -> list[str]: + """Words to match: lower case, without punctuation, "let's" as "lets".""" + return re.findall(r"\w+", re.sub(r"['’ʼ]", "", text.casefold())) + + +@functools.lru_cache(maxsize=256) +def pattern(phrase: str) -> re.Pattern | None: + """A phrase between slashes as the regular expression it matches the words with; None for a plain phrase. + Raises re.error for one that isn't valid.""" + if len(phrase) < 3 or not (phrase.startswith("/") and phrase.endswith("/")): + return None + return re.compile(rf"(? list[str]: + """Phrases from a box of them, one a line. Blank lines and repeats go.""" + found: dict[tuple, str] = {} + for line in text.splitlines(): + line = " ".join(line.split()) + if not line: + continue + if len(line) > MAX_CHARS: + raise PhraseError(f"Phrases can be at most {MAX_CHARS} characters.") + try: + compiled = pattern(line) + except re.error as e: + raise PhraseError(f"“{line}” isn't a valid regular expression: {e}.") from None + if compiled is not None: + if compiled.search("") or compiled.search(" "): + raise PhraseError(f"“{line}” matches nothing at all; it needs a word.") + found.setdefault(("/", line), line) + continue + if not tokens(line): + raise PhraseError(f"“{line}” has no words to listen for.") + found.setdefault(tuple(tokens(line)), line) + if len(found) > MAX_PHRASES: + raise PhraseError(f"At most {MAX_PHRASES} phrases of each kind.") + return list(found.values()) + + +def of_podcast(db: sqlite3.Connection, podcast_id: int | None) -> tuple[list[str], list[str]]: + row = db.execute("SELECT intro_phrases, outro_phrases FROM podcasts WHERE id = ?", (podcast_id,)).fetchone() + if row is None: + return [], [] + return json.loads(row["intro_phrases"] or "[]"), json.loads(row["outro_phrases"] or "[]") + + +# ---- the words of a transcript, with their times ----------------------------------- + +def _from_cue(start: float, end: float, text: str) -> list[tuple[float, float, str]]: + """A cue's words, each given a share of its time by where it falls in the text.""" + text = chapters._unescape(re.sub(r"<[^>]*>", "", text)) + spans = [(m.start(), m.end(), w) for m in re.finditer(r"\S+", text) for w in tokens(m[0])] + if not spans or end <= start: + return [] + size = max(1, len(text.rstrip())) + return [(start + (end - start) * a / size, start + (end - start) * b / size, w) for a, b, w in spans] + + +def _cue_words(text: str) -> list[tuple[float, float, str]]: + out = [] + for block in re.split(r"\n[ \t]*\n", text.replace("\r\n", "\n").replace("\r", "\n").strip()): + lines = block.split("\n") + at = next((i for i, line in enumerate(lines) if "-->" in line), None) + m = chapters.CUE.match(lines[at]) if at is not None else None + start, end = (chapters._clock(m[1]), chapters._clock(m[2])) if m else (None, None) + if start is not None and end is not None: + out += _from_cue(start, end, " ".join(lines[at + 1:])) + return out + + +def _json_words(text: str) -> list[tuple[float, float, str]]: + segments = [s for s in json.loads(text).get("segments", []) + if isinstance(s, dict) and isinstance(s.get("startTime"), (int, float))] + out = [] + for i, seg in enumerate(segments): + nxt = segments[i + 1]["startTime"] if i + 1 < len(segments) else seg["startTime"] + 5 + end = seg["endTime"] if isinstance(seg.get("endTime"), (int, float)) else nxt + out += _from_cue(seg["startTime"], end, str(seg.get("body") or "")) + return out + + +def words(ep: sqlite3.Row) -> list[tuple[float, float, str]]: + """The episode's words in the uncut audio, in order, as (start, end, word) with `word` as `tokens` gives it: + whisper's own times when its captions kept them, else guessed from a transcript's cues. Empty without one.""" + saved = chapters.transcripts(ep) + for t in saved: + if t.get("words") and Path(t["words"]).exists(): + return [(a, b, w) for a, b, word in json.loads(Path(t["words"]).read_text()) for w in tokens(word)] + for fmt in ("vtt", "srt", "json"): + if any(t["format"] == fmt for t in saved): + text = chapters._read(ep, fmt) + try: + return _json_words(text) if fmt == "json" else _cue_words(text) + except (ValueError, AttributeError): + return [] + return [] + + +# ---- finding them ---------------------------------------------------------------- + +def _hits(said: list[str], phrase: list[str]) -> list[int]: + n = len(phrase) + return [i for i in range(len(said) - n + 1) if said[i:i + n] == phrase] + + +def _regex_hits(said: list[str], compiled: re.Pattern) -> list[tuple[int, int]]: + """Where a regular expression matches the words, as (index of the first word, of the last).""" + text = " ".join(said) + starts = [0] + for w in said[:-1]: + starts.append(starts[-1] + len(w) + 1) + found = [] + for m in compiled.finditer(text): + if m.end() <= m.start(): + continue + first = bisect.bisect_right(starts, m.start()) - 1 + last = bisect.bisect_right(starts, m.end() - 1) - 1 + if last - first < MAX_WORDS: + found.append((first, last)) + return found + + +def _all_hits(said: list[str], phrases: list[str]) -> list[tuple[int, int, str]]: + """Every hearing of any of the phrases, in order, as (index of its first word, of its last, phrase).""" + found = [] + for p in phrases: + try: + compiled = pattern(p) + except re.error: + continue + if compiled is not None: + found += [(a, b, p) for a, b in _regex_hits(said, compiled)] + else: + found += [(i, i + len(tokens(p)) - 1, p) for i in _hits(said, tokens(p))] + return sorted(found) + + +def find(heard: list[tuple[float, float, str]], duration: float, intro: list[str], + outro: list[str]) -> list[dict]: + """The cuts the phrases make: at most one intro and one outro, as {type, start, end, phrase}.""" + if not heard or not duration: + return [] + said = [w for _, _, w in heard] + found = [] + hits = [h for h in _all_hits(said, intro) if heard[h[1]][1] <= duration * REACH] + if hits: + at, phrase = hits[0][1], hits[0][2] + for first, last, _ in hits[1:]: # said again straight after: a chant, or a host repeating it + if heard[first][0] - heard[at][1] > CHAIN: + break + at = max(at, last) + nxt = heard[at + 1][0] if at + 1 < len(heard) else duration + end = min(max(heard[at][1], min(heard[at][1] + PAD, nxt)), duration) + found.append({"type": INTRO, "start": 0.0, "end": round(end, 2), "phrase": phrase}) + hits = [h for h in _all_hits(said, outro) if heard[h[0]][0] >= duration * (1 - REACH)] + if hits: + at, phrase = hits[-1][0], hits[-1][2] + for first, last, said_as in reversed(hits[:-1]): + if heard[at][0] - heard[last][1] > CHAIN: + break + at, phrase = min(at, first), said_as + prev = heard[at - 1][1] if at > 0 else 0.0 + start = max(min(heard[at][0], max(heard[at][0] - PAD, prev)), 0.0) + if not found or start > found[0]["end"]: + found.append({"type": OUTRO, "start": round(start, 2), "end": round(duration, 2), "phrase": phrase}) + return found + + +def for_episode(db: sqlite3.Connection, ep: sqlite3.Row) -> list[dict]: + """The cuts the podcast's phrases make in this episode, as `find` gives them.""" + intro, outro = of_podcast(db, ep["podcast_id"]) + if not (intro or outro) or not ep["duration"]: + return [] + return find(words(ep), ep["duration"], intro, outro) + + +def saved(ep: sqlite3.Row) -> list[dict]: + return json.loads(ep["phrase_cuts"] or "[]") + + +# ---- when the phrases change ------------------------------------------------------ + +def save(db: sqlite3.Connection, podcast_id: int, intro: list[str], outro: list[str], user_id: int | None) -> int: + """Save a podcast's phrases, and queue its ready episodes to be cut again where they now cut differently. + Returns how many were queued.""" + with transaction(db): + db.execute("UPDATE podcasts SET intro_phrases = ?, outro_phrases = ? WHERE id = ?", + (json.dumps(intro), json.dumps(outro), podcast_id)) + return requeue(db, podcast_id, user_id, note="Waiting to be cut again: the intro and outro phrases changed") + + +def requeue(db: sqlite3.Connection, podcast_id: int, user_id: int | None, episode_ids: list[int] | None = None, + note: str | None = None) -> int: + """Queue the podcast's ready episodes (or these of them) whose phrase cuts are no longer what they were cut + with. Returns how many were queued.""" + rows = db.execute("SELECT * FROM episodes WHERE podcast_id = ? AND state = ? AND transcripts IS NOT NULL " + "AND transcripts != '[]'", (podcast_id, READY)).fetchall() + want = set(episode_ids) if episode_ids is not None else None + stale = [ep["id"] for ep in rows + if (want is None or ep["id"] in want) and for_episode(db, ep) != saved(ep)] + return len(enqueue(db, "process", stale, user_id, skip_ready=False, payload=json.dumps({"recut": True}), + note=note)) diff --git a/podblock/app/processing.py b/podblock/app/processing.py index 3a761aa..dbf05c4 100644 --- a/podblock/app/processing.py +++ b/podblock/app/processing.py @@ -22,7 +22,7 @@ from ..matcher import MatchParams from ..pipeline import Paths, episode_report, find_common_blocks, load_fingerprint, rescan from ..store import BUMPER, BUMPER_IN, BUMPER_OUT, IGNORED, INTRO, KEPT, OUTRO, PODCAST_TYPES, SHOW, Store -from . import captions, chapters +from . import captions, chapters, phrases from .config import Settings from .db import transaction from .jobs import Cancelled, Context, enqueue, settle_episode, take_along @@ -100,9 +100,12 @@ def _fill_gaps(db: sqlite3.Connection, podcast_id: int | None) -> float: return row["fill_gaps"] if row is not None else FILL_GAPS -def cuts(db: sqlite3.Connection, episode_id: int, report: dict) -> list[tuple[float, float]]: - """Everything to cut from an episode: the report's sections and the ones cut by hand.""" +def cuts(db: sqlite3.Connection, episode_id: int, report: dict, + phrase_cuts: list[dict] = ()) -> list[tuple[float, float]]: + """Everything to cut from an episode: the report's sections, the intro and outro its phrases mark, and the + ones cut by hand.""" found = [(s["start"], s["end"]) for s in report["sections"]] + found += [(p["start"], p["end"]) for p in phrase_cuts] found += [(r["start"], r["end"]) for r in db.execute("SELECT start, end FROM edits WHERE episode_id = ?", (episode_id,))] return found @@ -110,21 +113,22 @@ def cuts(db: sqlite3.Connection, episode_id: int, report: dict) -> list[tuple[fl def _render(ctx: Context, settings: Settings, episode_id: int, report: dict) -> None: ep = _episode(ctx.db, episode_id) - keep = cut.keep_spans(cuts(ctx.db, episode_id, report), ep["duration"]) - if not keep: - raise ProcessError("the whole episode was found to repeat, so nothing would be left") - spec = json.dumps([[round(a, 2), round(b, 2)] for a, b in keep]) - if ep["source_chapters"] is None: + if ep["source_chapters"] is None: # with the feed's transcripts, for its phrases ctx.step("Reading chapters", episode_id) chapters.gather(ctx.db, ep, settings.media.transcripts(ep)) ep = _episode(ctx.db, episode_id) + said = phrases.for_episode(ctx.db, ep) + keep = cut.keep_spans(cuts(ctx.db, episode_id, report, said), ep["duration"]) + if not keep: + raise ProcessError("the whole episode was found to repeat, so nothing would be left") + spec = json.dumps([[round(a, 2), round(b, 2)] for a, b in keep]) edits = ctx.db.execute("SELECT start, end FROM edits WHERE episode_id = ?", (episode_id,)).fetchall() - marks = chapters.build(json.loads(spec), chapters.cuts(report, edits), json.loads(ep["source_chapters"])) + marks = chapters.build(json.loads(spec), chapters.cuts(report, edits, said), json.loads(ep["source_chapters"])) if (spec == ep["cut_spec"] and json.dumps(marks) == ep["cut_chapters"] and ep["cut_path"] and Path(ep["cut_path"]).exists()): with transaction(ctx.db): # nothing to cut differently - ctx.db.execute("UPDATE episodes SET state = 'ready', state_note = NULL, " - "ready_at = COALESCE(ready_at, ?) WHERE id = ?", (time.time(), episode_id)) + ctx.db.execute("UPDATE episodes SET state = 'ready', state_note = NULL, phrase_cuts = ?, " + "ready_at = COALESCE(ready_at, ?) WHERE id = ?", (json.dumps(said), time.time(), episode_id)) return ctx.step("Cutting", episode_id) try: @@ -137,10 +141,10 @@ def _render(ctx: Context, settings: Settings, episode_id: int, report: dict) -> with transaction(ctx.db): ctx.db.execute( """UPDATE episodes SET state = 'ready', state_note = NULL, cut_path = ?, cut_type = ?, cut_duration = ?, - cut_bytes = ?, cut_spec = ?, cut_chapters = ?, processed_at = ?, + cut_bytes = ?, cut_spec = ?, cut_chapters = ?, phrase_cuts = ?, processed_at = ?, ready_at = COALESCE(ready_at, ?), expired_at = NULL, ever_downloaded = 1 WHERE id = ?""", - (str(out.path), out.mime, out.duration, out.size, spec, json.dumps(marks), time.time(), time.time(), - episode_id)) + (str(out.path), out.mime, out.duration, out.size, spec, json.dumps(marks), json.dumps(said), time.time(), + time.time(), episode_id)) def ensure_peaks(db: sqlite3.Connection, settings: Settings, episode_id: int) -> Path | None: @@ -423,12 +427,15 @@ def run_edit(ctx: Context, settings: Settings) -> None: def timeline(db: sqlite3.Connection, episode_id: int, report: dict | None) -> dict: """What the episode page's timeline draws: each cut section, what it is, and the blocks and bumpers in it; - the sections kept in this podcast; and the false positives that play in it, for a list of their own. - `custom` marks a section of this podcast's own types or blocks; `own` lists its blocks marked by hand.""" - podcast = db.execute("SELECT podcast_id FROM episodes WHERE id = ?", (episode_id,)).fetchone()[0] + the sections kept in this podcast; the intro and outro its phrases cut; and the false positives that play in + it, for a list of their own. `custom` marks a section that is what it is because of this podcast's own types + or blocks (an Outro block that also opens an episode is cut there as a plain repeat, so that one isn't); + `own` lists its blocks marked by hand.""" + ep = _episode(db, episode_id) + podcast = ep["podcast_id"] store = Store.on(db) types = store.types(podcast) - custom = set(store.podcast_types(podcast)) if podcast is not None else set() + mine = store.podcast_types(podcast) if podcast is not None else {} own = {r[0] for r in db.execute("SELECT id FROM segments WHERE by_hand = 1")} bumpers = [r for r in db.execute( "SELECT o.segment_id, o.start, o.end FROM occurrences o JOIN segments s ON s.id = o.segment_id " @@ -448,11 +455,12 @@ def timeline(db: sqlite3.Connection, episode_id: int, report: dict | None) -> di set_to = SHOW if SHOW in has else "auto" near = [{"block": r["segment_id"], "start": r["start"], "end": r["end"]} for r in bumpers if r["start"] <= s["end"] + 5 and r["end"] >= s["start"] - 5] + is_break = "break" in s["types"] + custom = (set_to in {mine.get(b) for b in blocks} or bool(own & set(blocks)) + or (is_break and any(b["block"] in mine for b in near))) sections.append({"id": f"r{i}", "start": s["start"], "end": s["end"], "value": set_to, - "is_break": "break" in s["types"], "blocks": blocks, - "bumpers": near if "break" in s["types"] else [], - "custom": bool(custom & {*blocks, *(b["block"] for b in near)} or own & set(blocks)), - "own": sorted(own & set(blocks))}) + "is_break": is_break, "blocks": blocks, "bumpers": near if is_break else [], + "custom": custom, "own": sorted(own & set(blocks))}) for i, k in enumerate((report or {}).get("kept", [])): blocks = [b for b in k["blocks"] if b in types] sections.append({"id": f"k{i}", "start": k["start"], "end": k["end"], "value": KEPT, "is_break": False, @@ -461,5 +469,6 @@ def timeline(db: sqlite3.Connection, episode_id: int, report: dict | None) -> di for i, x in enumerate((report or {}).get("not_repeats", []))] manual = [{"id": f"m{r['id']}", "edit": r["id"], "start": r["start"], "end": r["end"]} for r in db.execute("SELECT * FROM edits WHERE episode_id = ? ORDER BY start", (episode_id,))] - return {"sections": sections, "manual": manual, "not_repeats": not_repeats} + said = [{"id": f"p{i}", **p} for i, p in enumerate(phrases.saved(ep))] + return {"sections": sections, "manual": manual, "not_repeats": not_repeats, "phrases": said} diff --git a/podblock/web/routes/downloads.py b/podblock/web/routes/downloads.py index 70ceb68..9272797 100644 --- a/podblock/web/routes/downloads.py +++ b/podblock/web/routes/downloads.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import RedirectResponse, Response -from ...app import jobs, library, storage +from ...app import captions, jobs, library, storage from ..deps import admin_user, get_db, wake_worker from ..templating import filesize, flash, page, plural @@ -23,7 +23,8 @@ def downloads_page(request: Request, sort: str = "size", user=Depends(admin_user items = storage.on_disk(db, sort) return page(request, "downloads.html", title="Downloads", nav="downloads", user=user, items=items, sort=sort, sorts=SORTS, total=sum(d["total"] for d in items), originals=sum(d["original"] for d in items), - cuts=sum(d["cut"] for d in items), disk=storage.disk(request.app.state.settings.media_dir)) + cuts=sum(d["cut"] for d in items), disk=storage.disk(request.app.state.settings.media_dir), + transcriber=bool(request.app.state.settings.whisper_url)) @router.post("/downloads/delete") @@ -51,3 +52,18 @@ def reprocess_downloads(request: Request, ids: list[int] = Form([]), next: str = note = f" {skipped:,} already queued." if skipped else "" flash(request, f"Queued {plural(len(queued), 'episode')}.{note}" if queued else "Those are already queued.") return RedirectResponse(next if next.startswith("/downloads") else "/downloads", status_code=303) + + +@router.post("/downloads/transcribe") +def transcribe_downloads(request: Request, ids: list[int] = Form([]), next: str = Form("/downloads"), + user=Depends(admin_user), db: sqlite3.Connection = Depends(get_db)) -> Response: + """Transcribe the episodes picked again, replacing any transcript they have, their feed's included.""" + ids = list(dict.fromkeys(ids))[:library.MAX_IDS] + queued = captions.queue(db, request.app.state.settings, ids, user["id"], force=True) + if queued: + wake_worker(request) + skipped = len(ids) - len(queued) + note = f" {skipped:,} skipped: already queued, or no download." if skipped else "" + flash(request, f"Queued {plural(len(queued), 'episode')} to transcribe.{note}" if queued + else "Nothing queued: those are already queued, have no download, or no transcriber is set.") + return RedirectResponse(next if next.startswith("/downloads") else "/downloads", status_code=303) diff --git a/podblock/web/routes/podcasts.py b/podblock/web/routes/podcasts.py index 2fd1733..70da3c0 100644 --- a/podblock/web/routes/podcasts.py +++ b/podblock/web/routes/podcasts.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request from fastapi.responses import JSONResponse, RedirectResponse, Response -from ...app import directory, filters, following, library, private_feeds, storage +from ...app import directory, filters, following, library, phrases, private_feeds, storage from ..deps import admin_user, current_user, feed_url, get_db, wake_worker from ..templating import flash, page, plural @@ -154,6 +154,10 @@ def show_podcast(request: Request, podcast_id: int, view: str = "active", q: str return page(request, "_episodes.html", **context) if tab == "feed": context.update(private_addresses=_addresses(request, private_feeds.feed_token(db, user["id"], podcast_id))) + if tab == "settings": + intro, outro = phrases.of_podcast(db, podcast_id) + context.update(intro_phrases=params.get("intro", "\n".join(intro)), + outro_phrases=params.get("outro", "\n".join(outro))) return page(request, "podcast.html", title=pod["name"], nav="podcasts", user=user, cut_sec=library.cut_seconds(db, podcast_id), subscribed=library.subscribed(db, user["id"], podcast_id), @@ -178,6 +182,23 @@ def podcast_settings(request: Request, podcast_id: int, retention_days: int = Fo return RedirectResponse(f"/podcasts/{podcast_id}?tab=settings#tabs", status_code=303) +@router.post("/podcasts/{podcast_id}/phrases") +def podcast_phrases(request: Request, podcast_id: int, intro: str = Form(""), outro: str = Form(""), + user=Depends(current_user), db: sqlite3.Connection = Depends(get_db)) -> Response: + """Save the phrases that end the show's intro and begin its outro, and cut its episodes again to fit.""" + _podcast_or_404(db, podcast_id) + try: + queued = phrases.save(db, podcast_id, phrases.parse(intro), phrases.parse(outro), user["id"]) + except phrases.PhraseError as e: + flash(request, str(e), "error") + typed = urlencode({"tab": "settings", "intro": intro, "outro": outro}) + return RedirectResponse(f"/podcasts/{podcast_id}?{typed}#phrases", status_code=303) + if queued: + wake_worker(request) + flash(request, "Saved." + (f" Cutting {plural(queued, 'episode')} again to fit." if queued else "")) + return RedirectResponse(f"/podcasts/{podcast_id}?tab=settings#phrases", status_code=303) + + @router.post("/podcasts/{podcast_id}/filters") def add_filter(request: Request, podcast_id: int, pattern: str = Form(""), scope: str = Form("podcast"), processing: bool = Form(False), user=Depends(current_user), diff --git a/podblock/web/static/app.css b/podblock/web/static/app.css index 776c0b0..1030b9d 100644 --- a/podblock/web/static/app.css +++ b/podblock/web/static/app.css @@ -462,9 +462,9 @@ a.podcast-card:hover { background: var(--surface-2); } .bulkbar { position: sticky; bottom: 16px; z-index: 25; - display: flex; align-items: center; gap: 12px; + display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; /* the actions drop below when they don't fit */ width: min(100%, 560px); margin: 16px auto 0; padding: 10px 10px 10px 14px; - background: var(--surface-3); border: 1px solid var(--border-strong); border-radius: 999px; + background: var(--surface-3); border: 1px solid var(--border-strong); border-radius: 28px; box-shadow: 0 16px 40px rgb(0 0 0 / 0.55); } .js .bulkbar { position: fixed; left: 50%; bottom: 20px; margin: 0; transform: translate(-50%, 140%); opacity: 0; @@ -473,10 +473,14 @@ a.podcast-card:hover { background: var(--surface-2); } .bulk-all { display: grid; place-items: center; width: 28px; height: 28px; } .bulk-all input { width: 20px; height: 20px; accent-color: var(--accent); cursor: pointer; } .bulk-count { font-weight: 600; white-space: nowrap; } -.bulk-actions { display: flex; gap: 8px; margin-left: auto; } +.bulk-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-left: auto; } @media (max-width: 720px) { .js .bulkbar { bottom: calc(var(--tabbar-h) + 12px + env(safe-area-inset-bottom)); } } +@media (max-width: 480px) { + .bulk-actions { gap: 6px; } + .bulk-actions .btn { padding-inline: 10px; } +} /* ---- toasts --------------------------------------------------------------- */ @@ -833,6 +837,7 @@ button.transcript-time:hover { color: var(--accent); text-decoration: underline; .settings-form .field { flex: 1 1 240px; } .settings-form .btn { align-self: flex-end; margin-bottom: 22px; } .input-with-unit { display: flex; align-items: center; gap: 10px; } +textarea.phrase-input { padding: 10px 14px; resize: vertical; line-height: 1.4; } .input-with-unit .input { max-width: 120px; } select.input { appearance: auto; } @media (max-width: 720px) { diff --git a/podblock/web/static/timeline.js b/podblock/web/static/timeline.js index b3c87f3..554c570 100644 --- a/podblock/web/static/timeline.js +++ b/podblock/web/static/timeline.js @@ -27,6 +27,8 @@ const sections = [ ...data.sections.map((s) => ({ ...s, saved: s.value, deleted: false })), ...data.manual.map((m) => ({ ...m, manual: true, removed: false })), + // Cut by the show's intro and outro phrases: shown, and changed only through the phrases. + ...(data.phrases || []).map((p) => ({ ...p, byPhrase: true, value: p.type, saved: p.type, blocks: [], bumpers: [], own: [] })), ]; // False positives that play here: not on the timeline, only in a list of their own, to undo the flag. const falses = data.not_repeats.map((x) => ({ ...x, value: "ignored", saved: "ignored", bumpers: [] })); @@ -89,12 +91,14 @@ const isCut = (s) => !s.removed && !s.deleted && !(!s.manual && (s.value === "ignored" || s.value === "kept")); const allOwn = (s) => !s.manual && s.own.length > 0 && s.own.length === s.blocks.length; // only blocks marked by hand function kind(s) { + if (s.byPhrase) return s.type; if (s.manual) return s.removed ? "removed" : "manual"; if (s.deleted) return "removed"; if (s.value === "auto") return s.is_break ? "break" : "repeated"; return s.value === "kept" ? "ignored" : s.value; } function name(s) { + if (s.byPhrase) return s.type === "intro" ? "Intro (phrase)" : "Outro (phrase)"; if (s.manual) return isNew(s) ? "New section" : "Cut by hand"; if (s.value === "auto") return s.is_break ? "Ad break" : "Repeat"; return { intro: "Intro", outro: "Outro", show: "Local", kept: "Kept", ignored: "Not a repeat" }[s.value]; @@ -105,6 +109,7 @@ function changes() { const types = [], add = [], remove = [], del = []; for (const s of [...sections, ...falses]) { + if (s.byPhrase) continue; if (isNew(s)) add.push({ start: s.start, end: s.end, as_block: !!s.asBlock, type: s.blockType || "show" }); else if (s.manual && s.removed) remove.push(s.edit); else if (s.deleted) del.push(...s.own); @@ -184,7 +189,10 @@ const li = h("li", { class: `tl-item k-${kind(s)}${isNew(s) ? " tl-item-new" : ""}`, "data-id": s.id }, head, playButton(s, "icon-btn tl-item-play")); li.addEventListener("click", (e) => { if (!e.target.closest("button, select, input, label")) focusSection(s); }); - if (!s.manual) { + if (s.byPhrase) { + li.append(h("div", { class: "tl-item-row" }, + h("span", { class: "hint" }, `Heard “${s.phrase}”. Change it in the show's settings.`))); + } else if (!s.manual) { const row = h("div", { class: "tl-item-row" }, allOwn(s) ? null : valueSelect(s)); if (s.bumpers.length) row.append(h("span", { class: "hint" }, "Found from the bumpers around it")); if (editable && s.custom && !allOwn(s) && s.saved !== "auto" && s.value === s.saved) { @@ -254,7 +262,7 @@ } wave.append(s.region); s.label = h("div", { class: `tl-label k-${kind(s)}` }, - !s.manual && !allOwn(s) && editable ? valueSelect(s, true) : h("span", { class: "tl-label-text" }, name(s)), + !s.manual && !s.byPhrase && !allOwn(s) && editable ? valueSelect(s, true) : h("span", { class: "tl-label-text" }, name(s)), h("button", { type: "button", class: "tl-chip", title: `${name(s)}, ${clock(s.start)}: click to zoom in`, "aria-label": `${name(s)} at ${clock(s.start)}`, onclick: () => focusSection(s) }, icon(KIND_ICONS[kind(s)] || KIND_ICONS.repeated))); @@ -341,7 +349,7 @@ for (const [band, b] of s.bands) { Object.assign(band.style, { left: `${xAt(b.start)}px`, width: `${Math.max(2, xAt(b.end) - xAt(b.start))}px` }); } - const fullWidth = s.manual || !editable ? LABEL_W.text : LABEL_W.full; + const fullWidth = s.manual || s.byPhrase || !editable ? LABEL_W.text : LABEL_W.full; let placed = null; for (const [mode, width] of [["full", fullWidth], ["chip", LABEL_W.chip]]) { const row = rows.findIndex((taken) => free(taken, left, left + width)); diff --git a/podblock/web/templates/downloads.html b/podblock/web/templates/downloads.html index 2b2a72f..7a0f566 100644 --- a/podblock/web/templates/downloads.html +++ b/podblock/web/templates/downloads.html @@ -91,6 +91,10 @@

{{ e.title o 0 selected
+ {% if transcriber %} + + {% endif %} diff --git a/podblock/web/templates/podcast.html b/podblock/web/templates/podcast.html index 5e47123..b04754f 100644 --- a/podblock/web/templates/podcast.html +++ b/podblock/web/templates/podcast.html @@ -177,6 +177,25 @@

Settings

+
+

Intro and outro phrases

+

Listened for in captions and transcripts. One phrase a line; case and punctuation don't matter. + Between slashes, a regular expression over the words: /youth of (the|a) nation/.

+
+ + + +
+
+ {% if user.is_admin %}
diff --git a/tests/test_captions.py b/tests/test_captions.py index 08f1739..03aab11 100644 --- a/tests/test_captions.py +++ b/tests/test_captions.py @@ -184,3 +184,65 @@ def test_speaker_turns_are_saved_with_the_captions_and_compared_with_the_repeats from podblock.app import retention retention.clear(db, episode(db)) assert not list((setup.media_dir / "transcripts").iterdir()) # the turns go with the download + + +def test_captions_keep_each_words_time_and_an_intro_phrase_heard_cuts_again(db, setup, service): + from podblock.app import phrases + db.execute("UPDATE episodes SET duration = 20 WHERE id = 1") + db.execute("UPDATE podcasts SET intro_phrases = ? WHERE id = 1", (json.dumps(["You will hear AGAIN"]),)) + db.commit() + captions.queue(db, setup, [1], None) + assert worker.run_job(setup, db, jobs.claim(db, kinds=(captions.KIND,))) == "done" + assert phrases.words(episode(db))[:3] == [(1.0, 1.3, "an"), (1.3, 1.6, "ad"), (1.6, 1.9, "you")] + assert phrases.for_episode(db, episode(db)) == [ + {"type": "intro", "start": 0.0, "end": 3.05, "phrase": "You will hear AGAIN"}] + queued = db.execute("SELECT kind, episode_id, payload FROM jobs WHERE state = 'queued'").fetchall() + assert [tuple(r) for r in queued] == [("process", 1, '{"recut": true}')] + assert episode(db)["state_note"] == "Waiting to be cut again: an intro or outro phrase was heard" + + from podblock.app import retention + retention.clear(db, episode(db)) + assert not list((setup.media_dir / "transcripts").iterdir()) # the word times go with the download + + +def test_transcribing_again_replaces_the_feeds_transcript_and_earlier_captions(db, setup, service, tmp_path): + feeds = tmp_path / "media/transcripts/feed.srt" + feeds.parent.mkdir(parents=True) + feeds.write_text("1\n00:00:01,000 --> 00:00:02,000\nThe feed's own\n") + db.execute("UPDATE episodes SET transcripts = ? WHERE id = 1", + (json.dumps([{"format": "srt", "path": str(feeds)}]),)) + db.commit() + assert captions.queue(db, setup, [1], None) == [] # it has one + assert captions.queue(db, setup, [1], None, force=True) == [1] + assert captions.queue(db, setup, [1], None, force=True) == [] # one is waiting already + assert worker.run_job(setup, db, jobs.claim(db, kinds=(captions.KIND,))) == "done" + (saved,) = chapters.transcripts(episode(db)) + assert saved["made"] == "whisper" and not feeds.exists() + files = {saved["path"], saved["words"]} + + captions.queue(db, setup, [1], None, force=True) # again: written over the same files, which stay + assert worker.run_job(setup, db, jobs.claim(db, kinds=(captions.KIND,))) == "done" + assert [t["path"] for t in chapters.transcripts(episode(db))] == [saved["path"]] + assert all(Path(f).exists() for f in files) and len(service.model.calls) == 2 + + +def test_the_downloads_page_queues_episodes_to_transcribe_again(db, setup): + pytest.importorskip("fastapi") + from dataclasses import replace + + from fastapi.testclient import TestClient + + from podblock.web.app import create_app + client = TestClient(create_app(replace(setup, api_key="k", background=False)), follow_redirects=False) + client.post("/login", data={"key": "k"}) + db.execute("UPDATE episodes SET transcripts = '[{\"format\": \"vtt\", \"path\": \"x\"}]' WHERE id = 1") + db.commit() + assert 'formaction="/downloads/transcribe"' in client.get("/downloads").text + res = client.post("/downloads/transcribe", data={"ids": [1, 2]}) + assert res.status_code == 303 and res.headers["location"] == "/downloads" + (job,) = db.execute("SELECT kind, payload FROM jobs").fetchall() + assert tuple(job) == (captions.KIND, '{"episode": 1, "force": true}') + + off = TestClient(create_app(replace(setup, api_key="k", background=False, whisper_url=None))) + off.post("/login", data={"key": "k"}) + assert "/downloads/transcribe" not in off.get("/downloads").text # no transcriber, no button diff --git a/tests/test_phrases.py b/tests/test_phrases.py new file mode 100644 index 0000000..42a1efd --- /dev/null +++ b/tests/test_phrases.py @@ -0,0 +1,92 @@ +"""Intro and outro phrases: found in a transcript's words, and the cuts they make.""" + +import json + +import pytest + +from podblock.app import phrases + + +def said(start, text, each=0.5): + return [(start + i * each, start + (i + 1) * each - 0.1, w) for i, w in enumerate(phrases.tokens(text))] + + +def test_words_match_without_case_or_punctuation(): + assert phrases.tokens("Let’s get INTO it!") == ["lets", "get", "into", "it"] + assert phrases.parse(" let's get into it \n\nLETS GET INTO IT\nthat's all, folks\n") == [ + "let's get into it", "that's all, folks"] + for bad in ("?!", "x" * 101, "\n".join(f"phrase {i}" for i in range(21))): + with pytest.raises(phrases.PhraseError): + phrases.parse(bad) + + +def test_an_intro_ends_at_its_first_hearing_and_an_outro_starts_at_its_last(): + heard = [*said(0, "welcome back everybody lets get into it today we talk"), + *said(300, "and lets get into it once more"), # past the first third: not the intro's end + *said(500, "thanks for listening see you next week"), + *said(560, "thanks for listening this was a production of somebody")] + found = phrases.find(heard, 600, ["let's get into it", "never said"], ["Thanks for listening!"]) + assert found == [ + {"type": "intro", "start": 0.0, "end": 3.5, "phrase": "let's get into it"}, + {"type": "outro", "start": pytest.approx(559.75), "end": 600, "phrase": "Thanks for listening!"}, + ] + assert found[0]["end"] == heard[7][0] # padded, but not into the next word + + assert phrases.find(said(300, "lets get into it"), 600, ["lets get into it"], []) == [] + assert phrases.find(said(100, "thanks for listening"), 600, [], ["thanks for listening"]) == [] # too early + assert phrases.find([], 600, ["anything"], ["anything"]) == [] + + +def test_a_feeds_transcript_times_words_by_where_they_fall_in_a_cue(tmp_path): + vtt = tmp_path / "t.vtt" + vtt.write_text("WEBVTT\n\n00:00:10.000 --> 00:00:14.000\nOkay, let's & go\n") + ep = {"transcripts": json.dumps([{"format": "vtt", "path": str(vtt)}])} + words = phrases.words(ep) + assert [w for _, _, w in words] == ["okay", "lets", "go"] + assert words[0][0] == pytest.approx(10) and words[-1][1] == pytest.approx(14) + assert words[1][0] == pytest.approx(10 + 4 * 6 / 16) + + js = tmp_path / "t.json" + js.write_text(json.dumps({"segments": [{"startTime": 1, "endTime": 2, "body": "one two"}, + {"startTime": 5, "body": "three"}]})) + words = phrases.words({"transcripts": json.dumps([{"format": "json", "path": str(js)}])}) + assert [(w, round(a, 2)) for a, _, w in words] == [("one", 1), ("two", 1.57), ("three", 5)] + + +def test_captions_word_times_win_over_their_cues(tmp_path): + vtt, timed = tmp_path / "c.vtt", tmp_path / "c.words.json" + vtt.write_text("WEBVTT\n\n00:00:00.000 --> 00:00:09.000\nwrong times\n") + timed.write_text(json.dumps([[1.0, 1.4, " That's"], [1.5, 2.0, " it."]])) + ep = {"transcripts": json.dumps([{"format": "vtt", "path": str(vtt), "words": str(timed)}])} + assert phrases.words(ep) == [(1.0, 1.4, "thats"), (1.5, 2.0, "it")] + + +def test_a_chant_sung_over_and_over_is_cut_whole(): + chant = "we are we are the youth of the nation" + heard = [*said(0, "and that is the end of the story"), + *said(800, "thanks everybody"), + *said(880, chant), *said(886, "youth of the nation"), *said(891, chant), *said(897, "yeah")] + found = phrases.find(heard, 900, [], ["We are, we are, the youth of the nation", "youth of the nation"]) + assert found == [{"type": "outro", "start": pytest.approx(879.75), "end": 900, + "phrase": "We are, we are, the youth of the nation"}] + + intro = [*said(0, chant), *said(5, chant), *said(10, "welcome to the show"), *said(60, chant)] + assert phrases.find(intro, 900, [chant], [])[0]["end"] == pytest.approx(9.65) # through the second, not the third + assert phrases.find(said(880, "youth of a nation"), 900, [], [chant]) == [] # every word has to match + + +def test_a_phrase_between_slashes_is_a_regular_expression_over_whole_words(): + chant = "/(we are )*the youth of (the|a) nation/" + assert phrases.parse(f"{chant}\n{chant}\nyouth of the nation") == [chant, "youth of the nation"] + for bad in ("/(unclosed/", "/(we are )*/", "/x?/"): + with pytest.raises(phrases.PhraseError): + phrases.parse(bad) + + heard = [*said(0, "the end of the story"), *said(880, "We are, we are, the youth of a nation!")] + found = phrases.find(heard, 900, [], [chant]) + assert found == [{"type": "outro", "start": pytest.approx(879.75), "end": 900, "phrase": chant}] + assert phrases.find(said(880, "we are the youth of the nations"), 900, [], [chant]) == [] # whole words + assert phrases.find(said(880, "the youth of the nation"), 900, [], ["/YOUTH OF/"])[0]["start"] == \ + pytest.approx(880.4) # ignoring case; not into "the" + sprawl = said(700, "thanks " + "word " * 40 + "nation") + assert phrases.find(sprawl, 900, [], ["/thanks.*nation/"]) == [] # too many words for one match diff --git a/tests/test_processing.py b/tests/test_processing.py index 8be8f63..93242d9 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -296,6 +296,16 @@ def test_keep_and_intro_are_this_shows_own_and_come_off_again(env): assert env.db.execute("SELECT type FROM segments WHERE id = ?", (row["segment_id"],)).fetchone()[0] == "repeated" +def test_a_shows_outro_block_playing_at_the_start_is_a_plain_repeat_there(env): + processed(env) + first = report_of(env, "ep-3")["sections"][0] + assert first["start"] < 0.5 + env.db.execute("INSERT INTO podcast_types VALUES (?, ?, 'outro', 0)", (env.pod, first["blocks"][0])) + env.db.commit() + shown = processing.timeline(env.db, env.ids["ep-3"], report_of(env, "ep-3"))["sections"][0] + assert shown["value"] == "auto" and not shown["custom"] # nothing of the show's to remove here + + def test_not_a_repeat_is_for_every_show_and_listed_to_undo(env): processed(env) pod_b, b = other_show(env) @@ -1025,6 +1035,37 @@ def test_chapters_and_transcripts_fit_the_cut_audio(env): assert not saved.exists() and episode(env, "ep-3")["source_chapters"] is None +def test_an_outro_phrase_in_the_transcript_cuts_it_and_everything_after(env): + with_extras(env) + res = env.client.post(f"/podcasts/{env.pod}/phrases", data={"intro": "", "outro": "/after (the|an) ad/\n"}) + assert res.status_code == 303 + processed(env) + three = episode(env, "ep-3") + total = JINGLE + AD + 2 * FILLER + assert json.loads(three["phrase_cuts"]) == [ + {"type": "outro", "start": 79.75, "end": pytest.approx(total, abs=0.5), "phrase": "/after (the|an) ad/"}] + assert three["cut_duration"] == pytest.approx(79.75 - JINGLE - AD, abs=3) + assert episode(env, "ep-2")["phrase_cuts"] == "[]" # no transcript to hear it in + assert "After the ad" not in env.client.get(f"/episodes/{three['id']}/captions.vtt").text + + page = env.client.get(f"/episodes/{three['id']}").text + data = json.loads(re.search(r'id="timeline-data"[^>]*>(.*?)', page, re.S)[1]) + assert [(p["id"], p["type"], p["start"]) for p in data["phrases"]] == [("p0", "outro", 79.75)] + settings = env.client.get(f"/podcasts/{env.pod}?tab=settings").text + assert ">/after (the|an) ad/" in settings + + # Taking the phrase away cuts again only the episode it changes. + res = env.client.post(f"/podcasts/{env.pod}/phrases", data={"intro": "", "outro": ""}) + assert res.status_code == 303 and episode(env, "ep-3")["state"] == "queued" + assert episode(env, "ep-2")["state"] == "ready" + worker.run_queue(env.settings) + assert episode(env, "ep-3")["cut_duration"] == pytest.approx(total - JINGLE - AD, abs=3) + + bad = env.client.post(f"/podcasts/{env.pod}/phrases", data={"intro": "/(oops/", "outro": "kept"}) + assert bad.status_code == 303 and "outro=kept" in bad.headers["location"] + assert ">kept" in env.client.get(bad.headers["location"]).text # what was typed, to fix + + def test_episodes_cut_before_chapters_get_them_when_cut_again(env): processed(env) env.db.execute("UPDATE episodes SET cut_chapters = NULL, source_chapters = NULL") # as cut by an older version diff --git a/tests/test_schema.py b/tests/test_schema.py index eb3d304..db034a8 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -30,7 +30,7 @@ SHIPPED = ["9e41bd10322d1b18", "25b49003da210e9e", "a3b543510a6e79fe", "52c4ef83a590799a", "95f4530505c45b89", "da86379def3017da", "579499483c73fce2", "8584ad1da8ec8f0d", "0ecaa28be5177004", "3d3d0fb58e988193", "894c592d023cff02", "2279df03ba58202e", "6746b5025296d568", "03af274d2e0e3f79", "a4894b75bb3c8de0", - "5dc3b8c724b904c2"] + "5dc3b8c724b904c2", "6b5eaf907368114f"] def tables(db: sqlite3.Connection) -> set[str]: diff --git a/tests/test_web.py b/tests/test_web.py index b6dcf0c..b83c6a8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -232,7 +232,10 @@ def test_the_shared_feed_becomes_the_admins_on_upgrade(tmp_path): DROP TABLE grabs; ALTER TABLE episodes DROP COLUMN source_chapters; ALTER TABLE episodes DROP COLUMN transcripts; ALTER TABLE episodes DROP COLUMN cut_chapters; - ALTER TABLE episodes DROP COLUMN held_by;""") # undo every step from per-user feeds on + ALTER TABLE episodes DROP COLUMN held_by; + ALTER TABLE podcasts DROP COLUMN intro_phrases; + ALTER TABLE podcasts DROP COLUMN outro_phrases; + ALTER TABLE episodes DROP COLUMN phrase_cuts;""") # undo every step from per-user feeds on before = next(n for n, m in enumerate(MIGRATIONS) if isinstance(m, str) and "CREATE TABLE user_feeds" in m) db.execute("UPDATE settings SET value = ? WHERE key = 'app_schema'", (str(before),)) db.commit()