Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 2977f86c25 fix(409): report never-scanned LastScan as null for API/MCP parity (data migration + read coercion + SPA simplify)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m25s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m11s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 7s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Data migration nulls the historical 0001-01-01 sentinel rows on Library/LibraryPath (both providers); GetAllMediaSourcesForApiHandler coerces any residual sentinel to null; SPA drops the client-side heuristic now that the API is honest.

fixes #409

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 21:20:18 +02:00
9 changed files with 14495 additions and 30 deletions
@@ -10,6 +10,16 @@ public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
// data migration.
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
@@ -36,7 +46,7 @@ public class GetAllMediaSourcesForApiHandler(
l.Id,
l.Name,
l.MediaKind,
l.LastScan,
NormalizeLastScan(l.LastScan),
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class NullOutNeverScannedLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// irreversible data migration; there is no way to recover the original sentinel values
}
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class NullOutNeverScannedLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// irreversible data migration; there is no way to recover the original sentinel values
}
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
@@ -66,6 +67,54 @@ public class GetAllMediaSourcesForApiHandlerTests
result.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Report_Sentinel_And_Null_LastScan_As_Null()
{
await using (TvContext context = _db.CreateContext())
{
var source = new LocalMediaSource
{
Libraries =
[
new LocalLibrary
{
Name = "Never Scanned Sentinel",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(1, 1, 1),
Paths = [MakePath("/media/sentinel", 0)]
},
new LocalLibrary
{
Name = "Never Scanned Null",
MediaKind = LibraryMediaKind.Movies,
LastScan = null,
Paths = [MakePath("/media/nullscan", 0)]
},
new LocalLibrary
{
Name = "Really Scanned",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
Paths = [MakePath("/media/scanned", 0)]
}
]
};
context.MediaSources.Add(source);
await context.SaveChangesAsync();
}
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
MediaSourceLibraryResponseModel[] libraries = result.Single().Libraries.ToArray();
libraries.Single(l => l.Name == "Never Scanned Sentinel").LastScan.ShouldBeNull();
libraries.Single(l => l.Name == "Never Scanned Null").LastScan.ShouldBeNull();
libraries.Single(l => l.Name == "Really Scanned").LastScan
.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
+37
View File
@@ -98,6 +98,7 @@ in-file entries.
- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293)
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
---
@@ -1905,3 +1906,39 @@ Build-once (a `compile` job producing a single artifact, consumed by `test`/`mig
unless the runner's artifact storage or network changes materially.
Refs: #398 (closed), #420, PR #455.
## 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel still appears in the
DB from **two** sources — and the second is ongoing, not historical:
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
2. **Live code still writes it today**: `MediaSourceRepository` sets `library.LastScan =
SystemTime.MinValueUtc` on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows
(`MediaSourceRepository.cs:480/611/976`). So the sentinel keeps being written during normal use.
`GetAllMediaSourcesForApiHandler` projected `l.LastScan` straight onto its `DateTime?` DTO, so that
sentinel leaked to API/MCP clients as a fake midnight timestamp — the SPA papered over it with a
client-side year<1900 heuristic (#409 first pass). Decision: the API is the right place to be honest, so
**never-scanned reports as null** for API/MCP parity with the UI, via two layers:
- **Read-boundary coercion — the load-bearing, ongoing guard** (provider/history-independent):
`GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. Because
source #2 above keeps writing the sentinel, this coercion is *permanent*, not a stopgap — a
migration-only fix would regress the next time a user toggles a library's sync off.
- **Data migration** (`NullOutNeverScannedLastScan`, dual-provider): a one-time cleanup of the historical
residue — `UPDATE Library/LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan <
'2000-01-01'`. Data-only (empty `Up`/`Down` otherwise, both `TvContextModelSnapshot.cs` byte-identical).
The `< '2000-01-01'` predicate matches the `0001-01-01` sentinel robustly on both providers (ISO-text
compare on SQLite, whatever the out-of-range zero date stored on MySQL — where no `Reset_*LastScan`
migration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real
scan predates ErsatzTV. `Down` is a no-op — the original sentinel is unrecoverable and worthless.
`GetAllMediaSourcesForApiHandler` is the **only** API/MCP-facing consumer of `LastScan` (grepped
`LastScan` under `ErsatzTV.Application/**/Queries` and `ErsatzTV/Controllers`); the other reads are
internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non-nullable
`DateTimeOffset` scan-comparison needs and never serializes it to a client. The DTO field was already
`DateTime?`, so the OpenAPI schema is unchanged (no regen). The SPA's `hasScanned` heuristic in
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
+5 -6
View File
@@ -186,12 +186,15 @@ describe('LibrariesScreen', () => {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object));
});
it('renders "Never scanned" for a DateTime.MinValue-seeded lastScan, not a bogus midnight time (#409)', async () => {
it('renders "Never scanned" for a null lastScan (#409)', async () => {
// The API reports never-scanned libraries as a null lastScan (not the historical
// 0001-01-01 MinValue sentinel - see docs/decisions.md #409), so the SPA only needs a
// plain null/truthy check here.
mockApi({
mediaSources: [
mediaSource({
libraries: [
library({ id: 31, itemCount: 0, lastScan: '0001-01-01T00:00:00', mediaKind: 'Movies', name: 'Movies' }),
library({ id: 31, itemCount: 0, lastScan: null, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 0, lastScan: null, mediaKind: 'Shows', name: 'TV Shows' })
]
})
@@ -201,12 +204,8 @@ describe('LibrariesScreen', () => {
render(<LibrariesScreen />);
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
// Both the never-scanned-MinValue library row and the never-scanned-null library row read
// "Never scanned" - a plain `Boolean(lastScan)` truthiness check would treat the MinValue string
// as a real scan and print "Last scan 12:00 AM" instead.
expect(screen.getAllByText('Never scanned').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText(/Last scan/)).not.toBeInTheDocument();
expect(screen.queryByText(/12:00 AM/)).not.toBeInTheDocument();
});
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
+2 -23
View File
@@ -394,7 +394,7 @@ function LibraryRow({
<div className="ctv-library-row-main">
<strong>{library.name}</strong>
<span><span>{formatLibraryMediaKind(library.mediaKind)}</span> · <code>{library.itemCount.toLocaleString()}</code> items</span>
<small>{hasScanned(library.lastScan) ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
</div>
<div className="ctv-library-row-status">
{scanStatus ? (
@@ -465,33 +465,12 @@ function formatLibraryMediaKind(kind: string): string {
function sourceLastScanLabel(source: MediaSource): string {
const scans = source.libraries
.map((library) => library.lastScan)
.filter((scan): scan is string => hasScanned(scan))
.filter((scan): scan is string => Boolean(scan))
.sort();
return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned';
}
// A never-scanned local library's LastScan is seeded as DateTime.MinValue (not NULL), which the API
// serialises as the truthy string "0001-01-01T00:00:00" - a plain `Boolean(scan)` truthiness check
// (the previous behavior here) treats that as a real scan and renders a bogus midnight time (#409).
// Detect the MinValue sentinel client-side alongside null/undefined so both render "Never scanned".
// (Server-side normalisation of the DTO was considered - see #409 - but the SPA already owns every
// consumer of this field, so a read-boundary fix here covers both call sites without touching the API.)
function hasScanned(scan: string | null | undefined): scan is string {
if (!scan) {
return false;
}
const parsed = new Date(scan);
if (Number.isNaN(parsed.getTime())) {
return false;
}
// DateTime.MinValue is year 0001; treat anything before year 1900 as "no real scan" rather than
// matching the exact sentinel string, so any equivalent epoch-ish placeholder is also caught.
return parsed.getUTCFullYear() >= 1900;
}
// Inlined rather than imported back from App.tsx (mirrors the ChannelsScreen #244 extraction):
// App.tsx's formatDateTime is still used by other App.tsx-owned screens (Playouts, Guide), so
// keeping this screen self-sufficient means duplicating the tiny formatter here.