feat(media): media-verify.py replays a journal against the servers' own tables

A scan answers 204 whether or not it found anything, so the fleet's documented
proof — every move row has a row at its dst and zero rows still name its src —
was a hand-written sqlite query every time anyone needed it. That is the step a
half-finished relayout is judged on, so it is now a command with an exit code.

It encodes the two doors that bite: Jellyfin stores the container-side path
(/data stands for the share) while Plex stores the host path verbatim and hides
deleted scans behind deleted_at on two tables. The report breaks the missing rows
down by extension, because a .cue or .log has no row to land in and must not read
as a lost album; --ignore-ext keeps those out of the verdict.
This commit is contained in:
dank committed 2026-10-31 01:02:48 -07:00
1 parent 33f97cd3b3
commit 96944f0b17
3 files changed
+411 -13

No files matched your search

@@ -25,10 +25,11 @@ only reader is Plex — and nothing else in the repo touches a media file.
| `scripts/media-shows.py` | no — TSV out | a plan that re-files the TV library itself |
| `scripts/media-music.py` | no — TSV out | a plan that re-files the music library by its tags |
| `scripts/media-audiobooks.py` | no — TSV out | a plan that re-files the audiobooks by the book Plex matched |
| `scripts/media-verify.py` | no — reads the servers' DBs read-only | the verdict that every journaled move landed |
The seams are `tests/media_relayout.bats`, `tests/media_shows.bats`,
`tests/media_music.bats` and `tests/media_audiobooks.bats`; the movie and TV
target contracts are quoted from
`tests/media_music.bats`, `tests/media_audiobooks.bats` and `tests/media_verify.bats`;
the movie and TV target contracts are quoted from
[jellyfin.org/docs/general/server/media/movies](https://jellyfin.org/docs/general/server/media/movies)
in `media-plan.py`'s docstring.
@@ -118,19 +119,18 @@ shred -u /tmp/jf.hdr
A scan answers `204` whether or not it finds anything, so proof is the journal
replayed against both servers' own tables: every `move` row has a row at its
`dst`, and **zero** rows still name its `src`. There is no `sqlite3` binary on
these hosts and none inside either container — read the DBs with the host
`python3` and `mode=ro&immutable=1`, which takes no lock while the server runs.
`dst`, and **zero** rows still name its `src`. `scripts/media-verify.py` is that
replay: it opens each database read-only (there is no `sqlite3` binary on these
hosts or inside either container) and exits non-zero while a row is stale.
| | path | path column | shape |
|---|---|---|---|
| Jellyfin | `/home/dank/Containers/jellyfin/config/data/jellyfin.db` | `BaseItems.Path` | one `BaseItems` table; `Type` holds the .NET class (`…Entities.TV.Episode`). There is no `MediaItems`/`TypedItems`. |
| Plex | `/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db` | `media_parts.file` | join `media_parts → media_items → metadata_items`; filter `deleted_at is null` on **both** or deleted scans pollute the count. |
```bash
scripts/media-verify.py --journal /mnt/entropy/media-relayout-music.jsonl \
--db-kind jellyfin --ignore-ext jpg,jpeg,png,log,cue,toc,nfo,txt,m3u,accurip
```
**Jellyfin stores container-side paths.** The share binds `/mnt/entropy → /data`
in that container, so a journal `src` of `/mnt/entropy/TVShows/…` is stored as
`/data/TVShows/…`. Plex is bound at the same path it has on the host, so its
rows match the journal verbatim.
Ignore only what a server cannot index. A missing `.flac` or `.mkv` is the
fault; a row still naming its `src` means the scan has not followed the rename
yet, so re-run the check after the scan, never before.
Three doors that look right and are not: `GET /search?query=` matches metadata
titles, never file paths; `?path=` on `/library/sections/N/all` makes Plex's