Files
serverAppConfigs/tests/media_verify.bats
T
Frank a5a9c04da2 feat(media): the verdict splits what the database alone cannot
"No row at the destination" was one number, and it conflated three different
things: a sidecar the server never indexes, a file a later pass renamed again,
and a file that never left its source — the last one being actual damage. The
runbook already named the four classes; the tool never computed them, so an
operator reading `missing from the DB: 1851` had no way to tell a booklet.pdf
from a lost album and might have run a rollback over a false alarm.

`missing` rows are now stat'd on both paths and reported as
`on disk at dst, no row names it` / `REAL — file still at src` /
`gone — at neither path`, with offenders listed in that order. A row still
naming `src` stays a separate line: that is the server's own stale pointer, a
phantom, and it costs a re-scan rather than a restore.

Two new tests (REAL, and gone-vs-sidecar) plus two existing layout assertions
loosened from line positions to content. 14/14 in tests/media_verify.bats.
2026-10-31 10:28:44 -07:00

283 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bats
# Seam: scripts/media-verify.py — the replay of a relayout journal against a
# media server's OWN database, which is the documented proof that a rename
# landed (a scan answers 204 whether or not it finds anything).
#
# Every test states what the database holds and asserts what the command prints
# and returns; nothing here reaches into the script's internals. The fixtures are
# minimal sqlite files with the columns the two servers really use, so the seam
# fails if the tool ever reads the wrong table, forgets Plex's `deleted_at`
# filter, or forgets that Jellyfin stores the container-side path (/data/…) while
# Plex stores the host path verbatim.
load helpers
VERIFY="$REPO_ROOT/scripts/media-verify.py"
# jf_mk <db> — the Jellyfin shape: one BaseItems table, Path is the column.
jf_mk() {
python3 - "$1" <<'PY'
import sqlite3, sys
con = sqlite3.connect(sys.argv[1])
con.execute("create table BaseItems (Id INTEGER PRIMARY KEY, Path TEXT, Type TEXT,"
" ParentId INTEGER, Name TEXT)")
con.commit()
PY
}
# jf_row <db> <path> — one item the server believes it has.
jf_row() {
python3 - "$1" "$2" <<'PY'
import sqlite3, sys
con = sqlite3.connect(sys.argv[1])
con.execute("insert into BaseItems (Path, Type) values (?, 'x')", (sys.argv[2],))
con.commit()
PY
}
# px_mk <db> — the Plex shape: media_parts joined up to metadata_items.
px_mk() {
python3 - "$1" <<'PY'
import sqlite3, sys
con = sqlite3.connect(sys.argv[1])
con.execute("create table metadata_items (id INTEGER PRIMARY KEY, title TEXT,"
" deleted_at TEXT)")
con.execute("create table media_items (id INTEGER PRIMARY KEY, metadata_item_id"
" INTEGER, container TEXT)")
con.execute("create table media_parts (id INTEGER PRIMARY KEY, media_item_id"
" INTEGER, file TEXT, deleted_at TEXT)")
con.commit()
PY
}
# px_row <db> <path> [DELETED_AT] — one part; a non-empty DELETED_AT is a row a
# deleted scan left behind, which must never count as proof.
px_row() {
python3 - "$1" "$2" "${3:-}" <<'PY'
import sqlite3, sys
db, path, gone = sys.argv[1], sys.argv[2], sys.argv[3] or None
con = sqlite3.connect(db)
cur = con.execute("insert into metadata_items (title, deleted_at) values ('t', ?)",
(gone,))
mi = cur.lastrowid
cur = con.execute("insert into media_items (metadata_item_id, container)"
" values (?, 'mkv')", (mi,))
con.execute("insert into media_parts (media_item_id, file, deleted_at)"
" values (?, ?, NULL)", (cur.lastrowid, path))
con.commit()
PY
}
# move_row <src> <dst> — one move in the journal.
move_row() {
printf '{"op": "move", "src": "%s", "dst": "%s"}\n' "$1" "$2" >> "$JOURNAL"
}
setup() {
SHARE="$BATS_TEST_TMPDIR/mnt/entropy"
JFDB="$BATS_TEST_TMPDIR/jellyfin.db"
PXDB="$BATS_TEST_TMPDIR/plex.db"
JOURNAL="$BATS_TEST_TMPDIR/journal.jsonl"
: > "$JOURNAL"
jf_mk "$JFDB"
px_mk "$PXDB"
}
@test "every move that the database holds at its destination is reported landed" {
move_row "$SHARE/Movies/Old Name (1999).mkv" "$SHARE/Movies/Old Name (1999)/Old Name (1999).mkv"
move_row "$SHARE/Movies/Two.mkv" "$SHARE/Movies/Two/Two.mkv"
# Jellyfin stores the container-side path: /data stands for the share.
jf_row "$JFDB" "/data/Movies/Old Name (1999)/Old Name (1999).mkv"
jf_row "$JFDB" "/data/Movies/Two/Two.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 0 ]
[ "${lines[0]}" = "jellyfin: 2 moves checked" ]
[ "${lines[1]}" = "landed: 2" ]
[ "${lines[2]}" = "still at src: 0" ]
[ "${lines[3]}" = "missing from the DB: 0" ]
}
@test "a database that has not rescanned yet is refused, naming the stale rows" {
move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv"
jf_row "$JFDB" "/data/Movies/Old.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 1 ]
[ "${lines[1]}" = "landed: 0" ]
[ "${lines[2]}" = "still at src: 1" ]
[[ "$output" == *"$SHARE/Movies/Old.mkv"* ]]
}
@test "a moved file no row of the database names is reported missing" {
move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 1 ]
[ "${lines[3]}" = "missing from the DB: 1" ]
[[ "$output" == *"$SHARE/Movies/Old/Old.mkv"* ]]
}
@test "Plex stores the host path verbatim, so a row at the destination lands" {
move_row "$SHARE/TVShows/Show/Season 01/Show S01E01.mkv" \
"$SHARE/TVShows/Show (2002)/Season 01/Show (2002) S01E01.mkv"
px_row "$PXDB" "$SHARE/TVShows/Show (2002)/Season 01/Show (2002) S01E01.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind plex --db "$PXDB" \
--share "$SHARE"
[ "$status" -eq 0 ]
[ "${lines[0]}" = "plex: 1 moves checked" ]
[ "${lines[1]}" = "landed: 1" ]
}
@test "a Plex row a deleted scan left behind is not proof a move landed" {
move_row "$SHARE/TVShows/Old S01E01.mkv" "$SHARE/TVShows/Show/Old S01E01.mkv"
px_row "$PXDB" "$SHARE/TVShows/Show/Old S01E01.mkv" "2026-10-30 00:00:00"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind plex --db "$PXDB" \
--share "$SHARE"
[ "$status" -eq 1 ]
[ "${lines[1]}" = "landed: 0" ]
[ "${lines[3]}" = "missing from the DB: 1" ]
}
@test "a database without the columns that kind needs is an error, not a zero" {
move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$PXDB" \
--share "$SHARE" --container /data
[ "$status" -eq 2 ]
[[ "$output" == *"$PXDB"* ]]
[[ "$output" == *"BaseItems"* ]]
}
@test "only move rows are counted, so folder operations cannot inflate the tally" {
move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv"
jf_row "$JFDB" "/data/Movies/Old/Old.mkv"
printf '{"op": "mkdir", "dst": "%s/Movies/Old"}\n' "$SHARE" >> "$JOURNAL"
printf '{"op": "rmdir", "path": "%s/Movies/Empty"}\n' "$SHARE" >> "$JOURNAL"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 0 ]
[ "${lines[0]}" = "jellyfin: 1 moves checked" ]
}
@test "a journal with no moves is clean, not a failure" {
printf '{"op": "mkdir", "dst": "%s/Movies/Old"}\n' "$SHARE" >> "$JOURNAL"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 0 ]
[ "${lines[1]}" = "landed: 0" ]
}
@test "the offender list stops at --show so a stale library stays readable" {
for n in 1 2 3 4; do
move_row "$SHARE/Movies/F$n.mkv" "$SHARE/Movies/F$n/F$n.mkv"
done
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data --show 2
[ "$status" -eq 1 ]
[ "${lines[3]}" = "missing from the DB: 4" ]
[[ "$output" == *"$SHARE/Movies/F1/F1.mkv"* ]]
[[ "$output" != *"$SHARE/Movies/F4/F4.mkv"* ]]
}
@test "the missing rows are counted by extension, so a sidecar is not mistaken for a lost film" {
move_row "$SHARE/Music/Album/Mandatory Fun.log" "$SHARE/Music/Weird Al/Mandatory Fun (2014)/Mandatory Fun.log"
move_row "$SHARE/Music/Album/Film.mkv" "$SHARE/Music/Weird Al/Mandatory Fun (2014)/Film.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 1 ]
[ "${lines[3]}" = "missing from the DB: 2" ]
[[ "$output" == *"missing by extension: .log 1, .mkv 1"* ]]
}
@test "--ignore-ext keeps a file the server never indexes out of the verdict" {
move_row "$SHARE/Music/Album/Song.flac" "$SHARE/Music/Weird Al/Mandatory Fun (2014)/Song.flac"
jf_row "$JFDB" "/data/Music/Weird Al/Mandatory Fun (2014)/Song.flac"
move_row "$SHARE/Music/Album/Mandatory Fun.cue" "$SHARE/Music/Weird Al/Mandatory Fun (2014)/Mandatory Fun.cue"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data --ignore-ext log,cue
[ "$status" -eq 0 ]
[ "${lines[1]}" = "landed: 1" ]
[ "${lines[3]}" = "missing from the DB: 0" ]
[[ "$output" == *"not indexed (extension ignored): 1"* ]]
}
# wal_case <db> — a Jellyfin database whose NEW row lives only in the
# write-ahead log: the server has committed the rename but not checkpointed it,
# which is the normal state of a library a scan is writing. The holder
# connection keeps the WAL from folding into the main file, and the verifier
# runs as a real subprocess so it faces the same files Plex and Jellyfin leave.
wal_case() {
python3 - "$JFDB" "$JOURNAL" "$SHARE" "$VERIFY" <<'PY'
import sqlite3, subprocess, sys
db, journal, share, verify = sys.argv[1:5]
w = sqlite3.connect(db)
w.execute("pragma journal_mode=wal")
w.execute("pragma wal_autocheckpoint=0")
w.execute("insert into BaseItems (Path, Type) values ('/data/Movies/Old.mkv', 'x')")
w.commit()
w.execute("pragma wal_checkpoint(truncate)")
holder = sqlite3.connect(db) # last-connection close would checkpoint
w.execute("update BaseItems set Path='/data/Movies/Old/Old.mkv'")
w.commit()
r = subprocess.run([sys.executable, verify, "--journal", journal,
"--db-kind", "jellyfin", "--db", db,
"--share", share, "--container", "/data"],
capture_output=True, text=True)
print(r.stdout, end="")
sys.exit(r.returncode)
PY
}
@test "a row the server committed but has not checkpointed is proof, not a miss" {
move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv"
run wal_case
[ "$status" -eq 0 ]
[ "${lines[1]}" = "landed: 1" ]
[ "${lines[3]}" = "missing from the DB: 0" ]
}
# The DB alone cannot tell a lost file from a sidecar: both are "no row at the
# destination". The tool therefore stats the two paths and separates the classes
# the runbook names, so an operator never reads a `.pdf` as a lost film or,
# worse, reads a real loss as a sidecar.
@test "a file the database does not name that still sits at src is the pass's own damage" {
mkdir -p "$SHARE/Movies"
echo film > "$SHARE/Movies/Old.mkv"
move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 1 ]
[ "${lines[3]}" = "missing from the DB: 1" ]
[ "${lines[4]}" = " on disk at dst, no row names it: 0" ]
[ "${lines[5]}" = " REAL — file still at src: 1" ]
[ "${lines[6]}" = " gone — at neither path: 0" ]
[[ "$output" == *"real: $SHARE/Movies/Old.mkv"* ]]
}
@test "a file at neither path is gone, and a file at dst the server ignores is not a loss" {
move_row "$SHARE/Movies/Gone.mkv" "$SHARE/Movies/Gone/Gone.mkv"
mkdir -p "$SHARE/Movies/Sidecar"
echo cover > "$SHARE/Movies/Sidecar/cover.jpg"
move_row "$SHARE/Movies/Album/cover.jpg" "$SHARE/Movies/Sidecar/cover.jpg"
run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \
--share "$SHARE" --container /data
[ "$status" -eq 1 ]
[ "${lines[3]}" = "missing from the DB: 2" ]
[ "${lines[4]}" = " on disk at dst, no row names it: 1" ]
[ "${lines[5]}" = " REAL — file still at src: 0" ]
[ "${lines[6]}" = " gone — at neither path: 1" ]
[[ "$output" == *"gone: $SHARE/Movies/Gone/Gone.mkv"* ]]
}