fix(media): the census read Plex the same wrong way
The two Plex arms of media-census.py — `plex` (the movie planner's title authority) and `audiobooks` (the chapter rows the audiobook plan files by) — opened the library `mode=ro&immutable=1` while the Jellyfin arms already used plain `mode=ro`. Same defect as media-verify.py: an uncheckpointed row of a running scan is invisible, so a just-scanned item is simply absent from the authority file a plan is built from, and the pass cannot say why. Both now go through open_plex_ro(), which tries plain ro first and keeps immutable only as the fallback for a database whose -shm cannot be read. The new audiobooks test leaves a chapter row committed-but-uncheckpointed and asserts the export still carries it; red before the change, green after.
This commit is contained in:
1 parent
afad21dda5
commit
4b67c3c4d8
2 files changed
+67
-14
No files matched your search
+22
-14
@@ -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
|
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):
|
def enumerate_lib(root, out):
|
||||||
"""One row per real file under root: size, extension, path."""
|
"""One row per real file under root: size, extension, path."""
|
||||||
@@ -227,13 +247,7 @@ def probe(enum, out, jobs):
|
|||||||
|
|
||||||
def plex(root, db, out):
|
def plex(root, db, out):
|
||||||
"""Plex's matched titles for files under root, from its DB, read-only."""
|
"""Plex's matched titles for files under root, from its DB, read-only."""
|
||||||
uri = f"file:{db}?mode=ro&immutable=1"
|
con = open_plex_ro(db)
|
||||||
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.")
|
|
||||||
with open(out, "w", newline="") as fh:
|
with open(out, "w", newline="") as fh:
|
||||||
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
w = csv.writer(fh, delimiter="\t", lineterminator="\n")
|
||||||
w.writerow(PLEX_COLS)
|
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
|
at least one un-indexed chapter, so the relayout that reads this file must
|
||||||
not renumber anything from it.
|
not renumber anything from it.
|
||||||
"""
|
"""
|
||||||
uri = f"file:{db}?mode=ro&immutable=1"
|
con = open_plex_ro(db)
|
||||||
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.")
|
|
||||||
tables = {r[0] for r in con.execute(
|
tables = {r[0] for r in con.execute(
|
||||||
"select name from sqlite_master where type='table'")}
|
"select name from sqlite_master where type='table'")}
|
||||||
missing = {"media_parts", "media_items", "metadata_items"} - tables
|
missing = {"media_parts", "media_items", "metadata_items"} - tables
|
||||||
|
|||||||
@@ -228,6 +228,51 @@ PY
|
|||||||
[ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only
|
[ "$(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
|
# -------------------------------------------------------------------- the plan
|
||||||
|
|
||||||
@test "a matched chapter files under its narrator and its book, name untouched" {
|
@test "a matched chapter files under its narrator and its book, name untouched" {
|
||||||
|
|||||||
Reference in new issue
Block a user