Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2977f86c25 |
@@ -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();
|
||||
|
||||
|
||||
Generated
+7260
File diff suppressed because it is too large
Load Diff
+23
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7085
File diff suppressed because it is too large
Load Diff
+23
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user