Scanner has no anti-nuke guard: a successful-but-empty media-server fetch flags an entire library FileNotFound #477
Closed
opened 2026-07-19 20:59:32 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#477
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found while working #473 (2026-07-19). Latent, but data-loss-adjacent — filing so it is not rediscovered the hard way.
The gap
All three media-server scanners reconcile with
existing.Except(incomingItemIds)→FlagFileNotFound...:ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs:173-182, 369-371, 536-538.../MediaServerMovieLibraryScanner.cs:205-207.../MediaServerOtherVideoLibraryScanner.cs:212-214If the remote fetch returns successfully but empty,
incomingItemIdsis[], soexisting.Except([])is every item in the library and all of them are flaggedFileNotFound.There is no threshold/ratio check, no "abort if incoming count is 0", and no comparison against the server's reported total anywhere in the three scanners. The only guard present is the trivially-inverted one —
if (showItemIds.Count == 0) return [];(JellyfinTelevisionRepository.cs:365-368, 397-400, 429-432) — which short-circuits when nothing is missing, not when everything is.Why a successful-but-empty fetch is reachable
JellyfinApiClient.GetPagedLibraryItems(ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs:369-404) computespagesfromTotalRecordCount; aTotalRecordCountof 0 yieldspages = 0and a clean empty enumeration — no exception, so the sweep proceeds normally. A media server that is up but mid-restore / mid-library-rebuild / returning an emptied library is exactly this case.MediaServerTelevisionLibraryScanner.cs:67-70catches onlyTaskCanceledException/OperationCanceledException. It works, but by accident of control flow.ProjectToShow/ProjectToEpisodefailures are silently dropped by.Somes()(JellyfinApiClient.cs:400-403), so an item the server did return but ErsatzTV failed to project is indistinguishable from "deleted upstream" at the reconciliation step. A projection regression could therefore mass-flag healthy items.Blast radius
FileNotFoundis not itself destructive, but it feeds two consumers that are:EmptyTrashHandler(ErsatzTV.Application/Maintenance/Commands/) searchesstate:FileNotFound(cap 10,000) and calls_mediaItemRepository.DeleteItems(ids)— a user clicking Empty Trash after a bad scan permanently deletes the library rows.PlayoutBuilder'sPlayoutSkipMissingItemsskip would empty every affected collection.Suggested fix
A sanity guard before each sweep, e.g. refuse to flag when
incomingItemIds.Count == 0whileexisting.Count > 0, or when the missing fraction exceeds some threshold — log loudly and skip the sweep instead. Make the guard explicit rather than relying on an exception escaping. Worth also making a projection failure distinguishable from a deletion (count them, and skip the sweep if any occurred).Done-when
ErsatzTV.Scanner.Testscovering: empty incoming + non-empty existing → no flag writes; normal partial-deletion case still flagsClaiming (Claude Code orchestrator session, 2026-07-19). Deterministic selector (
scripts/select-queue.sh) ranked this behind #350 (skipped — its successor #472 isin-progress) and #474 (primary fix is a prod config/content change, deferred to operator/media-management scope). #477 is the lowest-numbered genuine, self-contained ErsatzTV code bug: data-loss-adjacent scanner reconciliation gap. Working it under the full## Done-when(explicit+logged guard, tests for empty-incoming vs partial-deletion, all three media-server scanners, projection-failure decision recorded).PR #483 open — implementation summary
Fix: shared
MediaServerReconciliationGuard.ShouldFlagMissingskips (and logs a Warning) the file-not-found sweep when a successful fetch returns zero items while items exist locally. Wired into the three library-level sweeps (Television shows / Movie / OtherVideo).Key subtlety — interaction with #476: an empty incoming set is indistinguishable at scan time from a mid-restore error (both report a zero total), so this deliberately overrides #476's degenerate "last item removed => empty incoming => flag" case. #476's cascade still fires for partial deletions (survivors present); its characterization test was moved from an empty incoming to a survivor+removed partial-deletion case so it still exercises the cascade.
Scope decisions (Done-when):
JellyfinApiClient— larger cross-layer change. Filed as follow-up #484 (with ratio-threshold). The deterministic zero-count guard has no false positives and covers the reported catastrophic case.Tests: policy table + per-scanner integration (empty incoming flags/reindexes nothing); proven non-vacuous by neutralizing the guard. Full
ErsatzTV.Scanner.Tests: 1481 passed; full solution build: 0 errors.Incidental: filed #485 —
.gitignorecoresilently ignores new files under anyCore/dir on macOS (bit this PR; force-added).Awaiting CI + independent adversarial review before requesting merge.
Closed — merged to main via PR #483 (
55be64bd)What was done: added a shared
MediaServerReconciliationGuard.ShouldFlagMissingthat skips (and logs a Warning) the file-not-found sweep when a successful media-server fetch returns zero items while items exist locally. Wired into the three library-level sweeps (MediaServer{Television,Movie,OtherVideo}LibraryScanner).Root cause: the sweeps reconcile deletions as
existing.Except(incomingItemIds). A successful-but-empty fetch (server mid-restore / library emptied upstream) makesincomingItemIdsempty, soexisting.Except([])is every item → the whole library flaggedFileNotFoundin one scan, whichEmptyTrashHandlercan then permanently delete andPlayoutSkipMissingItemsempties. The only prior protection was an exception incidentally unwinding past the flag step — protection by accident of control flow, not design.Files changed:
MediaServerReconciliationGuard.cs(new) + guard call in the 3 scanners;MediaServerReconciliationGuardTests.cs,MediaServerMovieLibraryScannerTests.cs,MediaServerOtherVideoLibraryScannerTests.cs(new);MediaServerTelevisionLibraryScannerTests.cs(#476 test rewritten to a survivor+removed partial deletion);docs/decisions.md.Key decision — #476 interaction: an empty incoming set is indistinguishable at scan time from a mid-restore error, so this deliberately overrides #476's degenerate "last item removed => empty incoming => flag" case. #476's cascade still fires for partial deletions (survivors present). Documented in decisions.md.
Deferred: nested TV season/episode sweeps left unguarded (bounded blast radius); ratio-threshold + projection-failure detection → follow-up #484.
Incidental: #485 filed for the
.gitignorecoreentry that silently ignores new files under anyCore/dir on macOS (bit this PR; force-added).Verification: 1481 scanner tests green, full solution 0 errors, tests proven non-vacuous via negative control, independent cold-context adversarial review returned MERGEABLE, CI green.