live: heartbeat sync 2026-10-30T18:26:49-07:00 (Refs #11)
This commit is contained in:
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"
|
||||
|
||||
Reference in new issue
Block a user