fix(media): verify read the library the way the servers write it #103

Merged
dank merged 3 commits from fix/media-verify-wal-read into main 2026-10-31 12:12:23 -07:00
2 changed files with 65 additions and 8 deletions
Showing only changes of commit a5a9c04da2 - Show all commits

No files matched your search

+28 -6
View File
@@ -27,9 +27,14 @@ beside a running scanner can even fail as "database disk image is malformed".
Neither server holds a row for everything the journal renames: a `.log`, `.cue`
or cover image has no row to land in, so its absence is not a fault. The report
therefore breaks the missing rows down by extension, and `--ignore-ext` names
the extensions this server does not index, which are then counted separately and
kept out of the verdict.
therefore breaks the missing rows down by extension, and `--ignore-ext` names the
extensions this server does not index, which are then counted separately and kept
out of the verdict. What is left is split by what the filesystem says, because
the database alone cannot: a file still at its `src` is `REAL` (this pass's own
damage), a file at neither path is `gone` (a later pass or the server renamed it),
and a file sitting at its `dst` with no row naming it is simply something the
server does not index. A row that still names `src` is none of these: it is the
server's own stale pointer — a phantom — and it costs a re-scan, never a restore.
Exit codes: 0 every move landed; 1 something is stale or missing (the details are
printed); 2 the journal or the database is not usable at all.
@@ -174,20 +179,37 @@ def main():
if at_src:
stale.append(src)
if not at_dst:
missing.append(dst)
missing.append((src, dst))
# The database cannot tell a lost file from one it never indexes: both read
# as "no row at the destination". Stat both paths, so the report separates
# the classes the runbook names — `REAL` is the only one that is this pass's
# own damage, and `gone` is a later pass or the server's own rename.
on_disk, real, gone = [], [], []
for src, dst in missing:
if os.path.exists(dst):
on_disk.append(dst)
elif os.path.exists(src):
real.append(src)
else:
gone.append(dst)
print(f"{args.db_kind}: {len(moves)} moves checked")
print(f"landed: {landed}")
print(f"still at src: {len(stale)}")
print(f"missing from the DB: {len(missing)}")
print(f" on disk at dst, no row names it: {len(on_disk)}")
print(f" REAL — file still at src: {len(real)}")
print(f" gone — at neither path: {len(gone)}")
if ignored:
print(f"not indexed (extension ignored): {ignored}")
if missing:
exts = Counter(os.path.splitext(d)[1].lower() or "(no extension)"
for d in missing)
for _, d in missing)
print("missing by extension: "
+ ", ".join(f"{e} {n}" for e, n in sorted(exts.items())))
for label, items in (("stale", stale), ("missing", missing)):
for label, items in (("real", real), ("gone", gone), ("missing", on_disk),
("stale", stale)):
for path in items[:args.show]:
print(f" {label}: {path}")
if len(items) > args.show:
+37 -2
View File
@@ -195,7 +195,7 @@ setup() {
--share "$SHARE" --container /data
[ "$status" -eq 1 ]
[ "${lines[3]}" = "missing from the DB: 2" ]
[ "${lines[4]}" = "missing by extension: .log 1, .mkv 1" ]
[[ "$output" == *"missing by extension: .log 1, .mkv 1"* ]]
}
@test "--ignore-ext keeps a file the server never indexes out of the verdict" {
@@ -208,7 +208,7 @@ setup() {
[ "$status" -eq 0 ]
[ "${lines[1]}" = "landed: 1" ]
[ "${lines[3]}" = "missing from the DB: 0" ]
[ "${lines[4]}" = "not indexed (extension ignored): 1" ]
[[ "$output" == *"not indexed (extension ignored): 1"* ]]
}
# wal_case <db> — a Jellyfin database whose NEW row lives only in the
@@ -245,3 +245,38 @@ PY
[ "${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"* ]]
}