Metadata-Version: 2.4
Name: easy-podcast
Version: 0.8.0
Summary: A local-first, generic-entity podcast knowledge base.
Author-email: Aryan Falahatpisheh <aryanfalahat@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/falahat/easy-podcast
Project-URL: Repository, https://github.com/falahat/easy-podcast.git
Keywords: podcast,rss,transcript,search,local-first
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Sound/Audio
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: feedparser>=6.0
Requires-Dist: requests>=2.28
Requires-Dist: tailnet-guard>=0.6.0
Requires-Dist: dispatch-kit>=0.1.0
Provides-Extra: transcribe
Requires-Dist: easy-whisperx>=0.1.0; extra == "transcribe"
Provides-Extra: connections
Requires-Dist: sentence-transformers<5,>=2.2; extra == "connections"
Requires-Dist: numpy>=1.24; extra == "connections"
Provides-Extra: web
Requires-Dist: flask>=3.0; extra == "web"
Provides-Extra: sync
Requires-Dist: pynacl>=1.5; extra == "sync"
Requires-Dist: qrcode>=7; extra == "sync"
Provides-Extra: observe
Requires-Dist: opentelemetry-sdk>=1.20; extra == "observe"
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20; extra == "observe"
Requires-Dist: sentry-sdk>=2.0; extra == "observe"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: mypy>=1.0; extra == "dev"
Requires-Dist: black>=23.0; extra == "dev"
Requires-Dist: flake8>=6.0; extra == "dev"
Requires-Dist: types-requests; extra == "dev"
Requires-Dist: numpy>=1.24; extra == "dev"
Requires-Dist: flask>=3.0; extra == "dev"
Requires-Dist: pynacl>=1.5; extra == "dev"
Requires-Dist: qrcode>=7; extra == "dev"
Dynamic: license-file

# easy-podcast

A **local-first, generic-entity podcast knowledge base**. Your data lives in a
folder you own — durable, human-readable, and portable — with full-text search
and a query/graph index on top. Built to grow into transcript search, story
segmentation, and cross-podcast connections (same story, author, voice).

> **Status:** v0.7 — feature-complete. The generic store, RSS ingestion,
> transcription, story segmentation, cross-podcast connections, and a local web
> UI are all implemented and tested (see [What it does](#what-it-does)).
> Everything is local and private — no LLMs, no cloud.

## Quick start (Docker)

The server ships the phone app inside it. Run it **hardened, non-root, exposed
only on `localhost`** — one command:

```bash
docker compose up -d --build      # serves on http://localhost:8765
# populate your library (via the CLI inside the container):
docker compose exec easypod easy-podcast --data-dir /data add <feed-url>
docker compose exec easypod easy-podcast --data-dir /data sync
docker compose exec easypod easy-podcast --data-dir /data download <podcast_id>
```

Open **http://localhost:8765** and "Add to Home Screen" — that's the installable,
offline PWA. To reach it from your **phone**, the deploy stack
([docker-compose.deploy.yml](docker-compose.deploy.yml)) fronts it with a bundled
**Tailscale sidecar** (HTTPS over the tailnet, no open ports); the full runbook is in
[docs/SECURE_DEPLOY.md](docs/SECURE_DEPLOY.md), and a from-scratch, layer-by-layer
explainer is in [docs/SYNC_DEEP_DIVE.md](docs/SYNC_DEEP_DIVE.md).
Prefer to run it directly (no Docker)? See [Install](#install).

## Principles — built to respect creators

This is a personal tool for organizing and exploring podcasts **you** listen to.
It is built to **always respect human authors and human voice actors**:

- **No AI training on anyone's content.** It honors creators who ask not to be
  used for AI — their feeds are excluded from examples and tests.
- **No LLMs.** Transcription (via whisperx) and all analysis run locally and
  privately; nothing leaves your machine.
- **Identify and *credit*, never imitate.** The "connections" features detect
  and link human authors and voice actors across episodes in order to *credit*
  their work — never to clone, synthesize, or impersonate a voice or an
  author's writing.
- **Polite by default.** Downloads are rate-limited, use conditional requests,
  and respect server back-off, so the app never hammers anyone's servers.

## Design in one breath

Truth is a **Ledger**: one JSON file per entity under `docs/<kind>/`, written
atomically. A **Lens** (an embedded SQLite database, `lens.sqlite`) is a
*derived, disposable* index over the Ledger providing exact-match queries,
full-text search, and referential edges. Delete the Lens and rebuild it from
the Ledger at any time. The store is **domain-agnostic** — any frozen-dataclass
`Entity` is stored and queried through one mechanism.

```python
from easy_podcast import Store, Podcast, Episode, DEFAULT_SPECS

store = Store.open("~/PodcastLibrary")          # the only storage decision; back up = copy the folder
for spec in DEFAULT_SPECS:
    store.register(spec)

show = Podcast.create("https://feeds.example/magnus-archives", title="The Magnus Archives")
store.repo(Podcast).put(show)

store.repo(Episode).put(Episode(
    id=Episode.id_for("guid-42", "https://cdn/ep42.mp3"),
    podcast_id=show.id, title="The Lighthouse", description="A keeper vanishes.",
    audio_url="https://cdn/ep42.mp3",
))

# exact-match query on a promoted field, and full-text search
store.repo(Episode).find(podcast_id=show.id)
for hit in store.search("lighthouse keeper"):
    print(hit.ref, hit.snippet)
```

### Adding a new entity type is a dataclass + one line

```python
from dataclasses import dataclass
from typing import ClassVar
from easy_podcast import Entity, EntitySpec

@dataclass(frozen=True)
class Bookmark(Entity):
    kind: ClassVar[str] = "bookmark"
    episode_id: str = ""
    note: str = ""
    t: float = 0.0

store.register(EntitySpec(Bookmark, promote=["episode_id"], fts=["note"]))
store.repo(Bookmark).put(Bookmark(id="b1", episode_id=ep.id, note="great twist", t=842.0))
```

No new persistence module, no schema migration.

## What it does

The core is pure-Python (feedparser + requests) and the intelligence layers are
**optional extras** you add only if you want them. Nothing uses an LLM; the ML is
local transcription and small local embeddings.

| Capability | Module | Extra |
|---|---|---|
| Generic store (Ledger + Lens), atomic writes, FTS, edges | `store/` | core |
| RSS ingestion: fault-tolerant parse, **self-throttling** verified download, content-based sync, SSRF/decompression-bomb defenses | `ingest/` | core |
| Transcription with **word-level timestamps** (whisperx align) + optional speaker diarization | `pipelines/transcribe.py` | `transcribe` |
| Creator-authored **chapters** (Podcasting 2.0 `podcast:chapters`) | `pipelines/chapters.py` | core |
| **Story segmentation** of anthology episodes + author/narrator extraction (heuristics, no LLM) | `pipelines/segment.py` | `transcribe` |
| Local **text + voice embeddings** → cross-podcast **connections** (same story / author / transcript / voice) | `pipelines/{embed,connect}.py` | `connections` |
| Full-text + transcript **search** | `store/lens.py` (FTS5) | core |
| Local **web UI** (localhost-only, CSRF-guarded) | `web/` | `web` |

## Install

```bash
pip install -e ".[dev]"          # core: feedparser, requests (pure-python, 3.10–3.13)
pip install -e ".[transcribe]"   # whisperx transcription/diarization (needs Python 3.10–3.12)
pip install -e ".[connections]"  # local embeddings + connection-finding (sentence-transformers)
pip install -e ".[web]"          # local Flask web UI
```

Transcription needs the `ffmpeg` binary on `PATH`. Speaker diarization
additionally needs a HuggingFace token (`--hf-token` or `HF_TOKEN`).

## Command line

```bash
easy-podcast add https://rss.acast.com/themagnusarchives   # subscribe
easy-podcast sync                                          # fetch new episodes
easy-podcast download <podcast_id>                         # polite, throttled
easy-podcast transcribe <episode_id> --model base          # + --diarize (needs HF token)
easy-podcast chapters <episode_id>                         # creator chapters, if published
easy-podcast segment <episode_id>                          # split into stories + credits
easy-podcast embed <episode_id>                            # local story embeddings
easy-podcast connections                                   # find cross-episode links
easy-podcast search "lighthouse keeper"                    # full-text + transcript
easy-podcast serve                                         # local web UI on 127.0.0.1:8000
```

The web UI binds to `127.0.0.1` **only** — there is intentionally no option to
bind a public address, and the app refuses any non-loopback client, so it can't
be exposed to a network by accident. The UI is unauthenticated by design; to
reach it from another device (e.g. your phone), put a proxy that adds auth + TLS
in front of the loopback port — [`tailscale serve <port>`](https://tailscale.com/kb/1242/tailscale-serve)
is the easy, private option (only your own enrolled devices can connect).

## Listen on your phone

The phone app is a **PWA** — no app store, no native build. Your library lives
*on the phone* (offline-capable); the home computer keeps the MP3s and does the
heavy ML. They sync over an authenticated, encrypted channel, paired once by
scanning a QR. **The built PWA ships inside the package**, so the server serves it
with no Node toolchain on the user's side.

**1 — set up the server**

```bash
pip install -e ".[sync]"        # adds the sync server (pynacl + qrcode)
easy-podcast add <feed-url>     # subscribe, then `sync` / `download` as usual
```

**2 — run the sync server.** It binds **loopback by default** — safe, invisible to
the network. Reaching the phone is an explicit opt-in; pick a mode:

```bash
easy-podcast sync-serve                          # 127.0.0.1 — same machine only (default)
easy-podcast sync-serve --host tailscale \       # off-box: over a private Tailscale net —
    --allow-peer <phone-tailscale-ip>            #   no open port on Wi-Fi, reachable anywhere
```

Add `--tls-cert/--tls-key/--tls-host` (a free Tailscale cert) to serve **HTTPS** —
the secure context the phone needs to install it as a real **offline** PWA. The
full step-by-step runbook (Tailscale, a least-privilege jail, certs) is
**[docs/SECURE_DEPLOY.md](docs/SECURE_DEPLOY.md)**.

**3 — pair the phone.** `easy-podcast pair "my phone"` prints a QR (and a paste-able
`{"secret":…}` line). On the phone, open the server URL and paste that line — or scan
the QR with your camera and paste what it decodes — then tap **Connect**. The app
pulls your podcasts/transcripts, fetches audio on demand, plays with lock-screen
controls, and "Add to Home Screen" makes it standalone. Pairing a device (or
revoking one with `unpair`) takes effect on a running server with no restart.

### Why it's safe (and why an "open port" isn't an exposure)

- **Local/overlay clients only**, fail-closed: the real TCP peer **and** the `Host`
  header must be loopback / private / your-tailnet — never an arbitrary domain
  (closing DNS-rebinding). `--allow-peer` pins it to one exact device, and every
  refusal is logged.
- **Paired devices only.** Every request is sealed under the QR secret
  (authenticated encryption, direction-bound, replay-deduped); the secret is shown
  as a QR and **never crosses the wire**. Unpaired → flat `403`.
- **No discoverable port.** Bound to loopback (unreachable) or the Tailscale
  overlay (the app port lives on a *virtual* interface nothing physical can
  address; WireGuard's own socket is silent to non-peers). A scan finds nothing.

A from-scratch, layer-by-layer walkthrough — the protocol, the networking, and a
full request trace down to the physical layer — is in
**[docs/SYNC_DEEP_DIVE.md](docs/SYNC_DEEP_DIVE.md)**.

Deliberate limits (so it doesn't overclaim): encryption is at the **message
layer**, with **no forward secrecy** (the pairing secret is long-lived but never
crosses the wire); the `/audio` body leans on the transport (WireGuard/TLS) for
confidentiality; a lost **unlocked** device is out of scope.

### Developing the phone app

The PWA is a **SolidJS "kernel + one folder per feature"** architecture — adding a
feature means dropping a self-registering folder under `pwa/src/features/`, editing
no shared file. To build it yourself (a built copy already ships in the package):
`cd pwa && npm install && npm run build` compiles straight into
`src/easy_podcast/sync/_webapp`; `npm run e2e` drives a real sync end-to-end.
Three docs:

- **[pwa/README.md](pwa/README.md)** — start here: how the code works, how to **add a
  feature in one folder**, and how to **test it** (typecheck → Vitest → Node e2e →
  browser smoke).
- **[docs/PWA_ARCHITECTURE.md](docs/PWA_ARCHITECTURE.md)** — the deep design ("Ports &
  SolidJS"): the kernel, the Solid ownership model, every contract, the footguns.
- **[docs/PWA_FEATURE_CATALOG.md](docs/PWA_FEATURE_CATALOG.md)** — the ~430-feature
  backlog with IDs, a dependency map, and the build waves.

## Roadmap — delivered

| Phase | Scope | |
|---|---|---|
| 0 | Generic store (Ledger + Lens), atomic writes, FTS, edges, models | ✅ |
| 1 | RSS ingestion (fault-tolerant parse, **rate-limited** verified download, content-based sync) + CLI | ✅ |
| 2 | Transcription via easy-whisperx; transcript full-text search | ✅ |
| 3 | Story segmentation (anthology-aware) + author/narrator extraction — **no LLMs** | ✅ |
| 4 | Text + voice embeddings; cross-podcast connections (same story / author / transcript / voice) | ✅ |
| 5 | Local web app | ✅ |
| 6 | Phone PWA (local-first replica) + authenticated LAN sync — secure by construction | ✅ |
| 7 | PWA feature platform — "Ports & SolidJS" kernel + one-folder-per-feature; features in progress | 🚧 |

## License

MIT — see [LICENSE](LICENSE).
