Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 28 additions & 5 deletions WEBAPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <item>
Expand All @@ -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,
Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down
52 changes: 39 additions & 13 deletions podblock/app/captions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
14 changes: 9 additions & 5 deletions podblock/app/chapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down Expand Up @@ -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 "[]"))


Expand Down
6 changes: 6 additions & 0 deletions podblock/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
""",
]


Expand Down
Loading
Loading