diff --git a/scripts/media-verify.py b/scripts/media-verify.py index 87e2861..346177c 100755 --- a/scripts/media-verify.py +++ b/scripts/media-verify.py @@ -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: diff --git a/tests/media_verify.bats b/tests/media_verify.bats index a230dbf..340e32a 100755 --- a/tests/media_verify.bats +++ b/tests/media_verify.bats @@ -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 — 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"* ]] +}