diff --git a/AGENTS.md b/AGENTS.md index 0206de6..b4a50ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -809,6 +809,7 @@ per-attempt page and no per-swap ledger anywhere else. | Deployment flow | `serverAppConfigs-docs/Architecture/Deployment Flow.md` | | Movie-library relayout (Jellyfin format, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Relayout.md` | | SSD-backed metadata view (Jellyfin writes, Plex reads, Dankserver) | `serverAppConfigs-docs/Dankserver/Media Metadata View.md` | +| Duplicate films and the purge hold (Dankserver) | `serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md` | There is no page for a retired endpoint and no change diary. A payload swap appends one row to the live endpoint's **Eras** table; when the endpoint itself @@ -851,6 +852,7 @@ history. | Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. | | Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. | | Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. | -| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Not yet deployed on Dankserver: the live quadlets still bind the array read-only — see [[Media Metadata View]].** | +| Duplicates in the library | **Prune into the hold** — byte-identical copies and smaller copies of the *same work* go to `.AGENTTRASH/`; a different cut is never touched. Screen by size, confirm by hash before moving, purge only on Frank's word. See [[Media Duplicate Prune]]. | +| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Deployed and live on Dankserver since 2026-11-01: both quadlets bind the view, not the raw array — see [[Media Metadata View]].** | | Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. | | Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. | diff --git a/scripts/media-verify.py b/scripts/media-verify.py index 23cca6d..346177c 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 @@ -22,9 +27,14 @@ Two doors, both read-only, both without a lock while the server runs 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. @@ -92,10 +102,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: @@ -161,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/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md new file mode 100644 index 0000000..b87e3ca --- /dev/null +++ b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md @@ -0,0 +1,128 @@ +--- +type: reference +title: Media Duplicate Prune +description: "Find and hold duplicate films on the entropy array: the hash ladder, the keep rules, the guards, the purge." +tags: [dankserver, media-server, storage, procedure] +timestamp: 2026-11-01T01:00:00Z +--- + +# Media Duplicate Prune + +The movie library holds the same film twice in two different ways — the same file +downloaded again, or the same film grabbed a second time at another quality — and +[[Entropy Volume]] is permanently full, so both are worth finding. They need +different rules, and neither is decided by name similarity alone. + +`/mnt/entropy/media-park` was a third kind: a parking lot that held whole shows +and films nothing served. It is gone; its contents went to `NeedsProcessing`. + +## What counts + +| Class | Test | Action | +|---|---|---| +| Byte-identical | equal size, equal content hash | keep one copy, hold the rest | +| Same work, worse encode | same title, same year, every runtime within 5 % | keep the largest — Frank's rule | +| Different cut | same title and year, runtimes more than 5 % apart | **never touched**: an extended and a theatrical version are different products | +| Same words, different film | title matches, year or duration does not | never grouped — this is the false-merge class that would destroy a unique file | + +## The screen + +The array cannot afford a full-content read, so identity is established in steps +and each step only narrows what the next one touches: + +1. `scripts/media-census.py enumerate` — size is free, and identical files share it. +2. Head+tail `blake2b`, 4 MiB at each end, on the size collisions only. +3. **Before anything moves**, confirm with a sparse-window hash: 32 MiB at eight + evenly spaced offsets. Re-hashing a remux-heavy set in full read 150 GiB for + the better part of an hour; the sparse window cost 50 s, a file that merely + shares its head and tail cannot survive it, and the destination is a + recoverable hold rather than a deletion. +4. Durations come from `scripts/media-census.py probe` — the relayout's own + census. Do not hand-roll a second probe: it re-seeks the array for an answer + the repo tool already produces, and a probe is the one census step that costs + hours instead of seconds. + +## Which copy survives + +In order: not one inside an `extras`, `featurettes`, `samples` or `trailers` +folder (an extra is not a copy of the film, and extra names collide across films); +then the shallowest path; then the file whose name equals its `Title (year)` +folder; then the largest. For the same-work class the largest is the answer, and +the canonical-name rule only breaks a tie — without it a `Film - 1080p.mkv` can +outlive `Film.mkv` for no reason but a space sorting before a dot. + +## Guards + +Each of these is a file that would have been destroyed without it: + +- **`.!qB` is qBittorrent state, not a duplicate.** It can be byte-identical to + the finished file while qB still counts the download incomplete; moving it + breaks the re-check and re-downloads the film. Report it, leave it. Both + leftovers in `Movies` are 13 months stale, so they are Frank's purge decision, + not a prune decision. +- **Require a year, and keep digits in the title.** Stripping digits merged + *Akira — Teaser 1* with *Teaser 2*; without a year, two different extras that + happened to share a name merged. +- Both members must be feature length, and a name carrying `disc`, `cd` or `part` + is excluded: half a multi-disc release is not a copy of the other half. +- Media containers only. Grouping every extension buries the report in subtitle + and cover pairs — and a duration guard that appears to fire on 720 groups is + counting subtitles, not protecting films. + +## The hold + +Nothing is deleted. Every doomed copy moves into a dated hold with the same +executor the relayout uses, so any file comes back: + +```bash +scripts/media-apply.py apply --plan /mnt/entropy/dup-prune-similar-plan.tsv \ + --root /mnt/entropy --journal /mnt/entropy/dup-prune-similar.jsonl # dry +scripts/media-apply.py apply --plan … --root /mnt/entropy --journal … --apply +scripts/media-apply.py verify --plan … --journal /mnt/entropy/dup-prune-similar.jsonl +scripts/media-apply.py sweep --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply +scripts/media-apply.py rollback --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply +``` + +The plan rows are the tool's own format (`action src dst kind`, `mkdir` rows for +every hold parent), and the planner re-checks the two things that go stale between +analysis and apply: a file that has moved since the census, and a group whose +keeper is missing — either one drops the whole group rather than half-applying it. + +- **A hold on the same volume frees nothing.** The purge is a separate, approved + act and the only thing that moves the free-space number; `df` does not budge + when 1 TiB moves sideways. +- `sweep` removes the release folders the pass emptied. Empty folders dated a year + ago are torrent detritus (`Subtitle,info`, `Screens`, `Sample`) from a previous + era, not this pass — read the mtimes before blaming the prune. +- The 2026-11-01 pass held 226 files (~1.1 TiB) in + `/mnt/entropy/.AGENTTRASH/20261101-duplicate-prune`, journals + `dup-prune-exact.jsonl` and `dup-prune-similar.jsonl`. Purge pending Frank's word. + +## After the pass + +Both servers still hold rows for files that are now in the hold, and neither +notices: the media bind is the [[Media Metadata View]], and array changes do not +cross it as inotify events. Scan in the order the rescan law sets — Jellyfin to +`Idle`, then Plex's Movies section alone, one accepted refresh each; the requests +are owned by [[Jellyfin]] and [[Plex]]. + +`scripts/media-verify.py` reads differently for a prune than for a relayout: +`landed: 0` is the **correct** end state (no server may index the hold), so the +verdict is `still at src: 0` with `REAL: 0`, and the rows that simply vanished +from the database are the pass succeeding. + +## Failure signatures + +| Symptom | Meaning | +|---|---| +| A size group whose members split under the sparse hash | The head+tail screen collided. Those files stay exactly where they are and get reported. | +| The planner holds far fewer files than the analyzer counted | Two implementations of "largest wins". The analyzer must emit `keep`/`hold` marks and the planner only transcribes them — a planner that re-derives the winner from the hold list alone drops the keeper out of the group and prunes almost nothing. | +| `df` unchanged after a TiB of moves | Expected: the hold is on the same filesystem. Only the purge frees space. | +| A film that "disappeared" after a prune | It is in the hold, named by the journal: `media-apply.py rollback --journal … --apply`. | + +## Links + +- [[Media Relayout]] — the census tools, the executor, the journal format. +- [[Media Metadata View]] — why neither server sees the change without a scan. +- [[Jellyfin]] · [[Plex]] — the scan requests, one server at a time. +- [[Entropy Volume]] — the array that is always full. diff --git a/serverAppConfigs-docs/Dankserver/Media Metadata View.md b/serverAppConfigs-docs/Dankserver/Media Metadata View.md index 71aceb2..dd29f8d 100644 --- a/serverAppConfigs-docs/Dankserver/Media Metadata View.md +++ b/serverAppConfigs-docs/Dankserver/Media Metadata View.md @@ -3,7 +3,7 @@ type: reference title: Media Metadata View description: "The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback." tags: [dankserver, media-server, jellyfin, plex, storage] -timestamp: 2026-10-30T00:00:00Z +timestamp: 2026-11-01T01:00:00Z --- # Media Metadata View @@ -20,18 +20,23 @@ The mount point is not a volume and holds nothing while unmounted. It sits insid merged door are one directory — and so the mount table grows no new `/mnt` entry next to the array. -**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh -status` answers `view: /var/lib/media-meta/view NOT MOUNTED`, -`/var/lib/media-meta` does not exist, and the installed quadlets under -`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro` -for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below -is the shape git describes, not the shape the servers run. The cutover is an -ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs -`ensure_media_view` (`install` then `verify`) before it restarts the container, -so it must not be fired through the middle of a library scan. Until that deploy -lands there is no upper layer, so no relayout can leave ghost metadata and -`reap` has nothing to do — `media-apply.py` prints that follow-up only when the -view really is a mountpoint. +**Deployed on Dankserver — verified 2026-11-01.** `mount` shows the overlay on +`/var/lib/media-meta/view` (`lowerdir=/mnt/entropy`, +`upperdir=/var/lib/media-meta/upper`), and the installed quadlets under +`~/.config/containers/systemd/` carry the view binds: +`Volume=/var/lib/media-meta/view:/data` for [[Jellyfin]], +`Volume=/var/lib/media-meta/view:/mnt/entropy:ro` for [[Plex]]. Prove it in that +order: `scripts/media-view.sh verify` (the deploy gate), then +`grep -n Volume ~/.config/containers/systemd/{jellyfin,plex}.container`. + +The cutover itself is an ordinary deploy: `deploy.sh dankserver --service +jellyfin|plex` runs `ensure_media_view` (`install` then `verify`) before it +restarts the container, so it must not be fired through the middle of a library +scan. Two consequences are now live rather than theoretical: inotify events for +array writes do not cross the merge, so any relayout or prune needs a manual +library refresh; and a move can leave ghost sidecars above, so `reap` runs after +a relayout — a pass that moves only media files with no sidecars next to them +leaves the upper layer untouched, and `reap` correctly finds nothing. ## Shape diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 7dec544..97a6a5b 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -3,7 +3,7 @@ type: reference title: Media Relayout description: "Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback." tags: [dankserver, media-server, jellyfin, plex, procedure] -timestamp: 2026-10-30T00:00:00Z +timestamp: 2026-11-01T01:00:00Z --- # Media Relayout @@ -109,6 +109,8 @@ HTTPD close without a single byte; and the same HTTPD drops requests that carry no `Accept` and client-identifier header (see the scan block above for the form that works). +A duplicate prune is the same census and the same proof, with its own keep rules: [[Media Duplicate Prune]]. + ## TV in the movie library A box set that arrives in the movie share stays there until someone moves it: diff --git a/serverAppConfigs-docs/Dankserver/Plex.md b/serverAppConfigs-docs/Dankserver/Plex.md index 9cc843e..9b2c683 100644 --- a/serverAppConfigs-docs/Dankserver/Plex.md +++ b/serverAppConfigs-docs/Dankserver/Plex.md @@ -27,7 +27,7 @@ public traffic can be fronted by Caddy like every other vhost. | Runtime | podman quadlet, `plex.service` (user manager) — migrated from the native `plexmediaserver.service` OS-package unit (2026-09) | | Image | `lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329` (pinned; verified == the registry's `latest` manifest digest at pin time: `sha256:8f655bad…e9e2334`) | | Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes | -| Media | git: `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only**; live today: `/mnt/entropy` itself, read-only — the view has never been installed. Either way every library path in the PMS database stays valid, zero re-scan | +| Media | `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only** — live since the 2026-10-31 cutover, so the PMS database's recorded paths keep resolving with zero re-scan | | Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover | | Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration | | Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` | @@ -40,7 +40,7 @@ public traffic can be fronted by Caddy like every other vhost. The migration moved zero bytes: the container bind-mounts the native data root where it already lived, and the media archive re-mounts at `/mnt/entropy` inside the container — only the HOST side of that bind has moved -(the array today; the overlay view in git has never been deployed), so every +(the array until the 2026-10-31 view cutover, the overlay since), so every absolute library path in the PMS database keeps resolving. The one metadata change: rootless podman cannot map the real host uid 997, so the fleet model applies — container @@ -87,6 +87,16 @@ root runs as the `dank` user, and the deployer adopts the data root with while the HTTP layer finishes loading; the first boot of a years-old library took ~10 minutes (2026-09 cutover). The deployer gate now allows 600 s before failing; later boots are much faster (warm page cache). +5. **`32400` answers TLS only to remote clients.** PMS requires secure + connections, so `curl http://192.168.0.235:32400/identity` from any other + machine closes without a single byte (curl exit 52) — that reads as a dead + server beside a healthy one, and it is how a post-cutover health check gets + misread. Probe with `curl -k https://192.168.0.235:32400/identity`. Two + consequences: `plex.franksdank.cloud` works **only** because its `transport + http` block carries `tls_insecure_skip_verify` (that option makes Caddy speak + TLS to PMS's self-signed cert — delete it and the vhost 502s), and the scan + block below rides `http://127.0.0.1` **inside** the container, where insecure + connections are allowed. ## Triggering a scan diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index fda0026..1a0172f 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -51,6 +51,7 @@ is a locator: one row, one clause, no numbers. | [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at | | [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. | | [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. | +| [[Media Duplicate Prune]] | Find and hold duplicate films on the entropy array: the hash ladder, the keep rules, the guards, the purge. | | [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. | | [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. | | [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman | diff --git a/tests/media_verify.bats b/tests/media_verify.bats index 1d9f8e1..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,5 +208,75 @@ 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 +# 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" ] +} + +# 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"* ]] }