# Podcasts, books and the Bible: three private offline apps — spec for an AI agent

Source: https://jacobkunselman.com/apps/ (2026-10-25). The code I run is in private repositories, so this is the
spec instead: what to build, where the data comes from and under what terms, and what we got wrong first. Build
it for your person, in your own code. Where a number is given, it is what worked for me; tune it.

## Before you start: ask your person

1. **Which podcasts?** Their feed URLs, or the show names (find the RSS feed; Apple's directory lists it). Do any
   of them publish transcripts (`podcast:transcript`)? Any YouTube-only channels?
2. **Which Bible translations?** Offer only texts that are free to keep (below). If they want the NIV, NKJV, ESV
   or another copyrighted text, say plainly that it can't be kept offline without a licence.
3. **Which always-on machine?** Any Linux box that stays on: a Raspberry Pi 5 (8 GB or more) is enough for
   serving, polling and indexing. Optional: a machine with a GPU for Whisper transcripts.
4. **Which devices?** Phone (Android Chrome, or iOS Safari with "Add to Home Screen"), laptops, an Android TV.
5. Install **Tailscale** on the server and every device, if it isn't already.

Build the podcast app first. The other two read its transcripts.

## Shared architecture (all three apps)

- **Server:** one Python 3 file per app, standard library only (`http.server.ThreadingHTTPServer`, `sqlite3`,
  `urllib`). It listens on `127.0.0.1` only and runs as a systemd **user** unit with `Restart=on-failure`.
- **Private network:** `tailscale serve --bg --https=<port> http://127.0.0.1:<local port>`, one HTTPS port per app.
  **Never Tailscale Funnel and never a public host.** A service worker needs HTTPS, and Tailscale serve
  provides it. The private network is the login, so there are no accounts.
- **The app:** `index.html`, `app.js`, `app.css`, `sw.js`, `manifest.webmanifest`, and PNG icons (192, 512 and
  maskable). No framework, no build step.
- **The service worker** keeps the shell under a versioned cache name (`shell-vN`). Bump N whenever a shell file
  changes, so installed copies update. Serve the API network-first, falling back to the last cached reply.
- **Offline data:** the server publishes a manifest, `{version, files: [[path, size, mtime], ...]}`. The app saves
  each file into Cache Storage, and on later visits fetches only files whose mtime is newer than the version it
  last saved, or that aren't cached. Without that rule, adding one file makes every phone download everything
  again. Call `navigator.storage.persist()`. Show "Saving for offline: n of m" while it runs.
- **Sync between devices** (positions, bookmarks, plan progress): every record carries `at`/`edited`, a
  millisecond timestamp. The device keeps its own copy and an "unsynced" set, and POSTs them (JSON only). The
  server keeps whichever edit is newer, per record, and the device takes the merged set back.
- **The server machine stays responsive:** batch work runs under `nice`, two workers at most on a Pi. Heavy jobs
  (Whisper, full rescans) go to another machine if there is one.
- **`data/` is never committed**, since it is all rebuilt from sources. `sources.json` lists every source with its
  URL and licence, and a fetch script downloads it into a gitignored `sources/`.

## App 1: Podcasts

**Server**
- `shows.json`: `[{"id", "feed", "backfill": 3, "serial": false, "skip": "regex", "before": "YYYY-MM-DD"}]`, or
  `{"id", "youtube": "<channel id>"}`.
- Every 10 minutes, poll each feed with a conditional GET (ETag / Last-Modified).
- On first sight of a show, download its newest `backfill` episodes. After that, download every new episode.
  Anything older is listed and streams from the show's host, and the Pi downloads it only when the app asks.
  `before` makes an archive: everything before that date is downloaded. `serial` shows play in order from
  episode 1.
- For each kept episode, store `data/media/<id>/`:
  - `audio.*`
  - `art.jpg`, scaled to 1000 px
  - a 160 px thumbnail for lists, about 13 KB
  - `chapters.json` (Podcasting 2.0 `podcast:chapters`), with each chapter image downloaded and its URL made local
  - the transcript (`podcast:transcript`, SRT or WebVTT)
- Serve media with **byte ranges**, so seeking works while streaming.
- **YouTube-only channels:** read the public channel feed (`youtube.com/feeds/videos.xml?channel_id=…`, no key) for
  titles, dates and descriptions, and link to each video. **Never download YouTube audio** (their terms forbid it).
  The feed lists only the newest 15, so keep every one ever seen.
- **Live streams:** a feed's `podcast:liveItem` with status `live` can be a "Live" row. Never save it or keep a
  position for it.
- **Search:** SQLite FTS5 over titles, show notes, chapter titles, and transcripts cut into windows of about 20 s.
  Every word must match, and the last is a prefix when it is 3 letters or more. Rank episodes by the sum of their
  rows' bm25, 20 per page. Each result lists every matching moment in time order, and a tap plays from that moment.
  Cap a query's rows (40,000 was right on a Pi) so a common word can't hold the server.
- **Machine transcripts (optional):** for episodes with no published transcript, and **only for audio already on
  the server**; never download audio just to transcribe it. Any OpenAI-compatible transcription endpoint works.
  I use whisper.cpp large-v3-turbo on a GPU, about 79× real time. Stream-decode with ffmpeg into memory, in
  30-minute parts, with the show and episode title as the prompt (it helps with names). Post the SRT back. Label it
  in the app as made by a machine, and replace it if the feed later publishes one.
- **Positions:** `positions.json`, `{episode: {t, d, done, ch: [chapter indices heard], at}}`. The newest `at` wins.

**App**
- **The list:** chips for All / On this phone / Unplayed and one per show. Draw 60 rows at a time, adding more as the
  end nears, with thumbnails, never full art. 2,180 rows of full art froze a 2 GB Android TV for 2.7 s per redraw.
- **Details first:** a tap shows the episode's art, length, chapters and notes, with Play, Save and Mark played.
  Nothing plays until asked.
- **Player:** chapter art and title, a scrubber with a tick at each chapter (placed on the bar itself, not at a
  percentage of a block that includes the times), −15 / +30, speed, captions, the transcript with the playing line
  marked, and the chapter list.
- **Lock screen:** MediaSession metadata, with the current chapter's art. Next and previous track jump a chapter.
- **Keep on the device:** each show's newest 2 unplayed episodes (a serial show's next 2), plus any saved by hand.
  Remove played ones automatically. Play saved audio from a blob URL, so seeking works offline.
- **A chapter counts as heard** only when playback runs over its end; a skip past it doesn't count.
- **Save the position** locally every 5 s, and to the server every 30 s and on pause.
- **Deep link:** `#play=<episode id>&t=<seconds>` plays that moment. The other two apps use it.
- **Android TV (optional):** Android TV has no Chrome to install from, so build a tiny app with one activity and a
  WebView on the same URL that sets `window.<YourApp>TV`. Under it the page:
  - moves a visible highlight with the arrows (nearest control in that direction, inside whatever is on top);
  - turns Back into close / clear / open-the-player while playing;
  - keeps the screen on while playing (`FLAG_KEEP_SCREEN_ON`);
  - stores nothing on the TV, streaming from the server.

## App 2: The Bible

**Texts:** only texts that are free to keep. Read each one's licence file before using it.
- **Translations:** eBible.org's VPL zips (`https://ebible.org/Scriptures/<code>_vpl.zip`, each with an
  `_about.htm` that states its licence):
  - public domain: BSB (`engbsb`), KJV (`eng-kjv`, with the Apocrypha), WEB (`eng-web`), ASV (`eng-asv`),
    YLT (`engylt`), Darby (`engDBY`), Geneva 1599 (`enggnv`), Douay-Rheims (`engDRA`), Brenton (`eng-Brenton`);
  - LSV (`englsv`), under CC BY-SA 4.0.
  - "World English Bible" is a trademark; the text is public domain.
- **Optional:** R.H. Charles's 1 Enoch (1917) and Jubilees (1913), which are public domain.
- **Cross-references:** OpenBible.info (`https://a.openbible.info/data/cross-references.zip`, CC BY), 343,571 of
  them with votes. Show the most voted first.
- **Versification:** number everything the standard English (KJV) way, so verses line up across translations. Map
  the ones that differ with STEPBible's TVTMS (CC BY): Douay-Rheims and Brenton number the Psalms the Greek way, so
  their Psalm 22 is Psalm 23.

**Study aids**
- **Where to get them:** CrossWire's SWORD module zips
  (`https://www.crosswire.org/ftpmirror/pub/sword/packages/rawzip/<Module>.zip`). Each one's `mods.d/*.conf`
  states its licence; check it.
  - `JFB`, `MHCC` (Matthew Henry's Concise): commentaries
  - `Nave`, `Easton`: topics and a dictionary
  - `SME`: Spurgeon's Morning and Evening
  - `StrongsGreek`, `StrongsHebrew`, `AbbottSmithStrongs`, `Dodson`: lexicons
  - All are public domain.
- **⚠ Not from GetBible's JSON copies of the same modules.** Their terms cap keeping content at 30 days, and their
  references were mis-resolved (Rev 1:11 came out as Gen 1:11).
- **Reading SWORD files** with the standard library, straight from the zip:
  - Commentaries (`zCom`/`zCom4`):
    - Per testament, `ot.bzv`/`nt.bzv` holds one entry per verse slot: block, offset and size (4+4+2 bytes, or
      4+4+4 for zCom4).
    - Those point into zlib blocks listed in `.bzs` (offset, size, uncompressed size) and stored in `.bzz`.
    - The slots run in KJV order: 0 is the module heading, 1 the testament heading, then each book's heading, each
      chapter's heading, and its verses.
    - Verses sharing one note point at the same bytes; merge them into a range.
  - Dictionaries:
    - `zLD`: `.idx` (offset, size) → `.dat`, which holds the key, a newline, then block and entry numbers into
      `.zdx`/`.zdt`.
    - `RawLD`: a 4-byte offset and a 2-byte size.
    - Keys are stored upper-cased, so resolve `@LINK key` entries case-insensitively, after NFC.
  - A dictionary's `DataPath` is the files' stem, not a folder.
- **Hebrew and Greek word by word:** STEPBible's TAHOT and TAGNT (CC BY 4.0). They ask that you download them from
  their GitHub rather than redistribute copies. Credit them in the app.
  - **TAGNT** is NRSV-numbered, with the KJV verse in `[ ]`. Word types: N (Nestle-Aland), K (TR/KJV), O (others),
    with lower case for a minor difference. Mark a word found only in the KJV's Greek (dashed) or only in the
    older manuscripts (faint).
  - **TAHOT** gives the English number first and the Hebrew in `( )`. Keep the L and Q words, not the ketiv or the
    LXX. A psalm's title is verse 0; show it with verse 1.
  - **Grammar codes:** decode them into words ("verb · aorist · active · indicative · 3rd person · singular").
- **Notes ranges:** JFB notes carry their own ranges. A Henry note runs to the next note's start − 1, or to the
  chapter's end.

**App**
- **Reading:** read a chapter in one translation, or several interleaved verse by verse. Swipe to turn chapters.
- **The verse sheet** (a tap on a verse):
  - the verse in every translation;
  - the original words, right to left for Hebrew; a tap on a word opens its lemma, grammar, lexicons and every verse
    it is in;
  - notes on the passage, with a *More* button;
  - podcast moments for the verse;
  - cross-references and topics;
  - **Bookmark** (date, note, labels, range), synced across devices.
- **Search:** FTS5 on the server; offline, a scan of the main translation on the device. `G25` / `H7225` opens a
  Strong's entry, and a topic name opens the topic.
- **Reading plans:** each day has a check box, a date read (editable), and where-you-stopped. Show the status: on
  schedule, or N days behind or ahead.
  - **Cover to cover**, cut by length of text, not by number of chapters.
  - **M'Cheyne (1842)**, from Ben Edgington's CC0 sheet. Test that every verse comes round exactly as designed.
  - **Old and New** side by side.
- Keep the plans file out of the offline manifest, so adding it doesn't make phones save the whole text again.

**Heard in the podcasts:** scan every transcript every 15 minutes (only new or changed ones, read-only). Find two
kinds of reference:
- **Said:** a book name, then a chapter that exists and, if given, a verse that exists.
  - Spoken forms: "John three sixteen", "first Corinthians thirteen", "Romans chapter eight verse twenty-eight",
    "the 23rd Psalm", "Psalm one nineteen" (1:19 doesn't exist; 119 does).
  - Caption forms: `Romans 8 28`, `Daniel 2 .21`, `Isaiah 8 -12` (that's 8:12), and `John 1135` (a lost colon:
    split it into a chapter and verse that exist).
  - Follow-ons: "verse 7" binds to the chapter named in the last few minutes.
  - Guards:
    - Books that are ordinary words (Acts, Numbers, Job, Judges, Revelation) must be capitalised.
    - Books that are names (Mark, John, James, Daniel) need Bible context nearby when the reference is weak.
    - A number followed by a unit is a quantity, not a reference ("John 17 years ago", "Numbers 3 percent").
    - A book named alone is never a hit.
- **Quoted:** the verse's words matched against several translations without its name.
  - Require enough distinctive words.
  - Drop phrases that are common in speech.
  - Score each match, and read the low scores.
- Store each hit with its episode, time, reference, kind, score and the words said. List them by episode, newest
  first, and in each verse's sheet. A tap plays the moment through the podcast app's deep link.
- **Measure it:** read random samples of hits against their transcripts, and turn every false hit into a test. Bump
  a finder version number whenever output would change, so everything is rescanned.

## App 3: Books

- **Catalog:** Project Gutenberg's RDF tarball, `https://www.gutenberg.org/cache/epub/feeds/rdf-files.tar.bz2`
  (127 MB; the catalog data is public domain).
  - Keep only English `Text` ebooks whose `dcterms:rights` is exactly `Public domain in the USA.`.
  - Group editions into works by normalised title and author set. A work in volumes keeps its volumes together.
- **⚠ Gutenberg's robot policy:** catalog files only from `gutenberg.org/cache/epub/feeds/`, and book files only
  from a mirror (`gutenberg.pglaf.org`). Fetch one at a time, a couple of seconds apart, and each file only once:
  keep the raw file. Never crawl gutenberg.org's pages.
- **Each book:**
  - Cut the Project Gutenberg header and licence.
  - Split it into paragraphs, keeping verse lines as verse.
  - Find a table of contents (parts, chapters, cantos, acts).
  - Store it as `text/<work>.json`, `{key, title, from: [ebook ids], paras, toc}`.
  - Index the paragraphs in FTS5.
- **Finding a book in a transcript** (scan like the Bible app; precision matters more than recall). Match catalog
  titles, alternative titles and a small alias list ("The Wealth of Nations", "Tao Te Ching"), words in order with
  the/a/an skipped. A title counts:
  - **with its author** named within about 40 words: "Dostoevsky's Crime and Punishment", "Milton wrote Paradise
    Lost";
  - **alone** only when it can be one book: much downloaded, the only well-read book of that title, a word that
    isn't everyday English, and a book word (read, novel, wrote) nearby.
  - Refuse:
    - a title of everyday words that isn't written as one ("crime and punishment" in lower case);
    - short or name-only titles alone;
    - a title that is a person ("Benjamin Franklin");
    - a name inside a longer name ("Milton Friedman", "Ulysses Grant");
    - a surname shared by several authors, or one that is a common first name;
    - authors that aren't people (Various, Anonymous).
  - The same book again within 90 s is one mention.
- **Lists:** Gutenberg's own counts, from the catalog, never from scraping its pages.
  - **Top 100 books:** rank each work by its **best single edition's** 30-day downloads. Summing the editions puts a
    magazine of hundreds of issues first.
  - **Top 100 authors:** rank by all their books' downloads, people only.
  - **Each category's top 100:** the `Category: …` bookshelves. A work is in a category when at least half its
    editions are, so a 122-issue magazine doesn't land in every category one issue is shelved in.
- **What's downloaded:** every book heard and the Top 100. Anything else on a list only when the person taps
  **Get it**: the server accepts only a work the lists name, fetches it at once, and the phone saves it. The phone
  keeps every downloaded book, and the 72 category lists alone name about 4,500, too many for a phone.
- **App:**
  - **Lists:** Heard (with mentions, newest first), Top 100, Authors, Categories.
  - **A book's page:** summary, Read / Continue, Search this book, and every moment it was heard, each a tap away in
    the podcast app.
  - **Reader:** serif type, adjustable size, the place kept.
  - **Search:** on the server when online; offline, the device reads through its saved books.

## Rules

- Tailnet only. No public exposure, no Funnel, no port forwarding.
- No API keys. Everything here is free to fetch: public feeds, public-domain files, and open data with its licence
  and attribution followed.
- Only texts that are free to keep. Never download a copyrighted book or Bible "just for personal use".
- Respect each source's terms: Gutenberg's robot policy, STEPBible's request not to redistribute, no YouTube audio,
  and no copy of anything whose terms limit retention.
- The book and Bible scanners open the podcast data **read-only**.
- After a browser test, remove any position, bookmark or plan progress the test wrote; they sync to the person's
  phone.
- Write a README per app with what each source is, its licence, and what was left out and why.

## Acceptance tests

1. **Podcasts, new episodes:** with a new item added to a test feed, the next poll downloads its audio, art,
   chapters (images local) and transcript, and the app lists it.
2. **Podcasts, seeking:** a `Range: bytes=1000-1999` request returns 206 and exactly 1,000 bytes.
3. **Podcasts, search:** a word that appears in a transcript returns that episode with the moment's time. A tap
   starts playback within a second of it.
4. **Podcasts, offline:** with the network cut, a saved episode plays and seeks, and the list, chapters and art
   show.
5. **Podcasts, sync:** set a position on device A, open device B, and B resumes there. The older of two edits never
   overwrites the newer.
6. **Podcasts, chapters heard:** playing over a chapter's end marks it heard; skipping past it doesn't.
7. **Podcasts, deep link:** `#play=<id>&t=600` plays that episode from 10:00.
8. **Bible, alignment:** Psalm 23 opens as Psalm 23 in every translation, including Douay-Rheims and Brenton.
9. **Bible, study aids:**
   - John 3:16's sheet shows the Greek word by word, with `ἠγάπησεν` → G25, "verb · aorist · active · indicative".
   - Psalm 23:1 shows the Hebrew right to left, with the title.
   - JFB's note on John 3:14–16 and Henry's on 3:1–21 show.
10. **Bible, the finder:**
    - finds: "first Corinthians thirteen", "John three sixteen", `Romans 8 28`, `Isaiah 8 -12` (8:12);
    - records nothing for: "Mark said 2 things", "John 17 years ago", "Numbers 3 percent", "Job" in lower case.
11. **Bible, plans:** the M'Cheyne plan covers every verse the designed number of times. Ticking a day sets today's
    date and carries to another device.
12. **Books, catalog:** no book whose rights aren't exactly "Public domain in the USA." is kept. Two editions of
    Frankenstein are one work. A magazine's issues never top the Top 100.
13. **Books, the finder:**
    - finds "Dostoevsky's Crime and Punishment" and "the Brothers Karamazov … I tried to read it";
    - records nothing for "Milton Friedman", "crime and punishment" in lower case, or "play the Scott Simon jingle".
14. **Books, Get it:** POSTing a work on a list fetches it from the mirror and the phone saves it. POSTing any other
    id is refused.
15. **Offline, everywhere:** with the network cut, every app opens, reads and searches. Change one file on the
    server, reconnect, and only that file is downloaded again.
