A verify run beside a running scan reported rows the scanner had just written as MISSING, and could fail outright as "database disk image is malformed". Both libraries live in WAL mode, and the verifier opened them with immutable=1, which tells sqlite the file never changes: it skips the write-ahead log, so every committed-but-uncheckpointed row is invisible. 134 MB of jellyfin.db-wal was riding past the verifier's eyes during the 2026-10-31 rescan. Open plain mode=ro first — sqlite then reads the WAL through its -shm — and keep immutable=1 only as the fallback for a database whose -shm cannot be read. The bats fixture leaves a journaled move uncheckpointed and proves the verifier still counts it landed.
248 lines
9.8 KiB
Bash
Executable File
248 lines
9.8 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" ]
|
|
[ "${lines[4]}" = "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" ]
|
|
[ "${lines[4]}" = "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" ]
|
|
}
|