diff --git a/web/src/screens/LibrariesScreen.test.tsx b/web/src/screens/LibrariesScreen.test.tsx
index df72f8f39..e68f644e0 100644
--- a/web/src/screens/LibrariesScreen.test.tsx
+++ b/web/src/screens/LibrariesScreen.test.tsx
@@ -186,6 +186,29 @@ 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 () => {
+ mockApi({
+ mediaSources: [
+ mediaSource({
+ libraries: [
+ library({ id: 31, itemCount: 0, lastScan: '0001-01-01T00:00:00', mediaKind: 'Movies', name: 'Movies' }),
+ library({ id: 32, itemCount: 0, lastScan: null, mediaKind: 'Shows', name: 'TV Shows' })
+ ]
+ })
+ ]
+ });
+
+ render();
+
+ 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 () => {
mockApi({
libraryScanStatuses: [
@@ -255,8 +278,18 @@ describe('LibrariesScreen', () => {
const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/v1/media-sources');
await runPollTick(intervalHandlers);
- expect(screen.queryByText('75%')).not.toBeInTheDocument();
- expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
+ // loadScanStatuses (the interval handler) kicks off its own Promise.all(...).then(...) chain
+ // (libraries.ts) rather than being itself async/awaitable, so `handler()` returns void - the
+ // state update that clears the progress bar and the follow-up loadSources() refetch both land a
+ // few microtask ticks after runPollTick's `act(async () => ...)` callback has already resolved.
+ // Asserting synchronously right after the tick races that chain (#447); waitFor lets both
+ // settle before asserting, without weakening either check.
+ await waitFor(() => {
+ expect(screen.queryByText('75%')).not.toBeInTheDocument();
+ });
+ await waitFor(() => {
+ expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
+ });
});
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
diff --git a/web/src/screens/LibrariesScreen.tsx b/web/src/screens/LibrariesScreen.tsx
index cba971637..534c8bc35 100644
--- a/web/src/screens/LibrariesScreen.tsx
+++ b/web/src/screens/LibrariesScreen.tsx
@@ -394,7 +394,7 @@ function LibraryRow({
{library.name}
{formatLibraryMediaKind(library.mediaKind)} ยท {library.itemCount.toLocaleString()} items
- {library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}
+ {hasScanned(library.lastScan) ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}
{scanStatus ? (
@@ -465,12 +465,33 @@ function formatLibraryMediaKind(kind: string): string {
function sourceLastScanLabel(source: MediaSource): string {
const scans = source.libraries
.map((library) => library.lastScan)
- .filter((scan): scan is string => Boolean(scan))
+ .filter((scan): scan is string => hasScanned(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.