From 49212809d6a08f424c8e43987c1acfd8a7b6e9bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:30:17 +0000 Subject: [PATCH 1/5] Initial plan From 99a028a286025d6ece895b40e41740b65396b662 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:38:46 +0000 Subject: [PATCH 2/5] Add autoOrganizeUnrecognized setting to prevent destructive file moves Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/server/src/services/jobs.ts | 12 +- apps/server/src/services/scanner.test.ts | 197 +++++++++++++++++- apps/server/src/services/scanner.ts | 17 +- .../shared/src/__tests__/constants.spec.ts | 2 + packages/shared/src/constants.ts | 1 + packages/shared/src/types.ts | 6 + 6 files changed, 220 insertions(+), 15 deletions(-) diff --git a/apps/server/src/services/jobs.ts b/apps/server/src/services/jobs.ts index 6436d0f..c785285 100644 --- a/apps/server/src/services/jobs.ts +++ b/apps/server/src/services/jobs.ts @@ -301,9 +301,15 @@ async function runScanJob(job: JobRecord): Promise { report.step("reorganize", 0.4, `Reorganizing ${unorganizedItems.length} items...`); logger.debug("Starting reorganization", { jobId: job.id, itemCount: unorganizedItems.length }); - const reorganizeResult = await reorganizeItems(unorganizedItems, libraryDir, (progress, message) => { - report.step("reorganize", 0.4 + (progress * 0.4), message); - }); + const autoOrganizeUnrecognized = settings.autoOrganizeUnrecognized ?? false; + const reorganizeResult = await reorganizeItems( + unorganizedItems, + libraryDir, + autoOrganizeUnrecognized, + (progress, message) => { + report.step("reorganize", 0.4 + (progress * 0.4), message); + } + ); logger.info("Reorganization completed", { jobId: job.id, diff --git a/apps/server/src/services/scanner.test.ts b/apps/server/src/services/scanner.test.ts index 579d066..0e8d40d 100644 --- a/apps/server/src/services/scanner.test.ts +++ b/apps/server/src/services/scanner.test.ts @@ -142,6 +142,180 @@ describe("Scanner Service - ROM Detection Heuristics", () => { }); }); + describe("Auto-organize Unrecognized Setting", () => { + it("should NOT move unrecognized files when autoOrganizeUnrecognized is false (default)", async () => { + const fileName = "Unknown Game (USA).nes"; + const originalPath = path.join(libraryRoot, fileName); + await fs.writeFile(originalPath, "test content"); + + // Mock Crocdb to return no results + vi.mocked(crocdb.searchEntries).mockResolvedValue({ + info: {}, + data: { + results: [], + current_results: 0, + total_results: 0, + current_page: 1, + total_pages: 0 + } + }); + + const items = await scanForUnorganizedItems(libraryRoot); + expect(items).toHaveLength(1); + + // Call with autoOrganizeUnrecognized=false (default behavior) + const result = await reorganizeItems(items, libraryRoot, false); + + // File should NOT be moved + expect(result.reorganizedFiles).toBe(0); + expect(result.skippedFiles).toBe(1); + expect(result.errors).toHaveLength(0); + + // Verify file is still in original location + const stillExists = await fs.access(originalPath).then(() => true).catch(() => false); + expect(stillExists).toBe(true); + + // Verify "Not Found" folder was NOT created + const notFoundDir = path.join( + libraryRoot, + "Nintendo - Nintendo Entertainment System", + "Not Found" + ); + const notFoundExists = await fs.access(notFoundDir).then(() => true).catch(() => false); + expect(notFoundExists).toBe(false); + }); + + it("should move unrecognized files when autoOrganizeUnrecognized is true", async () => { + const fileName = "Unknown Game (USA).nes"; + await fs.writeFile(path.join(libraryRoot, fileName), "test content"); + + // Mock Crocdb to return no results + vi.mocked(crocdb.searchEntries).mockResolvedValue({ + info: {}, + data: { + results: [], + current_results: 0, + total_results: 0, + current_page: 1, + total_pages: 0 + } + }); + + const items = await scanForUnorganizedItems(libraryRoot); + expect(items).toHaveLength(1); + + // Call with autoOrganizeUnrecognized=true + const result = await reorganizeItems(items, libraryRoot, true); + + // File should be moved + expect(result.reorganizedFiles).toBe(1); + expect(result.skippedFiles).toBe(0); + expect(result.errors).toHaveLength(0); + + // Verify manifest was created in "Not Found" folder + const expectedDir = path.join( + libraryRoot, + "Nintendo - Nintendo Entertainment System", + "Not Found", + "Unknown Game (USA)" + ); + const manifestPath = path.join(expectedDir, ".crocdesk.json"); + const manifestExists = await fs.access(manifestPath).then(() => true).catch(() => false); + + expect(manifestExists).toBe(true); + + if (manifestExists) { + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8")); + expect(manifest.crocdb.slug).toBe("unknown-game-usa"); + expect(manifest.crocdb.title).toBe("Unknown Game (USA)"); + } + }); + + it("should always organize recognized files regardless of autoOrganizeUnrecognized setting", async () => { + const fileName = "smb.nes"; + await fs.writeFile(path.join(libraryRoot, fileName), "test content"); + + // Mock Crocdb to return a match + vi.mocked(crocdb.searchEntries).mockResolvedValue({ + info: {}, + data: { + results: [ + { + slug: "super-mario-bros", + title: "Super Mario Bros.", + platform: "Nintendo - Nintendo Entertainment System", + regions: ["USA"], + links: [], + rom_id: "12345" + } + ], + current_results: 1, + total_results: 1, + current_page: 1, + total_pages: 1 + } + }); + + const items = await scanForUnorganizedItems(libraryRoot); + + // Test with autoOrganizeUnrecognized=false + const result = await reorganizeItems(items, libraryRoot, false); + + // Recognized file should still be organized + expect(result.reorganizedFiles).toBe(1); + expect(result.skippedFiles).toBe(0); + + // Should use Crocdb title instead of filename + const expectedDir = path.join( + libraryRoot, + "Nintendo - Nintendo Entertainment System", + "Super Mario Bros. (USA)" + ); + const exists = await fs.access(expectedDir).then(() => true).catch(() => false); + expect(exists).toBe(true); + }); + + it("should skip multiple unrecognized files when autoOrganizeUnrecognized is false", async () => { + const files = [ + "Unknown Game 1.nes", + "Unknown Game 2.nes", + "Unknown Game 3.gb" + ]; + + for (const file of files) { + await fs.writeFile(path.join(libraryRoot, file), "test content"); + } + + // Mock Crocdb to return no results + vi.mocked(crocdb.searchEntries).mockResolvedValue({ + info: {}, + data: { + results: [], + current_results: 0, + total_results: 0, + current_page: 1, + total_pages: 0 + } + }); + + const items = await scanForUnorganizedItems(libraryRoot); + expect(items).toHaveLength(3); + + const result = await reorganizeItems(items, libraryRoot, false); + + // All files should be skipped + expect(result.reorganizedFiles).toBe(0); + expect(result.skippedFiles).toBe(3); + expect(result.errors).toHaveLength(0); + + // Verify files are still in original locations + for (const file of files) { + const exists = await fs.access(path.join(libraryRoot, file)).then(() => true).catch(() => false); + expect(exists).toBe(true); + } + }); + }); + describe("Game Not Found in Crocdb", () => { it("should create manifest for game not found in Crocdb", async () => { const fileName = "Unknown Game (USA).nes"; @@ -162,7 +336,8 @@ describe("Scanner Service - ROM Detection Heuristics", () => { const items = await scanForUnorganizedItems(libraryRoot); expect(items).toHaveLength(1); - const result = await reorganizeItems(items, libraryRoot); + // Pass true to enable moving unrecognized files for this test + const result = await reorganizeItems(items, libraryRoot, true); expect(result.reorganizedFiles).toBe(1); expect(result.errors).toHaveLength(0); @@ -202,7 +377,8 @@ describe("Scanner Service - ROM Detection Heuristics", () => { }); const items = await scanForUnorganizedItems(libraryRoot); - const result = await reorganizeItems(items, libraryRoot); + // Pass true to enable moving unrecognized files for this test + const result = await reorganizeItems(items, libraryRoot, true); expect(result.reorganizedFiles).toBe(1); @@ -236,7 +412,8 @@ describe("Scanner Service - ROM Detection Heuristics", () => { const items = await scanForUnorganizedItems(libraryRoot); expect(items).toHaveLength(1); // Verify file was detected - const result = await reorganizeItems(items, libraryRoot); + // Pass true to enable moving unrecognized files for this test + const result = await reorganizeItems(items, libraryRoot, true); expect(result.reorganizedFiles).toBe(1); @@ -380,7 +557,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { }); const items = await scanForUnorganizedItems(libraryRoot); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, true); expect(result.reorganizedFiles).toBe(3); @@ -485,7 +662,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { data: { results: [], current_results: 0, total_results: 0, current_page: 1, total_pages: 0 } }); - await reorganizeItems(items, libraryRoot); + await reorganizeItems(items, libraryRoot, true); const platformDir = path.join(libraryRoot, "Nintendo - Game Boy"); const exists = await fs.access(platformDir).then(() => true).catch(() => false); @@ -501,7 +678,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { data: { results: [], current_results: 0, total_results: 0, current_page: 1, total_pages: 0 } }); - await reorganizeItems(items, libraryRoot); + await reorganizeItems(items, libraryRoot, true); const platformDir = path.join(libraryRoot, "Sega - Mega Drive - Genesis"); const exists = await fs.access(platformDir).then(() => true).catch(() => false); @@ -517,7 +694,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { data: { results: [], current_results: 0, total_results: 0, current_page: 1, total_pages: 0 } }); - await reorganizeItems(items, libraryRoot); + await reorganizeItems(items, libraryRoot, true); const platformDir = path.join(libraryRoot, "Nintendo - Super Nintendo Entertainment System"); const exists = await fs.access(platformDir).then(() => true).catch(() => false); @@ -544,7 +721,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { vi.mocked(crocdb.searchEntries).mockRejectedValue(new Error("API Error")); const items = await scanForUnorganizedItems(libraryRoot); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, true); // Should still reorganize using folder name expect(result.reorganizedFiles).toBe(1); @@ -565,7 +742,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { // Make the target platform directory read-only to prevent file moves into it await fs.chmod(targetPlatformDir, READONLY_PERMISSIONS); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, true); // Restore permissions for cleanup await fs.chmod(targetPlatformDir, READWRITE_PERMISSIONS); @@ -603,7 +780,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { await fs.writeFile(path.join(targetDir, "bad-game.nes"), "existing"); await fs.chmod(targetDir, READONLY_PERMISSIONS); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, true); // Should process at least the good one expect(result.reorganizedFiles).toBeGreaterThan(0); diff --git a/apps/server/src/services/scanner.ts b/apps/server/src/services/scanner.ts index 009946b..be314f6 100644 --- a/apps/server/src/services/scanner.ts +++ b/apps/server/src/services/scanner.ts @@ -198,6 +198,7 @@ async function scanPlatformFolder( export async function reorganizeItems( items: UnorganizedItem[], libraryRoot: string, + autoOrganizeUnrecognized: boolean = false, reportProgress?: (progress: number, message: string) => void ): Promise { const report = reportProgress || (() => {}); @@ -250,8 +251,20 @@ export async function reorganizeItems( : formatGameName(match.title, match.regions[0]); targetPlatform = platform; } else { - // Not found in Crocdb - put in "Not Found" subfolder - logger.info("Game not found in Crocdb, using fallback", { folderName, platform }); + // Not found in Crocdb + if (!autoOrganizeUnrecognized) { + // Skip organizing unrecognized files when setting is disabled + logger.info("Game not found in Crocdb, skipping (autoOrganizeUnrecognized=false)", { + folderName, + platform + }); + result.skippedFiles += groupItems.length; + processed++; + continue; + } + + // autoOrganizeUnrecognized is enabled - put in "Not Found" subfolder + logger.info("Game not found in Crocdb, moving to Not Found folder", { folderName, platform }); gameName = sanitizeFolderName(folderName); targetPlatform = `${platform}/${NOT_FOUND_FOLDER}`; } diff --git a/packages/shared/src/__tests__/constants.spec.ts b/packages/shared/src/__tests__/constants.spec.ts index 9852c5f..a5f7e4b 100644 --- a/packages/shared/src/__tests__/constants.spec.ts +++ b/packages/shared/src/__tests__/constants.spec.ts @@ -4,6 +4,8 @@ import { DEFAULT_SETTINGS } from '../constants'; describe('shared defaults', () => { it('DEFAULT_SETTINGS has expected values', () => { expect(DEFAULT_SETTINGS.downloadDir).toBe('./downloads'); + expect(DEFAULT_SETTINGS.libraryDir).toBe('./library'); + expect(DEFAULT_SETTINGS.autoOrganizeUnrecognized).toBe(false); expect(DEFAULT_SETTINGS.queue).toBeDefined(); expect(DEFAULT_SETTINGS.queue).toEqual({ concurrency: 2 }); }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 305d252..7903fd1 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -3,6 +3,7 @@ import type { Settings } from "./types"; export const DEFAULT_SETTINGS: Settings = { downloadDir: "./downloads", libraryDir: "./library", + autoOrganizeUnrecognized: false, queue: { concurrency: 2 } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 7438515..3d9e86e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -70,6 +70,12 @@ export type Settings = { * All scanning and library operations work from this root. */ libraryDir: string; + /** + * When true, automatically moves unrecognized files (not found in Crocdb) + * to a "Not Found" subfolder during scans. + * When false (default), unrecognized files are left in place. + */ + autoOrganizeUnrecognized?: boolean; queue?: { concurrency?: number; }; From 8e0e44a8a18f377d517d92ab8f3efe3166434bd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:40:32 +0000 Subject: [PATCH 3/5] Fix tests to explicitly pass autoOrganizeUnrecognized parameter Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/server/src/services/scanner.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/scanner.test.ts b/apps/server/src/services/scanner.test.ts index 0e8d40d..c990051 100644 --- a/apps/server/src/services/scanner.test.ts +++ b/apps/server/src/services/scanner.test.ts @@ -456,7 +456,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { }); const items = await scanForUnorganizedItems(libraryRoot); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, false); expect(result.reorganizedFiles).toBe(1); @@ -495,7 +495,7 @@ describe("Scanner Service - ROM Detection Heuristics", () => { }); const items = await scanForUnorganizedItems(libraryRoot); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, false); expect(result.reorganizedFiles).toBe(1); From a0912b5cba479f86ff20056339a143b3b7157242 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:52:09 +0000 Subject: [PATCH 4/5] Fix linting issues: remove unused type imports and prefix unused function Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- apps/server/src/services/scanner.test.ts | 4 +--- apps/server/src/services/scanner.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/scanner.test.ts b/apps/server/src/services/scanner.test.ts index c990051..69c6842 100644 --- a/apps/server/src/services/scanner.test.ts +++ b/apps/server/src/services/scanner.test.ts @@ -4,9 +4,7 @@ import os from "os"; import { promises as fs } from "fs"; import { scanForUnorganizedItems, - reorganizeItems, - type UnorganizedItem, - type ReorganizeResult + reorganizeItems } from "./scanner"; import * as crocdb from "./crocdb"; diff --git a/apps/server/src/services/scanner.ts b/apps/server/src/services/scanner.ts index be314f6..dae91cd 100644 --- a/apps/server/src/services/scanner.ts +++ b/apps/server/src/services/scanner.ts @@ -610,7 +610,7 @@ function extractVersionTags(fileName: string): string { /** * Check if ROM is a hack or modified version based on tags. */ -function isRomHack(fileName: string): boolean { +function _isRomHack(fileName: string): boolean { const nameWithoutExt = path.basename(fileName, path.extname(fileName)); const hackPattern = /\[(?:Hack|Translation|T\+|Trainer|Beta|Proto|Unl)\]/gi; return hackPattern.test(nameWithoutExt); From 368b56ff4d67fcdcbe542bc07e0837eb528477d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:06:56 +0000 Subject: [PATCH 5/5] Fix tests after merge: add fuzzy-matcher mock and update autoOrganizeUnrecognized param Co-authored-by: luandev <6452989+luandev@users.noreply.github.com> --- .../src/services/__tests__/scanner-enhanced.test.ts | 2 +- apps/server/src/services/scanner.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/__tests__/scanner-enhanced.test.ts b/apps/server/src/services/__tests__/scanner-enhanced.test.ts index e858497..6dc1ea5 100644 --- a/apps/server/src/services/__tests__/scanner-enhanced.test.ts +++ b/apps/server/src/services/__tests__/scanner-enhanced.test.ts @@ -480,7 +480,7 @@ describe("Scanner Service - Enhanced ROM Recognition", () => { }); const items = await scanForUnorganizedItems(libraryRoot); - const result = await reorganizeItems(items, libraryRoot); + const result = await reorganizeItems(items, libraryRoot, true); // Should still organize into "Not Found" folder expect(result.reorganizedFiles).toBe(1); diff --git a/apps/server/src/services/scanner.test.ts b/apps/server/src/services/scanner.test.ts index 69c6842..8713ae7 100644 --- a/apps/server/src/services/scanner.test.ts +++ b/apps/server/src/services/scanner.test.ts @@ -14,6 +14,15 @@ vi.mock("./crocdb", () => ({ getEntry: vi.fn() })); +// Mock fuzzy-matcher to pass through results without filtering +vi.mock("./fuzzy-matcher", () => ({ + findBestMatches: vi.fn((searchKey, candidates) => { + // Return first candidate with a high score + return candidates.length > 0 ? [{ ...candidates[0], score: 0.95 }] : []; + }), + expandAbbreviations: vi.fn((name) => [name]) +})); + // Mock logger to avoid console output during tests vi.mock("../utils/logger", () => ({ logger: {