hetchyy's picture
deploy: inspector prod @ 78d0569
6b633e6 verified
|
Raw
History Blame Contribute Delete
30 kB
# Catalog Reference
> **Storage**: the catalog is **SQLite** (`inspector.db`, tables read/written via `inspector/services/db/repo_catalog.py`), uploaded whole-file to the bucket after each committed write (`services/db/sync.py`). It was `catalog/reciter_catalog.json` pre-migration; that JSON backup (`storage_paths.catalog_path()`) is now **dead — no app reads it** (the DB is the catalog source of truth; the whole-bucket validator deliberately skips it) and is **slated for removal**. **Audio metadata sidecars stay JSON on the bucket** at `catalog/audio_manifest/<slug>.json` (`storage_paths.audio_manifest_path`), read via `services/audio/audio_meta.py`.
Layers, slug convention, schema, audio-metadata split, naming guide, and add/probe/maintain procedures.
## 1. Where things live
| Concern | Location | Writer |
|---|---|---|
| vocab / reciters / deliveries / aliases / derived | SQLite tables (see §4 col map) | `repo_catalog.py` (via `services/state/catalog.py`) |
| Read model (`ReciterCatalog`) | `repo_catalog.snapshot()` → cached on `db_seq` by `services/state/catalog.py::snapshot()` | — |
| Pydantic shapes (runtime authority) | `qua_shared/schemas/bucket/catalog.py` | — |
| Per-delivery audio sidecar | `catalog/audio_manifest/<slug>.json` (bucket JSON) | offline probe `scripts/audio/probe_audio_meta.py` |
| Public read endpoint | `/api/static/catalog.json` (`routes/public/static.py`) — serializes `snapshot()` | — |
`repo_catalog.snapshot()` reassembles the full `ReciterCatalog` so the public JSON stays byte-identical to the pre-migration file (migration parity gate diffs `model_dump(by_alias=True)`). Sidecars are 5–20 KB each for `by_surah` (114 chapters), larger for `by_ayah` (6236 ayahs).
## 2. The three layers
| Layer | Identity question | Where it lives |
|---|---|---|
| **Reciter** | Who is the human? | `reciters` table row, keyed by `reciter_id` |
| **Recording** | Which artistic act? *(implicit — group-by, not stored)* | computed over `deliveries` |
| **Delivery** | Which file did we actually fetch? | `deliveries` table row, keyed by `slug` |
A "recording" — same reciter performing the same style+riwayah+year — is **not** a stored entity. It's a group-by:
```
recording = (reciter_id, riwayah, style, recording_year, variant_label)
```
Sibling deliveries of the same recording (e.g. mp3quran + qul both serving Mishary's 2008 Hafs Murattal) are discovered with `GROUP BY` over `deliveries`. No `recording_id` middle layer — denormalizing year/variant across CDN-rows is cheap and removes a foreign key.
### Source × Channel are orthogonal
- **`source`** = the website we found the audio on (mp3quran.net, everyayah.com, qul.tarteel.ai, surahquran.com, quranicaudio.com, tvquran.com).
- **`channel`** = the host actually serving the bytes (`server*.mp3quran.net`, `download.quranicaudio.com`, `audio-cdn.tarteel.ai`, `download.tvquran.com`, `everyayah.com`, `*.archive.org`).
Relationship is M:N. One source can route through multiple channels (qul → both `tarteel` and `quranicaudio`); one channel can serve multiple sources (`quranicaudio` channel serves both `quranicaudio` and `qul`; `archive_org` serves `surah-quran`).
The `(source, channel)` pair on each delivery row is **authoritative** — vocab stores neither direction of the mapping. The materialized rollup `derived.source_channels[]` caches it (refreshed by `repo_catalog.refresh_derived()` on every delivery add).
**CDN is the primary dedup axis.** Same `(reciter, riwayah, style, channel)` is kept as separate deliveries when bitrate, sample rate, channels, or duration differ — different masters. Within-tuple disambiguators on the slug: `_<bitrate>k` when bitrates differ, `_v2` otherwise (§3).
### Download-only sources (YouTube / yt-dlp)
Some audio isn't a CDN of direct `.mp3` URLs — it's a **playlist** on YouTube (or any yt-dlp-supported host: SoundCloud, archive.org, Spreaker). These map onto the same model:
- **`channel`** = `youtube` (the host). Surfaces in the public dashboard filters like any channel. Added on first ingest via `vocab_additions` (idempotent) — not a migration, since channel vocab is data, not schema; the canonical definition lives in `BUILTIN_CHANNELS` (`.local/extraction/intake/channel_match.py`).
- **`source`** = the *uploader* (per-uploader provenance, e.g. a specific YouTube channel). **Not** surfaced in public filters (there is no `source` filter axis — only `channel`); supplied per-request at ingest (`--source` + `--proposed`).
- **`source_url`** = the originating playlist URL, stored on the delivery row (§4); the reciter-detail modal renders the channel cell as a hyperlink to it.
Download-only deliveries are **bucket-served only**: their per-chapter `audio/<ch>.mp3` is created during extraction (YouTube serves opus/m4a, never mp3 — see §5). The watch-page URL in the sidecar is provenance, not a streamable fallback — the audio-proxy never streams it; playback always uses the persisted bucket mp3.
## 3. Slug convention
```
<reciter_id>[_<riwayah_short>][_<style_short>][_<year>]_<channel_short>[_<disambiguator>]
```
Fixed ordering (left to right): reciter, riwayah, style, year, channel, disambiguator.
**Regex:** `SLUG_RE` = `^[a-z][a-z0-9_]{1,79}$` (`qua_shared/schemas/config/state.py`). ASCII lowercase, single underscores. No code parses the slug — opaque human-readable ID. `reciter_id` and delivery `slug` both use this regex (`ReciterEntry`, `Delivery` validators). Source slug is the exception: `SOURCE_SLUG_RE` = `^[a-z][a-z0-9_-]{1,79}$` allows hyphens (`surah-quran`).
**Suffix rules:**
| Component | Omit when | Source |
|---|---|---|
| `riwayah_short` | `hafs` (Hafs an Asim, the default) | `riwayahs.short` |
| `style_short` | `murattal` (the default) | `styles.short` |
| `year` | not known — never invent | row's `recording_year` |
| `channel_short` | **never** — mandatory | `channels.short` |
| `disambiguator` | only when same `(reciter, riwayah, style, year, channel)` has >1 delivery | derived at seed |
**Disambiguator forms:**
- `_<bitrate>k` — colliding deliveries differ by bitrate, e.g. `mahmoud_khalil_al_husary_qdc_64k` vs `..._qdc_128k`.
- `_v2`, `_v3`, … — fallback when bitrates match (different masters, same encoding); extend the chain when 3+ collide.
- `_byayah` — appended **after** the channel for a `by_ayah` delivery: `ahmad_al_nufais_tarteel_byayah`. Category marker, not a within-tuple disambiguator; stays last so left-to-right ordering holds for everything before it.
**Worked examples:**
| Source manifest | Slug |
|---|---|
| `mp3quran/abdulbasit_abdulsamad.json` | `abdulbasit_abdulsamad_mp3quran` |
| `qul/abdulbasit_abdulsamad.json` (→ tarteel) | `abdulbasit_abdulsamad_tarteel` |
| `qul/abdulbasit_abdulsamad_qdc.json` | `abdulbasit_abdulsamad_qdc` |
| `qul/abdulbasit_abdulsamad_mujawwad_qdc.json` | `abdulbasit_abdulsamad_mujawwad_qdc` |
| `mp3quran/abdelaziz_sheim_warsh.json` | `abdelaziz_sheim_warsh_mp3quran` |
| `surah-quran/abbadi_houssem_eddine.json` (→ archive.org) | `abbadi_houssem_eddine_archive` |
| `by_ayah/qul/ahmad_al_nufais.json` (tarteel, by_surah sibling exists) | `ahmad_al_nufais_tarteel_byayah` |
Renames are free for a bare catalog row — URLs are preserved per-delivery in the sidecar; nothing about the audio depends on the slug. **But once a lifecycle state row exists** (`delivery_states`/`transitions`, `reciters/<slug>/`, the audio manifest sidecar all key on the slug), a rename becomes a real cross-table migration. This is why slug minting for admin-dashboard **intake** contributions is deferred to the offline ingest pipeline (`services/admin/intake.py` accept records only the owner's canonical `reciter_id`): the slug's mandatory `channel_short` suffix needs the channel, which is only known after the audio is fetched + probed. Minting the slug at ingest — when source/channel/bitrate are all known — gets it right on the first try rather than guessing at accept and migrating later.
## 4. Schema
> **Authority**: the pydantic models at `qua_shared/schemas/bucket/catalog.py` are the runtime authority (cross-consumer: Inspector, training pipeline, dataset builder, GH Actions). FE types are codegen'd from them — never hand-edit `inspector/frontend/src/lib/types/generated/schemas.ts`. The tables below map pydantic field ↔ SQLite column.
> **Null convention**: any `<type> | null` field accepts `null` = "missing / not yet identified". Never `""` or `"unknown"` sentinels. Applies to `name_ar`, `country`, `recording_context`, `recording_year`, `variant_label`, and all audio-metadata fields.
DDL: `inspector/services/db/migrations/0001_init.sql`. Datetimes are TEXT ISO-8601 UTC; JSON-valued columns (`audio_categories`, `host_patterns`, `derived`) are TEXT (orjson). FKs enforced by SQLite; uniqueness + cross-table FK integrity also re-validated by `ReciterCatalog`'s `_check_unique_keys` model validator on snapshot.
### Vocab — `riwayahs` / `styles` (`_ShortNamed`)
| Column | Type | Notes |
|---|---|---|
| `slug` | TEXT PK | e.g. `hafs_an_asim`. Immutable. |
| `short` | TEXT | Used in delivery slugs, e.g. `hafs`. |
| `name` | TEXT | Display, e.g. `Hafs A'n Assem`. |
`styles` values: `murattal` / `mujawwad` / `muallim` / `children_repeat` / `hadr`. Legacy `taraweeh` style was migrated to `style: "murattal" + recording_context: "taraweeh"` — the `taraweeh` style slug is gone from vocab. `hadr` = fast-paced, less ornamented than murattal.
### Vocab — `sources` (`Source`)
| Column | Type | Notes |
|---|---|---|
| `slug` | TEXT PK | `mp3quran`, `qul`, `surah-quran` (hyphen allowed in source slug only). |
| `name` | TEXT | Display. |
| `url` | TEXT \| null | Website root. |
| `audio_categories` | TEXT (JSON `string[]`) | Subset of `["by_surah", "by_ayah"]` (`AudioCategory` enum). |
### Vocab — `channels` (`Channel`)
| Column | Type | Notes |
|---|---|---|
| `slug` | TEXT PK | `mp3quran`, `quranicaudio`, `archive_org`. |
| `short` | TEXT | Used in delivery slugs, e.g. `qdc` for `quranicaudio`, `archive` for `archive_org`. |
| `name` | TEXT | Display. |
| `host_patterns` | TEXT (JSON `string[]`) | Globs matched against URL hostnames at ingestion. |
### Vocab — `recording_contexts` (`RecordingContext`)
| Column | Type | Notes |
|---|---|---|
| `slug` | TEXT PK | `Literal["studio","broadcast","prayer","taraweeh","mixed"]`. |
| `name` | TEXT | Display. |
Orthogonal to `style` (pace/ornamentation). Semantics: `studio` = deliberate studio recording; `broadcast` = radio (iza'a)/TV; `prayer` = live non-Taraweeh prayer (Fajr, Qiyam, …); `taraweeh` = Ramadan night prayers (split from `prayer` for volume + annual cadence); `mixed` = single delivery spanning multiple contexts. Delivery's `recording_context` is **nullable** — `null` = not yet classified (default for seed rows). No `"unknown"` sentinel.
### `reciters` (`ReciterEntry`)
| Column | Type | Notes |
|---|---|---|
| `reciter_id` | TEXT PK | Slug-form (`SLUG_RE`), e.g. `mahmoud_khalil_al_husary`. Immutable. |
| `name_en` | TEXT (≥1) | Canonical display. §6 naming guide. |
| `name_ar` | TEXT \| null | Arabic; `null` if unknown. |
| `country` | TEXT \| null | ISO-2 or `null`. |
| `notes` | TEXT \| null | Free-form, ≤500 chars. |
Editable columns: `_RECITER_WRITABLE = (name_en, name_ar, country, notes)` (`repo_catalog.py`). `reciter_id` is immutable.
### `deliveries` (`Delivery`)
| Column | Type | Notes |
|---|---|---|
| `slug` | TEXT PK | Per §3. |
| `reciter_id` | TEXT FK→`reciters` | |
| `riwayah` | TEXT FK→`riwayahs.slug` | |
| `style` | TEXT FK→`styles.slug` | Pace/ornamentation only. |
| `source` | TEXT FK→`sources.slug` | |
| `channel` | TEXT FK→`channels.slug` | |
| `source_url` | TEXT \| null | Originating playlist/set URL for download-only sources (YouTube playlist, SoundCloud set). `null` for CDN deliveries — their per-chapter URLs live in the sidecar. Surfaced on `PublicDelivery`; the reciter-detail channel cell hyperlinks to it. |
| `recording_context` | TEXT \| null FK→`recording_contexts.slug` | `null` when unclassified. Orthogonal to style. |
| `recording_year` | INTEGER \| null | 4-digit Hijri or CE; null when unknown. |
| `variant_label` | TEXT \| null | Free token (`madinah`, `studio_2008`); rare. |
| `audio_category` | TEXT | `by_surah` \| `by_ayah` (`AudioCategory`). |
| `chapter_count` | INTEGER (≥0) | URL count. Full mushaf = 114 (by_surah) or 6236 (by_ayah); partial coverage lower. |
| `codec` | TEXT | `mp3`. |
| `container` | TEXT | `mp3`. |
| `sample_rate_hz` | INTEGER \| null | Probed; null for unprobed by_ayah. |
| `channels` | INTEGER \| null | 1 (mono) or 2 (stereo); `ge=1, le=2`. |
| `bitrate_mode` | TEXT | `BitrateMode` enum (§5). Default `unknown`. |
| `bitrate_kbps_nominal` | INTEGER \| null | CBR exact / VBR avg / ABR target. **`null` when `bitrate_mode == "mixed"`** (model validator enforces). |
| `total_duration_sec` | INTEGER \| null | Whole-mushaf seconds. |
| `added_at` | TEXT (datetime) | ISO-8601 UTC. |
| `added_by_hf_id` | TEXT | HF user id of the maintainer who added the row. |
Editable columns: `_DELIVERY_WRITABLE` (`repo_catalog.py`) covers riwayah/style/recording_context/recording_year/variant_label/source/channel/source_url/audio_category/chapter_count/codec/container/sample_rate_hz/channels/bitrate_mode/bitrate_kbps_nominal/total_duration_sec. The **service** (`services/state/catalog.py::edit_delivery`) exposes a narrower public surface: only `riwayah/style/recording_context/recording_year`. `slug`/`reciter_id` immutable. Checksum does **not** live on the row (sidecar `_meta.checksum` only).
### `audio_manifest/<slug>.json` (bucket sidecar — `AudioManifestSidecar`)
```jsonc
{
"schema_version": 1,
"slug": "<delivery slug>",
"_meta": { // SidecarMeta (alias "_meta")
"checksum": "sha256:<hex>", // sole storage; row carries no copy
"source_meta_reciter": "<original>", // migration/audit
"source_manifest_path": "data/...", // original ingestion path
"chapter_count": 114,
"category": "by_surah"
},
"chapters": { // dict[str, ChapterEntry]
"1": { "url": "https://...", "size_bytes": 803456, "duration_sec": 50, "bitrate_kbps": 128, "bitrate_mode": "cbr", "max_linear_seek_err_ms": 26 }
}
}
```
Chapter keys: `"1"`–`"114"` (by_surah) or `"<surah>:<ayah>"` (by_ayah). Per-chapter metric fields nullable until probed. `ChapterEntry` fields: `url` (required), `size_bytes`, `duration_sec`, `bitrate_kbps`, `bitrate_mode` (`cbr`/`vbr` per chapter), and `max_linear_seek_err_ms` (probe verdict evidence).
`AudioManifestSidecar` (`qua_shared/schemas/bucket/catalog.py`) is a bucket artefact with pure `extra="forbid"`: any unknown/legacy sidecar field raises `ValidationError` on parse (writer-drift signal), never silently stripped — the same external-file strictness the bucket-validation harness surfaces.
**Checksum** (`_meta.checksum`): `sha256(normalized_urls_sorted.joined_by_newline)` at build time. Normalization: lowercase hostname, strip trailing slashes, drop fragment; query order + path case preserved (CDN-sensitive). Lives only in the sidecar; re-probe jobs compute + compare.
`audio_meta.py` reads sidecars via `get_backend().read_json(audio_manifest_path(slug))` and caches each into LRU slots (`_audio_manifest` doc + `_audio_manifest_url_index` URL→chapter inverse). Powers VBR routing (`is_vbr`), audio-proxy short-circuit (`chapter_for_url`), the `/peaks` route's URL enumeration (`chapter_urls`), and FE warmup hints (`vbr_chapters_for_reciter`, `chapter_bitrate_kbps_for_reciter`).
### `catalog_aliases` (`Alias`)
Empty in seed. Reserved for slug/reciter_id renames. Columns: `kind` (`slug`|`reciter_id`), `old`, `new`, `ts`. Insert via `repo_catalog.insert_alias`.
### `catalog_meta` + `derived` (`Derived`)
Single row `id=1`: `schema_version` (2), `generated_at`, `derived` (TEXT JSON). `derived.source_channels[]` = M:N rollup `{source, channel, delivery_count}` per observed pair, **persisted** verbatim (not reconstructed) for parity; recomputed by `repo_catalog.refresh_derived()` whenever the delivery set changes (deterministic order `(source, channel)`).
## 5. Audio metadata: row vs sidecar
| Field | Uniform across chapters? | Lives in |
|---|---|---|
| `codec`, `container`, `sample_rate_hz`, `channels`, `bitrate_mode` | yes (encoder settings) | `deliveries` row |
| `bitrate_kbps_nominal` (CBR exact; VBR avg) | yes (label) | row |
| `total_duration_sec` (whole mushaf) | yes (aggregate) | row |
| `bitrate_kbps` (measured per chapter) | no (varies for VBR) | sidecar |
| `duration_sec` / `size_bytes` / `url` (per chapter) | no | sidecar |
### CBR vs VBR
Row-level `bitrate_mode` (`BitrateMode` enum) derived from per-chapter probe data:
Enum collapsed to four values by migration 0014 (legacy `mostly_cbr`/`mostly_vbr`/`abr` folded into `mixed`):
| Value | Meaning |
|---|---|
| `cbr` | All probed chapters CBR. `bitrate_kbps_nominal` = exact. |
| `vbr` | All probed VBR. `bitrate_kbps_nominal` = target / observed average. |
| `mixed` | Chapters disagree (any CBR+VBR within the delivery, or differing CBR rates). Use sidecar for per-chapter truth. **`bitrate_kbps_nominal` is null** (model validator enforces). |
| `unknown` | No chapters probed yet (default for by_ayah). |
Per-chapter `ChapterEntry.bitrate_mode` (sidecar) is `cbr`/`vbr` only — the `mixed` collapse is a row-level rollup over chapters that individually are one or the other.
Detection — **whole-file linear byte→time seek error** (`qua_shared/mp3_frames.py::classify_bitrate_mode`, mirrored inline in `probe_audio_meta.py`): walk every audio frame, measure how far a browser's linear `time→byte` seek would land from each frame's true time; `≤ 200 ms``cbr`, else `vbr`. This is the only reliable signal — it is exactly what the playback transport needs to know (can the browser seek this natively?). The two shortcuts this replaced both produced systematic mislabels: mutagen's header `bitrate_mode` calls every Xing-tagged file VBR (incl. CBR audio our pipeline tagged Xing) and `len(set(bitrates))==1` / head-only uniformity is fooled by a stray frame or by VBR whose variation starts past the head (whole tvquran/archive reciters slipped through as CBR and stalled on seek). Because the metric needs every frame, source probing (`probe_audio_meta.classify`, `mp3_probe.probe_source(allow_full=True)`) downloads the full file. Per-chapter results land in the sidecar, rolled up to the row at build.
**Download-only sources create their own encode.** Unlike CDN audio (publisher mp3 bytes preserved verbatim, with only a Xing seek header injected via `-c:a copy`), a YouTube/yt-dlp source is opus/m4a — so extraction produces the canonical mp3 once: **192 kbps CBR, 44.1 kHz, mono**, cover-art stripped (`segments/audio_io.py::_download_via_ytdlp`). Because the watch URL can't be HTTP-frame-probed, the row + sidecar audio fields come from a **post-align reprobe** of the produced files (`ingest_intake.py::reprobe_persisted_audio`). Forced-CBR ⇒ these deliveries never hit the VBR playback path.
### Style vs recording_context
- **`style`** = pace/ornamentation: `murattal` / `mujawwad` / `muallim` / `children_repeat` / `hadr`.
- **`recording_context`** = why the recording exists: `studio` / `broadcast` / `prayer` / `taraweeh` / `mixed`; `null` when not identified.
Two independent axes — "what kind of recitation, and where from?" The `taraweeh` style slug is removed from vocab; new taraweeh entries pick `style: "murattal"` + `recording_context: "taraweeh"`.
## 6. Naming style guide
New `name_en` entries must follow these (applied at seed).
| Axis | Rule |
|---|---|
| `Al-` prefix | `Al-<Surname>` (capital A, hyphen). Never `AlSurname`, `al-surname`, `Al Surname`. |
| Egyptian `El-` | **Preserve** when source-attested (Ahmed El-Shafei, Hamza El-Far). Regional signal. |
| Sun-letter (`Az-`, `Ath-`, `As-`) | Preserve where attested. Don't retroactively introduce — `Al-Sudais` stays. |
| `Abdul-` / `Abdel-` | **Glued**: `Abdulrahman`, `Abdelaziz`. Never spaced/hyphenated. |
| First-name canonical spellings | `Mohammed` (not Muhammad/Mohamed/Mohammad), `Ahmed` (not Ahmad/Ahmet), `Yusuf` (not Yousef/Youssef/Yousif), `Mahmoud` (not Mahmood). Also `Khalid`←Khaled, `Mansour`←Mansoor, `Mishary`←Mishari/Meshari, `Adel`←Adil, `Khalil`←Khaleel, `Yasser`←Yassir, `Mustafa`←Mostafa, `Hisham`←Hesham, `Saeed`←Sayeed, `Tawfiq`←Tawfeeq. Maintainer override: canonical normalization is deliberate (a prior report recommended against it to preserve regional signal — overridden). |
| `bin` patronymic | Lowercase between names: `Ahmed Talib bin Humaid`. |
| Honorifics | **Strip** (Sheikh, Sheik, Shaik, Qari, Hafiz). One seed exception (`Ustaz Zamri`). |
| Numbers in names | Only Madinah/Makkah Taraweeh series years (`1437H`). |
| Apostrophes | Straight ASCII `'` (U+0027). |
| Compound-word casing | Lowercase after first letter (`Abdulmajid`, never `AbdulMajid`). |
| Slug mirrors name | `Al-X` → `al_x` (e.g. `mahmoud_khalil_al_husary`). |
`name_ar` is **preserved as-source** — no Arabic normalization beyond emptying a literal `"unknown"` to `null`.
## 7. Seed origin (historical)
The seed catalog (864 deliveries, 422 reciter clusters, 0 slug collisions) was built one-time by a dedup + probe pipeline whose scratch artifacts lived in `.local/dedup/` (gitignored): dual-agent name clustering, same-channel ffprobe duplicate detection, naming-consistency + canonical-spelling normalization passes, then a bulk 256 KB-range audio probe and a `build_catalog.py` assembly to `reciter_catalog.json` + sidecars. That JSON was later migrated into SQLite (`scripts/migrations/migrate_json_to_sqlite.py`). The `.local/dedup/` artifacts are historical scratch — not load-bearing. Seed rows carry `added_at = 2026-05-12T00:00:00Z`, `added_by_hf_id = system_seed`.
## 8. Mutation surface
All catalog writes go through `inspector/services/state/catalog.py` (sole writer). Each mutation requires `_require_maintainer(actor)` (MAINTAINER or OWNER → else `NotAuthorizedForCatalog`), runs inside `db.sync.durable_transaction()` (atomic with its audit row; nesting-safe for the accept flow), and appends a `catalog.*` audit event.
| Service fn | Repo fn | Audit event | Notes |
|---|---|---|---|
| `add_reciter` | `repo_catalog.add_reciter` | `catalog.added` (kind=reciter) | `Duplicate``InvalidCatalogChange`. |
| `edit_reciter` | `repo_catalog.edit_reciter` | `catalog.edited` (kind=reciter) | Patch computed before txn; no-change → no I/O. `reciter_id` immutable. |
| `add_delivery` | `repo_catalog.add_delivery` | `catalog.added` (kind=delivery) | Refreshes `derived`. FK/`Duplicate``InvalidCatalogChange`. |
| `edit_delivery` | `repo_catalog.edit_delivery` | `catalog.edited` (kind=delivery) | Public surface = riwayah/style/recording_context/recording_year only. FK violation → `InvalidCatalogChange`. |
| `add_audio_source` | `repo_catalog.add_source` | `catalog.audio_source_added` | New vocab source. |
Audit `patch` shape `{field: {from, to}}` is computed in the service; the repo persists only. Reads: `snapshot()` (cached on `db_seq`), `find_delivery`, `find_reciter`, `display_name`.
**Public-projection edits stamp release staleness.** `edit_delivery` / `edit_reciter` carry `PUBLIC_DELIVERY_FIELDS` / `PUBLIC_RECITER_FIELDS` — the catalog fields that surface in the HF `mushafs` catalog + GH release `catalog.json`. When a changed field is in that set, the service stamps the affected published `hf`/`gh` rows `catalog_edit`-stale (`repo_releases.stamp_stale` / `stamp_stale_for_reciter`, reciter edits fanning out to every delivery) inside the same txn — so the Releases tab surfaces the drift and offers the cheap catalog refresh. Admin-only fields (`notes` / `variant_label` / `source`) never stamp. → [`dataset-and-releases.md`](dataset-and-releases.md).
## 9. Adding a single reciter / delivery
1. **Check for collision.** `find_reciter(reciter_id)` and scan `name_en`/`name_ar` variants. If the reciter exists, you're adding a *delivery* — skip to step 3.
2. **Add the reciter** via `catalog.add_reciter(actor=…, reciter_id=…, name_en=…, …)`. Pick `reciter_id` per `SLUG_RE` + §6.
3. **Decide the delivery shape** — riwayah, style, recording_year (if known), source (must exist in `sources`; `add_audio_source` first if not), channel (must exist in `channels`; §10 to add).
4. **Compute the slug** (§3); apply disambiguator rules on collision.
5. **Probe** at least chapter 1 (`scripts/audio/probe_audio_meta.py`) → `bitrate_mode`, `bitrate_kbps_nominal`, `sample_rate_hz`, `channels`. `codec`/`container` = `mp3`.
6. **Add the delivery** via `catalog.add_delivery(actor=…, delivery=Delivery(...))` (constructs + validates the pydantic model; FK + duplicate enforced at insert; `derived` auto-refreshed; row + audit committed durably to the bucket DB).
7. **Generate the sidecar** at `catalog/audio_manifest/<slug>.json` (URL map; per-chapter metrics may be null until a later probe). Checksum → `_meta.checksum`, never the row.
There is no separate validate-then-rebuild step: the SQLite FKs + `Delivery`/`ReciterCatalog` validators enforce integrity at write time, and `sync` uploads the DB after the committed txn.
## 10. Adding a source / channel
**New source** (`vocab.sources`): `catalog.add_audio_source(actor=…, source=Source(slug=…, name=…, url=…, audio_categories=[…]))`. Hyphens permitted in source slug only.
**New channel** (`vocab.channels`) — needed when bytes come from an unregistered host. No service endpoint today; insert via `repo_catalog.load_vocab` / a migration:
1. Identify the host pattern — most specific glob without false positives (`download.example.com`, not `*.example.com`).
2. Slug: lowercase ASCII + underscores, 2–40 chars, `<domain_root>` or `<host>_<role>` (`archive_org`).
3. `short`: ≤10 chars, mnemonic, used in delivery slugs.
4. Verify the probe's host→channel mapping picks up the new pattern.
**Channel host migration** (CDN relocates, e.g. `download.tvquran.com``cdn.tvquran.net`): add the new pattern to `channels.host_patterns` alongside the old (keep old for historical sidecars); re-scrape affected manifests (row identity stable, sidecar URLs change → `_meta.checksum` changes, invalidating downstream caches); re-probe; audit-log the date + host shift + delivery count.
### Registering a download-only (yt-dlp) source
YouTube, SoundCloud, archive.org, Spreaker, Bandcamp, … all flow through the offline intake's enumerate + download path (`ingest_intake.py``intake/playlist.py` + `segments/audio_io.py`). Registering one is **data, not code**:
1. Add a `Channel` to `BUILTIN_CHANNELS` (`.local/extraction/intake/channel_match.py`) with its `host_patterns` and `gh_release_eligible=False` (not a public CDN — its source URLs can't go in a public GH release; the bucket mp3 is the distributed artifact). The driver detects it out of the box and auto-adds it to the catalog via `vocab_additions` on first ingest (idempotent).
2. The per-uploader `source` is supplied per-request: `ingest_intake.py --source <uploader_slug> --proposed <vocab.json>` (a `Source` with `name`/`url`/`audio_categories`).
No new download/encode/probe code is needed — yt-dlp handles enumeration + fetch, and the canonical encode (§5) + post-align reprobe are source-agnostic. Full workflow: the `segments-extraction` skill's `references/playlist_intake.md`.
## 11. Removing a delivery / reciter
Maintainer-only, manual; mandatory audit. No automation / row-level archive flag yet.
**Single delivery:** delete the `deliveries` row + the sidecar; `refresh_derived()`; if the reciter has no remaining deliveries optionally remove the `reciters` row (orphans are harmless); audit the reason + actor.
**Entire reciter:** remove all deliveries + sidecars, then the `reciters` row; optionally record a retirement alias; same audit requirement.
**Never repurpose a `reciter_id` or `slug`** — once retired it stays burned, repurposing breaks URL stability for any external system that cached the old mapping.
## 12. Probing
| Job | Script | Scope |
|---|---|---|
| Single-reciter VBR/CBR + bitrate | `scripts/audio/probe_audio_meta.py --reciter <slug>` | one manifest |
| Bulk row fields | `scripts/audio/probe_audio_meta.py --all` | all tracked reciters |
| Per-ayah probing | not run — `by_ayah` deferred until a consumer needs per-ayah metrics | — |
**How:** 256 KB HTTP Range fetch per URL (no full downloads). `mutagen` reads Xing/Info/VBRI/LAME (authoritative bitrate mode + duration); frame-by-frame scan as fallback. `Content-Range` response header gives total size free. URL-keyed probe cache short-circuits reruns. Concurrency capped (≤10 for `*.archive.org` politeness). Re-probe compares `_meta.checksum`; unchanged → skip.
**When:** new delivery (probe ≥chapter 1 for row fields); new source (bulk by_surah); URL-list change (= checksum change → re-probe that sidecar); periodic maintenance pass for silent format/URL drift.
## 13. Known limitations
- **`by_ayah` deliveries are unprobed** — row audio fields `null`, URL-only sidecars. Bounded re-probe work, deferred until a consumer needs per-ayah metrics.
- **Per-chapter `duration_sec` is null** when no Xing TOC is in the first 256 KB. Full-file probing deferred.
- **`surah-quran` source slug carries a hyphen** — tolerated in `sources.slug` only; never in `reciter_id`, delivery `slug`, or channel slug.
- **No aliases yet**`catalog_aliases` empty; renames are manual.
- **No `recording_year` in the seed** — field exists; populating it is per-reciter research, deferred.
- **`recording_context` null for ~all seed rows** except migrated taraweeh. Backfill is a domain-research task, deferred.