From afad21dda56540b63d6dfe50faaed9abe2180126 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 31 Oct 2026 10:00:11 -0700 Subject: [PATCH] fix(media): verify read the library the way the servers write it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/media-verify.py | 25 +++++++++++++++++++------ tests/media_verify.bats | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/scripts/media-verify.py b/scripts/media-verify.py index 23cca6d..87e2861 100755 --- a/scripts/media-verify.py +++ b/scripts/media-verify.py @@ -9,8 +9,13 @@ its destination, and NO row may still name its source — a source row is a scan that has not caught up, and it is the one signature of a library pointing at files that no longer exist. -Two doors, both read-only, both without a lock while the server runs -(`mode=ro&immutable=1`; there is no `sqlite3` binary on these hosts): +Two doors, both read-only, both without a lock while the server runs (there is +no `sqlite3` binary on these hosts). Open `mode=ro` PLAIN: both servers keep +their libraries in WAL mode, and `immutable=1` — which claims the file never +changes — makes sqlite skip the write-ahead log entirely, so every row the +server has committed but not yet checkpointed reads as MISSING, and a read +beside a running scanner can even fail as "database disk image is malformed". +`immutable=1` is only the fallback, for a database whose `-shm` cannot be read. jellyfin `BaseItems.Path`. The container binds the share at `/data`, so the database holds `/data//…` where the journal holds @@ -92,10 +97,18 @@ def read_moves(path): def server_paths(db, kind): """Every path the server holds, or a refusal that names what is missing.""" spec = KINDS[kind] - try: - con = sqlite3.connect(f"file:{db}?mode=ro&immutable=1", uri=True) - except sqlite3.Error as e: - die(f"cannot open {db}: {e}") + con = None + err = None + # Plain ro reads the WAL the server commits through; immutable is the last + # resort, because it cannot see a row the server has not checkpointed. + for opt in ("mode=ro", "mode=ro&immutable=1"): + try: + con = sqlite3.connect(f"file:{db}?{opt}", uri=True) + break + except sqlite3.Error as e: + err = e + if con is None: + die(f"cannot open {db}: {err}") with con: have = {r[1] for r in con.execute(f"pragma table_info({spec['table']})")} if not spec["columns"] <= have: diff --git a/tests/media_verify.bats b/tests/media_verify.bats index 1d9f8e1..a230dbf 100755 --- a/tests/media_verify.bats +++ b/tests/media_verify.bats @@ -210,3 +210,38 @@ setup() { [ "${lines[3]}" = "missing from the DB: 0" ] [ "${lines[4]}" = "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" ] +}