live: heartbeat sync 2026-10-30T18:26:49-07:00 (Refs #11)

This commit is contained in:
Frank committed 2026-10-30 18:26:49 -07:00
1 parent 674a22be48
commit 69f983f94d
9 files changed
+216 -9

No files matched your search

@@ -0,0 +1,76 @@
---
name: dankvoice-behavior-scenario-authoring
description: "Author or extend a dankvoice behavior.bats scenario: driver env knobs (DV_REWRITE/DV_FAIL/DV_CUT_MS/DV_TAG_LOG), the SPOKEN heard-words census, private-TMPDIR leak assertions, and the arm-the-precondition-before-absence trap."
---
# Writing a dankvoice behavior test
`tests/behavior.bats` drives the **built bundle** through a generated harness. Each `@test`
is: set env → run a named *scenario* → assert on printed census lines. Never assert on
internals; the observable vocabulary is `CALLS`, `PLAYS`, `PLAYORDER`, `SPOKEN`, `NOTIFY`,
`CALL <url> input=… auth=… at=…`, `PLAYER`, `KILLED`.
The driver lives in a heredoc inside `setup()`; find and read it before adding anything:
```bash
sed -n '/cat > "$DV_DRIVER" </,/^TS$/p' tests/behavior.bats
```
A scenario is a `case "<name>":` in that switch that calls the extension's own handlers
(`say.handler("on", ctx)`, `messageEnd?.(said("…"), ctx)`, `agentEnd?.(answer("…"), ctx)`).
Prefer delivering narration through the harness (`agent_end`/`message_end`) over a typed
`/say`: the humanizer, mood, heckle, framing and the frequency hold govern **unsolicited**
narration only, so a typed line cannot exercise them.
## Fixture knobs (set per test, all read by the driver)
| Knob | Effect |
|---|---|
| `DV_REWRITE=off\|echo\|prose\|reasoning\|stall` | what the chat (humanizer) route answers; unset ⇒ it 404s and the rewrite falls back to mechanical text |
| `DV_STALL_MS` | delay the chat answer (timeout behaviour) |
| `DV_FAIL=<N>` | the first N `/audio/speech` requests answer HTTP 500 JSON — an endpoint outage a test can watch the queue weather |
| `DV_HOLD=<sec>` | how long the player stub holds each clip; sets the playback windows |
| `DV_CUT_MS=<ms>` | in `stagecut`, when another session cuts the line |
| `DV_TAG_LOG`, `DV_TIMING_LOG` | per-clip tag census (`PLAYORDER`, `SPOKEN`) and player START/END ms |
| `DV_CT` | content type of the answer (non-WAV routing) |
| `DV_KEYS` | key replayer for the settings modal |
| `TMPDIR="$WORK/scratch-tmp"` | scope synthesis temp dirs so a leak assertion is yours alone |
Always pass `DANKVOICE_STAGE_DIR="$WORK/stage"` when the test touches the stage, and use the
`say_run <scenario>` helper (it wraps `run env …` so `$output` is captured); a bare
`scenario_name` invocation prints without capturing.
## Two traps that have each hidden a real bug
**Assert absence only with the producer armed.** `DV_REWRITE` unset ⇒ the chat route 404s,
so `[[ "$output" != *"/chat/completions"* ]]` and "payload equals the source text" pass even
when rewriting is happening. Nine tests pinned the opposite of the repo's law this way. Pin
with `DV_REWRITE=prose` (the fixture answers with its *own* words) and assert the payload you
expect — then survival is evidence, not absence.
**Count what was HEARD, not what was requested.** A segment whose clip was killed mid-play,
or aborted mid-synthesis, is legitimately synthesized again on resume. Laws about framing
("one intro per narration") are about hearing, so count `SPOKEN` lines: the driver maps each
played tag back to the `/audio/speech` request that produced that clip. `CALLS` counts
requests and will over-report across a line-cut.
Temp-dir leak assertions mirror this: `TMPDIR` private, then
`find "$TMPDIR" -maxdepth 1 -type d -name 'dankvoice-*' | wc -l`.
## Pinning timing
Derive windows from `DV_HOLD`: with 0.5 s clips, clips occupy ~[10,310], [310,610], [610,910];
choose `DV_CUT_MS` so the cut lands strictly inside the clip you mean to interrupt, leaving
margin for the 250 ms stage poll and synthesis time. Default scenario timings are env-overridable
(`DV_CUT_MS ?? 600`) so a new test never edits an existing one's schedule.
## Red-proof and gate (project invariants)
- Prove a new assertion can fail: in a **private worktree**, `git show HEAD:src/<file> > src/<file>`,
`bun run build`, run the filter, restore the file and rebuild. Never while another suite runs
(`setup()` rebuilds the bundle from `src/`). See `dankvoice-bats-serialization`,
`dankvoice-bundle-swap-red-proof`.
- `bun run build` before staging: `tests/packaging.bats` byte-compares committed `dist/` with a
fresh build, so `dist/index.js` must be in the same commit as the `src/` change.
- Gate with the whole suite (`bats tests/`), not the filter; ~5 min, so run it unbounded/async and
never write to `src/` while it runs.
@@ -0,0 +1,73 @@
---
name: plex-metadata-title-cache-read
description: "Read the live Plex library DB read-only for clean titles/file paths (no sqlite3 binary, server running), and use it as the title authority over filename parsing."
---
Plex's metadata cache already holds a *clean* title for every file it matched
(`Alien`, not `Alien.Directors.Cut.1979.1080p.BRrip.x265-RARBG`). Read it instead
of parsing filenames, and name folders / group duplicates from it.
## Where it lives
Host package: data dir `/var/lib/plexmediaserver`, DB at
```
/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db
```
(`Resources/com.plexapp.plugins.library.db` under `/usr/lib/plexmediaserver` is a
blank template — not the library.) The dir is `dank:dank`, so no sudo needed.
Containers: check `podman ps` first; on Dankserver Plex runs as a host process.
## Read it while Plex is RUNNING
No `sqlite3` binary on the fleet hosts — use `python3 -c` with `sqlite3`. Open
immutable read-only so no WAL/SHM is touched and no lock is taken:
```python
con = sqlite3.connect(f"file:{db}?mode=ro&immutable=1", uri=True)
```
A long local `find /` can hit the tool's own deadline; scope `find` with
`-maxdepth` or use the path above directly.
## Schema traps
* `library_sections` → column is **`section_type`**, not `type`. Movies = 1,
TV = 2, Music = 8.
* **`metadata_titles` does not exist** (empty name in the table list). Titles are
on `metadata_items`: `title`, `original_title`, `edition_title`, `year`,
`metadata_type`, `parent_id`, `deleted_at`.
* File path is `media_parts.file`; join
`media_parts → media_items (id = media_item_id) → metadata_items
(id = metadata_item_id)`, and `LEFT JOIN metadata_items par ON par.id =
mi.parent_id` for the show/movie parent.
* **Movies use the LEAF title** (`mi.title`); the parent row is empty. TV uses
the parent. Filter `p.deleted_at IS NULL AND mi.deleted_at IS NULL` or deleted
scans pollute the census.
```sql
select p.file, mi.title, mi.original_title, mi.edition_title, mi.year, m.container,
m.video_codec, m.audio_codec, m.width, m.height
from media_parts p
join media_items m on m.id = p.media_item_id
join metadata_items mi on mi.id = m.metadata_item_id
where p.file like '/mnt/entropy/Movies/%'
and p.deleted_at is null and mi.deleted_at is null
```
`media_items` also carries codec/resolution already — often no ffprobe pass is
needed for matched files.
## Use it as the title authority
Reject a Plex title only if it looks like a filename
(`2160p|1080p|x26[45]|hevc|bluray|web-?dl`) — a healthy cache scores **0** junk
titles. Coverage expectation measured on `/mnt/entropy/Movies`: 3,754 of 4,698
media files matched; the unmatched remainder is extras, BDMV disc trees, TV
episodes misfiled into Movies, and collection box sets — exactly the classes a
filename parser must still handle.
Propagate one match to every copy of a film (a group of versions) so sidecars,
`.nfo` and media files share one stem; fall back to the filename parser only
when no row exists.
@@ -0,0 +1,54 @@
---
name: plex-title-authority-for-relayout
description: "Read Plex's metadata cache as the clean-title authority (ziggurat schema, immutable read, no sqlite3 CLI) when naming or regrouping a media library from scripts instead of filename parsing."
---
Plex has already matched most of a library to real titles. When a script must name folders/files (Jellyfin relayout, dedup reports, library census), **read Plex's DB and let it override filename parsing** — `Alien.Directors.Cut.1979.1080p.BRrip.x265-RARBG` → `Alien`, `1917.2019.2160p…` → `1917`. Measured on dankserver's Movies section: 3,773 parts, **0** filename-shaped titles, 80% of media files matched (unmatched = extras, BDMV disc trees, TV).
## Locate and open the DB (no sqlite3 CLI needed)
```
DB="<data>/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
# data dir: /var/lib/plexmediaserver (host install) or the container's volume
```
```python
con = sqlite3.connect(f"file:{DB}?mode=ro&immutable=1", uri=True) # no copy, no wal, no lock
```
`immutable=1` is the whole trick: the DB was 1.5 GiB and Plex was live — no copy, no `sqlite3` binary, no risk of touching the service. Set `busy_timeout`; expect occasional inconsistency, acceptable for a read-only census.
## Schema trap: Plex renamed the tables ("ziggurat")
The old `sections` table **does not exist** — querying it throws, which reads like "wrong DB". Real names:
| table | join | key columns |
|---|---|---|
| `library_sections` | — | `id`, `name`, `section_type` (`movie`/`show`), `agent`, `scanner` |
| `section_locations` | `library_section_id` | `root_path`, `available` |
| `metadata_items` | `parent_id` → own `id` | `metadata_type`, `title`, `original_title`, `edition_title`, `year`, `indexed`, `deleted_at` |
| `media_items` | `metadata_item_id` | `container`, `video_codec`, `audio_codec`, `width`, `height` |
| `media_parts` | `media_item_id` | **`file`** (full path), `size`, `duration`, `deleted_at` |
Census section coverage first (`count(*) where file like '<root>/%'`), then dump:
```sql
select p.file, mi.title, mi.original_title, mi.edition_title, mi.year, m.video_codec, m.width, m.height
from media_parts p
join media_items m on m.id = p.media_item_id
join metadata_items mi on mi.id = m.metadata_item_id
where p.file like '<root>/%' and p.deleted_at is null and mi.deleted_at is null
```
For a movie library the leaf *is* the film; `parent_id` is NULL, so a `left join` on parent returns the same title. `metadata_type`: 1 = movie, 2 = show (a show section riding the same root means episodes live in the MOVIES folder — worth reporting).
## Using it in a renamer
- **One film, one name:** match any copy → apply that title to the whole group. Keyed by `file` path, so the map is exact.
- **Override, don't replace:** the parser still names the unmatched (extras, disc trees, TV, unscanned). Keep it as a fallback tier.
- **Bucket on the old key, name with Plex:** group/score sidecars with the parser's words (a `.srt` matches on the year token), format the folder from Plex: `f"{title} ({year})"`.
- **Sanitize for the FS:** Plex titles carry `:` and `&` (`2001: A Space Odyssey`, `Rock & Rule`) — those must go through the same sanitizer as filename-derived names.
## Cross-check before trusting a parser
Score the parser against the cache: count titles matching `2160p|1080p|x26[45]|bluray|hevc|10bit` — Plex returned **0**. Every parser mismatch is a real bug class: year-echo pseudo-titles (`1917.2019.2160p…`), two-year titles (`2001…1968`), numeric titles eaten as track indexes (`12 Monkeys`, `007 Skyfall`, `000 Miles to Graceland`, `0 - First Dates` — keep the number unless it's 1–2 digits glued to the words with a year present, i.e. `01. Bye Bye Baby`).
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1793403526285
"lastConsolidatedAt": 1793408441828
}
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1793405934807,
"lastConsolidatedAt": 1793409834839,
"lastResult": {
"at": 1793403514528,
"sessions": 1,
@@ -1,6 +1,6 @@
{
"v": 1,
"lastConsolidatedAt": 1793405994161,
"lastConsolidatedAt": 1793409947332,
"lastResult": {
"at": 1793405994161,
"sessions": 1,
@@ -1,4 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1793406016281
"lastConsolidatedAt": 1793409916309
}
@@ -0,0 +1,4 @@
{
"v": 1,
"lastConsolidatedAt": 1793410002774
}
@@ -279,22 +279,22 @@ transition = [ "fade" ]
interval_seconds = 1200
[wallpaper.default]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-mlomkm.png"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-gw26v3.png"
[wallpaper.last]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-mlomkm.png"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-gw26v3.png"
[wallpaper.monitors.DP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-gjqmel.png"
[wallpaper.monitors.DP-2]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-mlomkm.png"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-gw26v3.png"
[wallpaper.monitors.HDMI-A-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-mlomkm.png"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-gw26v3.png"
[wallpaper.monitors.eDP-1]
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-mlomkm.png"
path = "/home/blank/.local/share/noctalia/wallpapers/wallhaven-gw26v3.png"
[weather]
unit = "imperial"