Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
61660e5
Scan root folders on add/enable and fix matching release-named folders
claude Oct 7, 2026
173e309
Remove PR screenshots
claude Oct 7, 2026
9423412
Queue a rescan when a root folder changes during an active scan
claude Oct 7, 2026
323d907
Keep polling scan status after kickoff and refresh games on scan prog…
claude Oct 7, 2026
d0c053d
Scan root folders when post-processing is switched on
claude Oct 7, 2026
5880bbe
Remove PR screenshots
claude Oct 7, 2026
fcb0113
Include scan timestamps in the scan progress snapshot
claude Oct 7, 2026
25da5c2
Keep polling scan status after a settings save starts a scan
claude Oct 7, 2026
2c27145
Keep scan status busy across a multi-folder batch
claude Oct 7, 2026
a9c5556
Refresh the games list everywhere after a scan adds games
claude Oct 7, 2026
d97609b
Push scan progress to Discover and survive batch failures
claude Oct 7, 2026
aaccd1e
Let users search IGDB by name for folders awaiting review
claude Oct 7, 2026
06fe73c
Remove PR screenshots for the IGDB name search
claude Oct 7, 2026
b42d009
Refresh clients even when a scan fails after adding games
claude Oct 7, 2026
e043791
Harden the IGDB name search and deferred rescans
claude Oct 7, 2026
f61791e
Keep earlier IGDB candidates matchable and refresh after any game write
claude Oct 7, 2026
51ff422
Flag the scan before its first game write
claude Oct 7, 2026
6e90b3c
Move root folder discovery to a Library Import page in the sidebar
claude Oct 7, 2026
a25dfd3
Remove Library Import screenshots used in the PR description
claude Oct 7, 2026
01cef95
Open the Library Import page in the root folder e2e journey
claude Oct 7, 2026
d24f7be
Merge branch 'main' into claude/project-thread-uxru5l
Doezer Oct 10, 2026
f751473
Use theme colors for Library Import header and add light-theme evidence
Doezer Oct 10, 2026
2f6bc1d
Refresh library games only for new matches or finished scans
Doezer Oct 10, 2026
5989642
Remove PR screenshots as requested
Doezer Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/__tests__/AppSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@ describe("AppSidebar library counts", () => {
expect(screen.queryByTestId("badge-playing")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Playing" })).toBeInTheDocument();
});

it("lists Library Import under Library", () => {
renderSidebar([]);

expect(screen.getByRole("button", { name: "Library Import" })).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions client/__tests__/MobileBottomNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock("lucide-react", () => {
Compass: Icon,
Database: Icon,
Download: Icon,
FolderInput: Icon,
Gamepad2: Icon,
HardDrive: Icon,
Home: Icon,
Expand Down
148 changes: 147 additions & 1 deletion client/__tests__/RootFolderDiscovery.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand All @@ -9,6 +9,11 @@ import { createTestQueryClient, getRequestUrl } from "./test-utils";
import { RootFolderDiscovery } from "../src/components/RootFolderDiscovery";
import type { RootFolder } from "@shared/schema";

const mockSocket = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }));
vi.mock("@/lib/socket", () => ({
getSocket: () => mockSocket,
}));

const mockToast = vi.fn();
vi.mock("@/hooks/use-toast", () => ({
useToast: () => ({ toast: mockToast }),
Expand Down Expand Up @@ -67,6 +72,147 @@ describe("RootFolderDiscovery", () => {
vi.restoreAllMocks();
});

it("refreshes the library and Needs Review list once scan progress is known", async () => {
mockFetch(
[folder],
[
{
rootFolderId: "rf-1",
rootFolderPath: "/mnt/old-library",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
status: "completed",
totalCandidates: 3,
processedCandidates: 3,
matched: 2,
unmatched: 1,
errors: 0,
},
]
);
const client = createTestQueryClient();
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<RootFolderDiscovery />
</QueryClientProvider>
);

await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["/api/games"] }));
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["/api/library/scan/unmatched"] });
});

it("keeps polling scan status after Scan All even if the first status is empty", async () => {
// The first status fetch lands before the server registers the scan.
let statusCalls = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (url: RequestInfo | URL) => {
const u = getRequestUrl(url);
if (u.includes("/api/library/scan/status")) {
statusCalls += 1;
return createJsonResponse(
statusCalls === 1
? []
: [
{
rootFolderId: "rf-1",
rootFolderPath: "/mnt/old-library",
startedAt: new Date().toISOString(),
status: "running",
totalCandidates: 3,
processedCandidates: 1,
matched: 1,
unmatched: 0,
errors: 0,
},
]
);
}
if (u.includes("/api/root-folders")) return createJsonResponse([folder]);
return createJsonResponse([]);
});
const client = createTestQueryClient();
const invalidate = vi.spyOn(client, "invalidateQueries");
render(
<QueryClientProvider client={client}>
<RootFolderDiscovery />
</QueryClientProvider>
);
fireEvent.click(await screen.findByRole("button", { name: /scan all/i }));

await waitFor(() => expect(statusCalls).toBeGreaterThan(1), { timeout: 4000 });
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ["/api/games"] }));
});

it("shows scan progress pushed by the server without waiting for a poll", async () => {
mockFetch([folder]);
renderComponent();
await screen.findByText("/mnt/old-library");

const handler = mockSocket.on.mock.calls.find(
([event]) => event === "library-scan-progress"
)?.[1];
expect(handler).toBeDefined();
act(() => {
handler({
rootFolderId: "rf-1",
rootFolderPath: "/mnt/scan-pushed",
startedAt: new Date().toISOString(),
status: "running",
totalCandidates: 4,
processedCandidates: 1,
matched: 1,
unmatched: 0,
errors: 0,
});
});

expect(await screen.findByText("/mnt/scan-pushed")).toBeInTheDocument();
expect(screen.getByText("running")).toBeInTheDocument();
});

it("lets the user search IGDB under their own name when no candidate fits", async () => {
const { apiRequest } = await import("@/lib/queryClient");
mockFetch(
[folder],
[],
[
{
rootFolderId: "rf-1",
rootFolderPath: "/mnt/old-library",
folderName: "Absolum v1.01 [CUSA53342] [EUR]",
absolutePath: "/mnt/old-library/Absolum v1.01 [CUSA53342] [EUR]",
candidates: [{ igdbId: 314, name: "Wrong Game", releaseYear: 2001 }],
},
]
);
vi.mocked(apiRequest).mockResolvedValueOnce({
json: async () => [{ igdbId: 777, name: "Absolum", releaseYear: 2025 }],
} as Response);
renderComponent();

const input = await screen.findByLabelText("Search IGDB for Absolum v1.01 [CUSA53342] [EUR]");
fireEvent.change(input, { target: { value: "Absolum" } });
fireEvent.click(screen.getByRole("button", { name: "Search" }));

await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("POST", "/api/library/scan/unmatched/search", {
rootFolderId: "rf-1",
folderName: "Absolum v1.01 [CUSA53342] [EUR]",
query: "Absolum",
})
);
// The searched result replaces the scan's guess and can be picked.
fireEvent.click(await screen.findByRole("button", { name: "Absolum (2025)" }));
expect(screen.queryByRole("button", { name: "Wrong Game (2001)" })).not.toBeInTheDocument();
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("POST", "/api/library/scan/unmatched/match", {
rootFolderId: "rf-1",
folderName: "Absolum v1.01 [CUSA53342] [EUR]",
igdbId: 777,
})
);
});

it("shows an empty state when there are no root folders", async () => {
renderComponent();
expect(await screen.findByText("No root folders configured yet")).toBeInTheDocument();
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const NotFound = lazy(() => import("@/pages/not-found"));
const CalendarPage = lazy(() => import("@/pages/calendar"));
const WishlistPage = lazy(() => import("@/pages/wishlist"));
const PlayingPage = lazy(() => import("@/pages/playing"));
const LibraryImportPage = lazy(() => import("@/pages/library-import"));
const XrelReleasesPage = lazy(() => import("@/pages/xrel-releases"));
const RssPage = lazy(() => import("@/pages/rss"));
const LoginPage = lazy(() => import("@/pages/auth/login"));
Expand Down Expand Up @@ -132,6 +133,7 @@ function Router() {
<Route path={routePaths.calendar} component={CalendarPage} />
<Route path={routePaths.wishlist} component={WishlistPage} />
<Route path={routePaths.playing} component={PlayingPage} />
<Route path={routePaths.libraryImport} component={LibraryImportPage} />
<Route path={routePaths.xrel} component={XrelReleasesPage} />
<Route path={routePaths.rss} component={RssPage} />
<Route path={routePaths.stats} component={StatsPage} />
Expand Down
21 changes: 13 additions & 8 deletions client/src/components/ImportSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import type { ImportConfig, UserSettings } from "@shared/schema";
import { selectedPlatformNames as resolveSelectedPlatformNames } from "@shared/platforms";
import { PathMappingSettings } from "./PathMappingSettings";
import { FileBrowser } from "./FileBrowser";
import { RootFolderDiscovery } from "./RootFolderDiscovery";
import SecurityScanSettings from "./SecurityScanSettings";

type HardlinkPairCheck = {
Expand Down Expand Up @@ -104,10 +103,21 @@ export default function ImportSettings() {
const { importPlatformIds: _unused, ...rest } = data;
await apiRequest("PATCH", "/api/imports/config", rest);
},
onSuccess: () => {
toast({ title: "Settings Saved", description: "Import configuration updated." });
onSuccess: (_data, saved) => {
// Switching post-processing on starts a scan of every enabled root folder.
const scanStarted = saved.enablePostProcessing && !config?.enablePostProcessing;
toast({
title: "Settings Saved",
description: scanStarted
? "Import configuration updated. Scanning your root folders for games now."
: "Import configuration updated.",
});
queryClient.invalidateQueries({ queryKey: ["/api/imports/config"] });
queryClient.invalidateQueries({ queryKey: ["/api/imports/hardlink/check"] });
if (scanStarted) {
queryClient.invalidateQueries({ queryKey: ["/api/library/scan/status"] });
queryClient.invalidateQueries({ queryKey: ["/api/library/scan/unmatched"] });
}
Comment thread
Doezer marked this conversation as resolved.
},
onError: () => {
if (config) setLocalConfig(config);
Expand All @@ -134,7 +144,6 @@ export default function ImportSettings() {
<TabsTrigger value="config">General Config</TabsTrigger>
<TabsTrigger value="paths">Path Mappings</TabsTrigger>
<TabsTrigger value="security">Security & Scanning</TabsTrigger>
<TabsTrigger value="discover">Discover</TabsTrigger>
<TabsTrigger value="help">Help</TabsTrigger>
</TabsList>

Expand Down Expand Up @@ -379,10 +388,6 @@ export default function ImportSettings() {
<SecurityScanSettings />
</TabsContent>

<TabsContent value="discover" className="space-y-4">
<RootFolderDiscovery />
</TabsContent>

<TabsContent value="help" className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-6 text-sm">
Expand Down
Loading
Loading