#!/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 — 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 — 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 — 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 [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 — 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 — 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"* ]] }