diff --git a/scripts/media-census.py b/scripts/media-census.py index fd9c5b3..a03251f 100755 --- a/scripts/media-census.py +++ b/scripts/media-census.py @@ -128,6 +128,26 @@ where p.file like ? and p.deleted_at is null and mi.deleted_at is null and al.deleted_at is null """ +def open_plex_ro(db): + """Plex's library, read the way Plex writes it. + + `immutable=1` tells sqlite the file never changes, so it skips the + write-ahead log: every row a scan has committed but not yet checkpointed + is invisible, and reading a live database that way can fail outright as + "database disk image is malformed". Plain ro reads the WAL through its + -shm; immutable is only the fallback for a database whose -shm cannot be + read. The same law scripts/media-verify.py states. + """ + last = None + for opt in ("mode=ro", "mode=ro&immutable=1"): + try: + return sqlite3.connect(f"file:{db}?{opt}", uri=True) + except sqlite3.Error as e: + last = e + sys.exit(f"cannot open the Plex database read-only ({db}): {last}\n" + "Plex runs rootless here: run this as the container's owner, never " + "with sudo — rootless storage is per-user, so root sees nothing.") + def enumerate_lib(root, out): """One row per real file under root: size, extension, path.""" @@ -227,13 +247,7 @@ def probe(enum, out, jobs): def plex(root, db, out): """Plex's matched titles for files under root, from its DB, read-only.""" - uri = f"file:{db}?mode=ro&immutable=1" - try: - con = sqlite3.connect(uri, uri=True) - except sqlite3.Error as e: - sys.exit(f"cannot open the Plex database read-only ({db}): {e}\n" - "Plex runs rootless here: run this as the container's owner, never " - "with sudo — rootless storage is per-user, so root sees nothing.") + con = open_plex_ro(db) with open(out, "w", newline="") as fh: w = csv.writer(fh, delimiter="\t", lineterminator="\n") w.writerow(PLEX_COLS) @@ -405,13 +419,7 @@ def audiobooks(root, db, out): at least one un-indexed chapter, so the relayout that reads this file must not renumber anything from it. """ - uri = f"file:{db}?mode=ro&immutable=1" - try: - con = sqlite3.connect(uri, uri=True) - except sqlite3.Error as e: - sys.exit(f"cannot open the Plex database read-only ({db}): {e}\n" - "Plex runs rootless here: run this as the container's owner, never " - "with sudo — rootless storage is per-user, so root sees nothing.") + con = open_plex_ro(db) tables = {r[0] for r in con.execute( "select name from sqlite_master where type='table'")} missing = {"media_parts", "media_items", "metadata_items"} - tables diff --git a/tests/media_audiobooks.bats b/tests/media_audiobooks.bats index 6f50db0..3214065 100755 --- a/tests/media_audiobooks.bats +++ b/tests/media_audiobooks.bats @@ -228,6 +228,51 @@ PY [ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only } +# wal_fresh DB ROOT OUT — a row Plex matched during a scan and committed, but +# has not checkpointed: the normal state of a library being walked. The holder +# connection stops the WAL folding into the main file when the writer closes, +# and the census runs as a real subprocess against those same files. +wal_fresh() { + local db="$1" root="$2" out="$3" + python3 - "$db" "$root" "$out" "$CENSUS" <<'PY' +import sqlite3, subprocess, sys +db, root, out, census = sys.argv[1:5] +w = sqlite3.connect(db) +w.execute("pragma journal_mode=wal") +w.execute("pragma wal_autocheckpoint=0") +w.commit() +w.execute("pragma wal_checkpoint(truncate)") # the rows so far are on disk +holder = sqlite3.connect(db) # last close would checkpoint +ar = w.execute('insert into metadata_items (parent_id, metadata_type, title,' + ' year, "index", deleted_at) VALUES (NULL, 8, ?, NULL, NULL, NULL)', + ("Fresh Narrator",)).lastrowid +al = w.execute('insert into metadata_items (parent_id, metadata_type, title,' + ' year, "index", deleted_at) VALUES (?, 9, ?, ?, NULL, NULL)', + (ar, "Fresh Book", 2026)).lastrowid +mi = w.execute('insert into metadata_items (parent_id, metadata_type, title,' + ' year, "index", deleted_at) VALUES (?, 10, ?, NULL, ?, NULL)', + (al, "Fresh Chapter", 1)).lastrowid +it = w.execute("insert into media_items (metadata_item_id, deleted_at)" + " VALUES (?, NULL)", (mi,)).lastrowid +w.execute("insert into media_parts (media_item_id, file, deleted_at)" + " VALUES (?, ?, NULL)", (it, root + "/Fresh/01.mp3")) +w.commit() +sys.exit(subprocess.run([sys.executable, census, "audiobooks", "--root", root, + "--db", db, "--out", out]).returncode) +PY +} + +@test "the census exports a row the scanner committed but has not checkpointed" { + DB="$BATS_TEST_TMPDIR/plex.db" + db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Old/01.mp3" \ + "Old Narrator" "Old Book" 1990 1 "Old Chapter" + run wal_fresh "$DB" "$BATS_TEST_TMPDIR/Audiobooks" "$BATS_TEST_TMPDIR/exp.tsv" + [ "$status" -eq 0 ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 3 ] # header + both rows + tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \ + | grep -q "^$BATS_TEST_TMPDIR/Audiobooks/Fresh/01\.mp3 Fresh Narrator Fresh Book 2026 1 Fresh Chapter$" +} + # -------------------------------------------------------------------- the plan @test "a matched chapter files under its narrator and its book, name untouched" {