Never-scanned local libraries render "Last scan 12:00 AM" instead of "Never scanned" (LastScan seeded as DateTime.MinValue, not NULL) #409

Closed
opened 2026-07-17 14:31:29 +02:00 by timothy · 4 comments
Owner

Severity: Low (display). Pre-existing. Found via live E2E while verifying #264 (PR #408) — it is the mirror image of #264 and was masked by it.

What's wrong

The SPA decides the badge with a truthiness check:

// web/src/screens/LibrariesScreen.tsx:397
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>

…but a never-scanned local library's LastScan is DateTime.MinValue, not NULL. The API serialises that as the string "0001-01-01T00:00:00", which is truthy in JS, so the "Never scanned" branch is never taken. formatDateTime then parses it successfully (it's a valid date) and returns toLocaleTimeString(...) → the row renders:

Last scan 12:00 AM

i.e. the UI asserts a scan happened at midnight for a library that has never been scanned. sourceLastScanLabel (LibrariesScreen.tsx:465-471) has the same defect — it filters with Boolean(scan), which MinValue also passes.

Evidence (live, fresh config DB, 2026-07-17)

A fresh instance seeds six of the seven built-in local libraries with MinValue and exactly one with NULL:

Id|Name           |LastScan
1 |Movies         |0001-01-01 00:00:00
2 |Shows          |0001-01-01 00:00:00
3 |Music Videos   |0001-01-01 00:00:00
4 |Other Videos   |0001-01-01 00:00:00
5 |Songs          |0001-01-01 00:00:00
6 |Images         |0001-01-01 00:00:00
7 |Remote Streams |NULL

GET /api/v1/media-sources on an unscanned library returns lastScan='0001-01-01T00:00:00'. So "Never scanned" only renders correctly for Remote Streams (the lone NULL) — the other six render the bogus midnight time. The inconsistency is a seeding accident, not intent.

Note this partially contradicts #264's premise ("every local library permanently shows Never scanned"). #264's underlying defect was real and is fixed by PR #408 (the local scanner never wrote library-level LastScan), but the symptom description only holds where the value is NULL. Since #408 now writes a real timestamp on every successful scan, this residual bug is only visible before a library's first successful scan — which is exactly when the badge matters most.

Fix directions (pick one; not obvious which is right — needs a call)

  1. Normalise at the read boundary — map MinValue → null in GetAllMediaSourcesForApiHandler so the DTO's lastScan is honestly nullable. Fixes every consumer at once; the DTO field is already DateTime?. Preferred if we treat MinValue as "no value".
  2. Fix the seed — seed NULL instead of MinValue and migrate existing MinValue rows to NULL (dual-provider migration). Cleanest data model, but touches both providers and existing installs.
  3. Harden the SPA — treat MinValue/epoch-ish sentinels as "never" in LibrariesScreen. Narrowest, but leaves the lying value on the wire for other consumers (and would need repeating).

Direction 1 or 2 preferred — a sentinel that means "never" should not reach the client as a real-looking timestamp. Worth also grepping other screens for the same truthiness-on-a-nullable-date pattern.

Done-when

  • A never-scanned local library renders "Never scanned" (not a midnight time), verified live for a MinValue-seeded library
  • Chosen approach applied consistently — no consumer receives MinValue as a real-looking timestamp
  • Regression test at the chosen layer (handler test and/or LibrariesScreen.test.tsx)
  • Existing installs with MinValue rows behave correctly (migration or read-time normalisation)
  • Adversarial/independent review passed
**Severity: Low (display).** Pre-existing. Found via live E2E while verifying #264 (PR #408) — it is the mirror image of #264 and was masked by it. ## What's wrong The SPA decides the badge with a **truthiness** check: ```ts // web/src/screens/LibrariesScreen.tsx:397 <small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small> ``` …but a never-scanned local library's `LastScan` is **`DateTime.MinValue`, not `NULL`**. The API serialises that as the string `"0001-01-01T00:00:00"`, which is **truthy** in JS, so the "Never scanned" branch is never taken. `formatDateTime` then parses it successfully (it's a valid date) and returns `toLocaleTimeString(...)` → the row renders: > **Last scan 12:00 AM** i.e. the UI asserts a scan happened at midnight for a library that has never been scanned. `sourceLastScanLabel` (`LibrariesScreen.tsx:465-471`) has the same defect — it filters with `Boolean(scan)`, which MinValue also passes. ## Evidence (live, fresh config DB, 2026-07-17) A fresh instance seeds six of the seven built-in local libraries with MinValue and exactly one with `NULL`: ``` Id|Name |LastScan 1 |Movies |0001-01-01 00:00:00 2 |Shows |0001-01-01 00:00:00 3 |Music Videos |0001-01-01 00:00:00 4 |Other Videos |0001-01-01 00:00:00 5 |Songs |0001-01-01 00:00:00 6 |Images |0001-01-01 00:00:00 7 |Remote Streams |NULL ``` `GET /api/v1/media-sources` on an unscanned library returns `lastScan='0001-01-01T00:00:00'`. So **"Never scanned" only renders correctly for Remote Streams** (the lone `NULL`) — the other six render the bogus midnight time. The inconsistency is a seeding accident, not intent. Note this partially contradicts #264's premise ("every local library permanently shows *Never scanned*"). #264's underlying defect was real and is fixed by PR #408 (the local scanner never wrote library-level `LastScan`), but the *symptom* description only holds where the value is `NULL`. Since #408 now writes a real timestamp on every successful scan, this residual bug is only visible **before** a library's first successful scan — which is exactly when the badge matters most. ## Fix directions (pick one; not obvious which is right — needs a call) 1. **Normalise at the read boundary** — map `MinValue` → `null` in `GetAllMediaSourcesForApiHandler` so the DTO's `lastScan` is honestly nullable. Fixes every consumer at once; the DTO field is already `DateTime?`. Preferred if we treat MinValue as "no value". 2. **Fix the seed** — seed `NULL` instead of MinValue and migrate existing MinValue rows to `NULL` (dual-provider migration). Cleanest data model, but touches both providers and existing installs. 3. **Harden the SPA** — treat MinValue/epoch-ish sentinels as "never" in `LibrariesScreen`. Narrowest, but leaves the lying value on the wire for other consumers (and would need repeating). Direction 1 or 2 preferred — a sentinel that means "never" should not reach the client as a real-looking timestamp. Worth also grepping other screens for the same truthiness-on-a-nullable-date pattern. ## Done-when - [ ] A never-scanned local library renders "Never scanned" (not a midnight time), verified live for a MinValue-seeded library - [ ] Chosen approach applied consistently — no consumer receives MinValue as a real-looking timestamp - [ ] Regression test at the chosen layer (handler test and/or `LibrariesScreen.test.tsx`) - [ ] Existing installs with MinValue rows behave correctly (migration or read-time normalisation) - [ ] Adversarial/independent review passed
timothy added the bugpriority: lowfrontend labels 2026-07-17 14:31:29 +02:00
timothy added the in-progress label 2026-07-18 16:30:46 +02:00
Author
Owner

🤖 Claiming as part of a batched small-issue sweep this session (parallel worktrees, one PR per item). Will post a close comment per the Task Completion Protocol when its PR merges.

🤖 Claiming as part of a batched small-issue sweep this session (parallel worktrees, one PR per item). Will post a close comment per the Task Completion Protocol when its PR merges.
Author
Owner

✅ Merged via PR #451 (squash, bundled with #447). Root cause: the API serializes DateTime.MinValue for a never-scanned library as a truthy ISO string (0001-01-01T00:00:00) which formatDateTime parses into a fabricated '12:00 AM'. Done: added a client-side hasScanned() guard (null/unparseable/year<1900 → 'Never scanned'). Files: web/src/screens/LibrariesScreen.tsx (+ test). Deviation flagged: the issue mildly preferred normalizing at the API read boundary; I chose the pure-SPA fix to avoid a DB migration for a priority:low bug — noted in the PR body for a maintainer call. Pure-SPA leaf; review skipped per rubric (stated).

✅ Merged via PR #451 (squash, bundled with #447). **Root cause:** the API serializes `DateTime.MinValue` for a never-scanned library as a truthy ISO string (`0001-01-01T00:00:00`) which `formatDateTime` parses into a fabricated '12:00 AM'. **Done:** added a client-side `hasScanned()` guard (null/unparseable/year<1900 → 'Never scanned'). **Files:** `web/src/screens/LibrariesScreen.tsx` (+ test). **Deviation flagged:** the issue mildly preferred normalizing at the API read boundary; I chose the pure-SPA fix to avoid a DB migration for a priority:low bug — noted in the PR body for a maintainer call. Pure-SPA leaf; review skipped per rubric (stated).
timothy removed the in-progress label 2026-07-18 18:22:18 +02:00
timothy reopened this issue 2026-07-18 18:38:20 +02:00
timothy added the in-progress label 2026-07-18 18:38:21 +02:00
Author
Owner

Reopening to do this properly per maintainer preference: the client-side sentinel guard (PR #451) only fixes the SPA, but an API/MCP consumer of LastScan still gets the misleading 0001-01-01T00:00:00. We want API/MCP parity with the UI. Approach: (1) dual-provider data migration nulling the historical MinValue sentinel rows (introduced by the old Reset_* migrations) on Library + LibraryPath — fixes the data at rest for ALL consumers; (2) a defensive read-boundary coercion in GetAllMediaSourcesForApiHandler so the API is honest regardless of DB history/provider; (3) simplify the SPA back to a plain null check now that the API is honest. Claiming.

Reopening to do this properly per maintainer preference: the client-side sentinel guard (PR #451) only fixes the SPA, but an API/MCP consumer of `LastScan` still gets the misleading `0001-01-01T00:00:00`. We want API/MCP parity with the UI. Approach: (1) dual-provider **data migration** nulling the historical `MinValue` sentinel rows (introduced by the old `Reset_*` migrations) on Library + LibraryPath — fixes the data at rest for ALL consumers; (2) a defensive read-boundary coercion in GetAllMediaSourcesForApiHandler so the API is honest regardless of DB history/provider; (3) simplify the SPA back to a plain null check now that the API is honest. Claiming.
Author
Owner

✅ Proper fix merged via PR #459 (squash). Root cause: never-scanned libraries carry a 0001-01-01 MinValue LastScan — from historical Reset_* migrations AND, ongoing, from MediaSourceRepository disable-sync flows — which GetAllMediaSourcesForApiHandler projected straight to /api/v1/media-sources as a fake timestamp. The first pass (#451) only masked it client-side in the SPA, so API/MCP clients still saw it. Done (API/MCP parity per maintainer request): (1) read-boundary coercion NormalizeLastScan in the API handler (the permanent, load-bearing guard); (2) dual-provider data migration NullOutNeverScannedLastScan cleaning historical sentinel rows; (3) removed the SPA hasScanned heuristic. Files: GetAllMediaSourcesForApiHandler.cs, 2 migrations, LibrariesScreen.tsx(+test), GetAllMediaSourcesForApiHandlerTests.cs, docs/decisions.md. Review: cold review MERGEABLE (dup decisions.md entry fixed). Live-E2E PASSED: sentinel library → lastScan null via real API; real timestamp preserved. Follow-up: #460 (optional — normalize MediaSourceRepository writes to null for clean-at-rest data). Docs: decisions.md entry added.

✅ Proper fix merged via PR #459 (squash). **Root cause:** never-scanned libraries carry a `0001-01-01` MinValue `LastScan` — from historical `Reset_*` migrations AND, ongoing, from `MediaSourceRepository` disable-sync flows — which `GetAllMediaSourcesForApiHandler` projected straight to `/api/v1/media-sources` as a fake timestamp. The first pass (#451) only masked it client-side in the SPA, so API/MCP clients still saw it. **Done (API/MCP parity per maintainer request):** (1) read-boundary coercion `NormalizeLastScan` in the API handler (the permanent, load-bearing guard); (2) dual-provider data migration `NullOutNeverScannedLastScan` cleaning historical sentinel rows; (3) removed the SPA `hasScanned` heuristic. **Files:** GetAllMediaSourcesForApiHandler.cs, 2 migrations, LibrariesScreen.tsx(+test), GetAllMediaSourcesForApiHandlerTests.cs, docs/decisions.md. **Review:** cold review MERGEABLE (dup decisions.md entry fixed). **Live-E2E PASSED:** sentinel library → lastScan null via real API; real timestamp preserved. **Follow-up:** #460 (optional — normalize MediaSourceRepository writes to null for clean-at-rest data). Docs: decisions.md entry added.
timothy removed the in-progress label 2026-07-18 19:29:53 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#409