import { mkdir, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { eq } from 'drizzle-orm'; import { bookFiles, books } from '../src/db/schema'; import type { FixtureEntry } from './e2e/scanner/scanner-fixture-builder'; import { createFixtureTree, file } from './e2e/scanner/scanner-fixture-builder'; import { assertNoIntegrityViolations, closeScannerE2EContext, createScannerE2EContext, loadLibraryBookState, seedLibrary, triggerLibraryScan, waitForScanCompletion, type LibraryBookState, type ScannerE2EContext, } from './e2e/scanner/scanner-harness'; type OrganizationMode = 'book_per_file' | 'book_per_folder'; interface ExpectedBook { folderPath: string; filePaths: string[]; primaryPath?: string | null; status?: 'present' | 'missing'; } interface ScannerScenario { id: string; mode: OrganizationMode; entries: FixtureEntry[]; expected: ExpectedBook[]; allowedFormats?: string[]; excludePatterns?: string[]; } interface ScenarioRunResult { id: string; mode: OrganizationMode; status: 'passed' | 'failed'; durationMs: number; error?: string; } const SCENARIO_TIMEOUT_MS = 60_000; const bookPerFolderScenarios: ScannerScenario[] = [ { id: 'book-per-folder-flat-root-file', mode: 'book_per_folder', entries: [file('book.epub')], expected: [{ folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' }], }, { id: 'book-per-folder-mixed-root-and-folder', mode: 'book_per_folder', entries: [file('root.pdf'), file('Author/Book/book.epub')], expected: [ { folderPath: 'root.pdf', filePaths: ['root.pdf'], primaryPath: 'root.pdf' }, { folderPath: 'Author/Book', filePaths: ['Author/Book/book.epub'], primaryPath: 'Author/Book/book.epub' }, ], }, { id: 'book-per-folder-multi-format-folder-and-root-stray', mode: 'book_per_folder', entries: [file('Series/Book/book.epub'), file('Series/Book/book.mobi'), file('Series/Book/cover.jpg'), file('stray.cbz')], expected: [ { folderPath: 'Series/Book', filePaths: ['Series/Book/book.epub', 'Series/Book/book.mobi', 'Series/Book/cover.jpg'], primaryPath: 'Series/Book/book.epub', }, { folderPath: 'stray.cbz', filePaths: ['stray.cbz'], primaryPath: 'stray.cbz' }, ], }, { id: 'book-per-folder-audiobook-multipart-folder', mode: 'book_per_folder', entries: [file('Audio/track-1.mp3'), file('Audio/track-2.mp3'), file('Audio/cover.jpg')], expected: [ { folderPath: 'Audio', filePaths: ['Audio/cover.jpg', 'Audio/track-1.mp3', 'Audio/track-2.mp3'], primaryPath: 'Audio/track-1.mp3', }, ], }, { id: 'book-per-folder-disc-folder-flattening', mode: 'book_per_folder', entries: [file('AudioBook/CD 1/01.mp3'), file('AudioBook/Disc 2/02.mp3'), file('AudioBook/cover.jpg')], expected: [ { folderPath: 'AudioBook', filePaths: ['AudioBook/CD 1/01.mp3', 'AudioBook/Disc 2/02.mp3', 'AudioBook/cover.jpg'], primaryPath: 'AudioBook/CD 1/01.mp3', }, ], }, { id: 'book-per-folder-ebook-with-stem-audio-subfolder', mode: 'book_per_folder', entries: [file('TheBook/TheBook.epub'), file('TheBook/TheBook/01.mp3'), file('TheBook/TheBook/02.mp3')], expected: [ { folderPath: 'TheBook', filePaths: ['TheBook/TheBook.epub', 'TheBook/TheBook/01.mp3', 'TheBook/TheBook/02.mp3'], primaryPath: 'TheBook/TheBook.epub', }, ], }, { id: 'book-per-folder-root-stem-audio-not-flattened', mode: 'book_per_folder', entries: [file('BookTitle.epub'), file('BookTitle/01.mp3'), file('BookTitle/02.mp3')], expected: [ { folderPath: 'BookTitle.epub', filePaths: ['BookTitle.epub'], primaryPath: 'BookTitle.epub' }, { folderPath: 'BookTitle', filePaths: ['BookTitle/01.mp3', 'BookTitle/02.mp3'], primaryPath: 'BookTitle/01.mp3' }, ], }, { id: 'book-per-folder-hidden-files-and-folders-ignored', mode: 'book_per_folder', entries: [file('.hidden/ghost.epub'), file('.root-hidden.epub'), file('Visible/book.epub')], expected: [{ folderPath: 'Visible', filePaths: ['Visible/book.epub'], primaryPath: 'Visible/book.epub' }], }, { id: 'book-per-folder-exclude-patterns', mode: 'book_per_folder', entries: [file('skip-folder/book.epub'), file('keep-folder/book.epub'), file('keep-folder/cover.jpg')], excludePatterns: ['skip*'], expected: [{ folderPath: 'keep-folder', filePaths: ['keep-folder/book.epub', 'keep-folder/cover.jpg'], primaryPath: 'keep-folder/book.epub' }], }, { id: 'book-per-folder-allowed-formats', mode: 'book_per_folder', entries: [file('book.epub'), file('book.pdf')], allowedFormats: ['epub'], expected: [{ folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' }], }, { id: 'book-per-folder-zero-byte-content-file-skipped', mode: 'book_per_folder', entries: [file('zero.epub', '')], expected: [{ folderPath: 'zero.epub', filePaths: [], primaryPath: null }], }, { id: 'book-per-folder-no-primary-files', mode: 'book_per_folder', entries: [file('Only/cover.jpg'), file('Only/book.opf')], expected: [], }, { id: 'book-per-folder-deep-nested-book-folder', mode: 'book_per_folder', entries: [file('A/B/C/book.epub')], expected: [{ folderPath: 'A/B/C', filePaths: ['A/B/C/book.epub'], primaryPath: 'A/B/C/book.epub' }], }, { id: 'book-per-folder-same-filename-different-authors', mode: 'book_per_folder', entries: [file('Author One/Shared/book.epub'), file('Author Two/Shared/book.epub')], expected: [ { folderPath: 'Author One/Shared', filePaths: ['Author One/Shared/book.epub'], primaryPath: 'Author One/Shared/book.epub' }, { folderPath: 'Author Two/Shared', filePaths: ['Author Two/Shared/book.epub'], primaryPath: 'Author Two/Shared/book.epub' }, ], }, { id: 'book-per-folder-disc-name-variants-flattened', mode: 'book_per_folder', entries: [file('Saga/Disk03/03.mp3'), file('Saga/PartA/04.mp3'), file('Saga/SideB/05.mp3'), file('Saga/front.jpg')], expected: [ { folderPath: 'Saga', filePaths: ['Saga/Disk03/03.mp3', 'Saga/PartA/04.mp3', 'Saga/SideB/05.mp3', 'Saga/front.jpg'], primaryPath: 'Saga/Disk03/03.mp3', }, ], }, { id: 'book-per-folder-stem-subfolder-case-mismatch-no-merge', mode: 'book_per_folder', entries: [file('Novel/Book.epub'), file('Novel/book/01.mp3'), file('Novel/book/02.mp3')], expected: [ { folderPath: 'Novel', filePaths: ['Novel/Book.epub'], primaryPath: 'Novel/Book.epub' }, { folderPath: 'Novel/book', filePaths: ['Novel/book/01.mp3', 'Novel/book/02.mp3'], primaryPath: 'Novel/book/01.mp3' }, ], }, { id: 'book-per-folder-stem-subfolder-with-disc-flattening', mode: 'book_per_folder', entries: [file('Title/Title.epub'), file('Title/Title/CD 1/01.mp3'), file('Title/Title/Disc 2/02.mp3'), file('Title/cover.jpg')], expected: [ { folderPath: 'Title', filePaths: ['Title/Title.epub', 'Title/Title/CD 1/01.mp3', 'Title/Title/Disc 2/02.mp3', 'Title/cover.jpg'], primaryPath: 'Title/Title.epub', }, ], }, { id: 'book-per-folder-root-multi-format-files-remain-separate', mode: 'book_per_folder', entries: [file('book.epub'), file('book.mobi')], expected: [ { folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' }, { folderPath: 'book.mobi', filePaths: ['book.mobi'], primaryPath: 'book.mobi' }, ], }, { id: 'book-per-folder-multi-book-single-folder-coalesced', mode: 'book_per_folder', entries: [file('Anthology/Book One.epub'), file('Anthology/Book Two.epub'), file('Anthology/cover.jpg')], expected: [{ folderPath: 'Anthology', filePaths: ['Anthology/Book One.epub', 'Anthology/Book Two.epub', 'Anthology/cover.jpg'] }], }, { id: 'book-per-folder-allowed-formats-audio-filter', mode: 'book_per_folder', entries: [file('Audio/book.mp3'), file('Audio/book.m4b'), file('Audio/cover.jpg')], allowedFormats: ['m4b'], expected: [{ folderPath: 'Audio', filePaths: ['Audio/book.m4b', 'Audio/cover.jpg'], primaryPath: 'Audio/book.m4b' }], }, { id: 'book-per-folder-exclude-exact-root-filename', mode: 'book_per_folder', entries: [file('skip.epub'), file('Keep/book.epub')], excludePatterns: ['skip.epub'], expected: [{ folderPath: 'Keep', filePaths: ['Keep/book.epub'], primaryPath: 'Keep/book.epub' }], }, { id: 'book-per-folder-uppercase-extensions', mode: 'book_per_folder', entries: [file('Caps/BOOK.EPUB'), file('Caps/COVER.JPG')], expected: [{ folderPath: 'Caps', filePaths: ['Caps/BOOK.EPUB', 'Caps/COVER.JPG'], primaryPath: 'Caps/BOOK.EPUB' }], }, { id: 'book-per-folder-non-disc-similar-folder-not-flattened', mode: 'book_per_folder', entries: [file('AudioBook/Discography/01.mp3'), file('AudioBook/cover.jpg')], expected: [{ folderPath: 'AudioBook/Discography', filePaths: ['AudioBook/Discography/01.mp3'], primaryPath: 'AudioBook/Discography/01.mp3' }], }, ]; const bookPerFileScenarios: ScannerScenario[] = [ { id: 'book-per-file-multiple-content-files', mode: 'book_per_file', entries: [file('Series/book1.epub'), file('Series/book2.epub'), file('Series/book3.mobi'), file('Series/cover.jpg')], expected: [ { folderPath: 'Series/book1.epub', filePaths: ['Series/book1.epub'], primaryPath: 'Series/book1.epub' }, { folderPath: 'Series/book2.epub', filePaths: ['Series/book2.epub'], primaryPath: 'Series/book2.epub' }, { folderPath: 'Series/book3.mobi', filePaths: ['Series/book3.mobi'], primaryPath: 'Series/book3.mobi' }, ], }, { id: 'book-per-file-audio-parts', mode: 'book_per_file', entries: [file('Audio/01.mp3'), file('Audio/02.mp3')], expected: [ { folderPath: 'Audio/01.mp3', filePaths: ['Audio/01.mp3'], primaryPath: 'Audio/01.mp3' }, { folderPath: 'Audio/02.mp3', filePaths: ['Audio/02.mp3'], primaryPath: 'Audio/02.mp3' }, ], }, { id: 'book-per-file-ignore-non-content', mode: 'book_per_file', entries: [file('Book/cover.jpg'), file('Book/book.epub')], expected: [{ folderPath: 'Book/book.epub', filePaths: ['Book/book.epub'], primaryPath: 'Book/book.epub' }], }, { id: 'book-per-file-allowed-formats-and-excludes', mode: 'book_per_file', entries: [file('keep.epub'), file('keep.pdf'), file('skip-folder/skip.epub')], allowedFormats: ['epub'], excludePatterns: ['skip*'], expected: [{ folderPath: 'keep.epub', filePaths: ['keep.epub'], primaryPath: 'keep.epub' }], }, { id: 'book-per-file-deep-root-mix', mode: 'book_per_file', entries: [file('root.epub'), file('A/B/C/book.pdf'), file('A/B/C/cover.jpg')], expected: [ { folderPath: 'root.epub', filePaths: ['root.epub'], primaryPath: 'root.epub' }, { folderPath: 'A/B/C/book.pdf', filePaths: ['A/B/C/book.pdf'], primaryPath: 'A/B/C/book.pdf' }, ], }, { id: 'book-per-file-disc-subfolders-not-grouped', mode: 'book_per_file', entries: [file('Saga/CD 1/01.mp3'), file('Saga/Disc 2/02.mp3'), file('Saga/cover.jpg')], expected: [ { folderPath: 'Saga/CD 1/01.mp3', filePaths: ['Saga/CD 1/01.mp3'], primaryPath: 'Saga/CD 1/01.mp3' }, { folderPath: 'Saga/Disc 2/02.mp3', filePaths: ['Saga/Disc 2/02.mp3'], primaryPath: 'Saga/Disc 2/02.mp3' }, ], }, { id: 'book-per-file-uppercase-extensions', mode: 'book_per_file', entries: [file('Novel/Book.EPUB'), file('Novel/Book.PDF')], expected: [ { folderPath: 'Novel/Book.EPUB', filePaths: ['Novel/Book.EPUB'], primaryPath: 'Novel/Book.EPUB' }, { folderPath: 'Novel/Book.PDF', filePaths: ['Novel/Book.PDF'], primaryPath: 'Novel/Book.PDF' }, ], }, { id: 'book-per-file-exclude-exact-filename', mode: 'book_per_file', entries: [file('keep.epub'), file('skip.epub')], excludePatterns: ['skip.epub'], expected: [{ folderPath: 'keep.epub', filePaths: ['keep.epub'], primaryPath: 'keep.epub' }], }, { id: 'book-per-file-allowed-formats-case-insensitive', mode: 'book_per_file', entries: [file('keep.EPUB'), file('drop.PDF')], allowedFormats: ['epub'], expected: [{ folderPath: 'keep.EPUB', filePaths: ['keep.EPUB'], primaryPath: 'keep.EPUB' }], }, { id: 'book-per-file-hidden-and-no-extension-ignored', mode: 'book_per_file', entries: [file('.hidden/ghost.epub'), file('visible/README'), file('visible/book.mobi')], expected: [{ folderPath: 'visible/book.mobi', filePaths: ['visible/book.mobi'], primaryPath: 'visible/book.mobi' }], }, { id: 'book-per-file-root-and-nested-same-name', mode: 'book_per_file', entries: [file('book.epub'), file('Series/book.epub')], expected: [ { folderPath: 'book.epub', filePaths: ['book.epub'], primaryPath: 'book.epub' }, { folderPath: 'Series/book.epub', filePaths: ['Series/book.epub'], primaryPath: 'Series/book.epub' }, ], }, { id: 'book-per-file-zero-byte-file-skipped', mode: 'book_per_file', entries: [file('empty.epub', ''), file('valid.epub')], expected: [ { folderPath: 'empty.epub', filePaths: [], primaryPath: null }, { folderPath: 'valid.epub', filePaths: ['valid.epub'], primaryPath: 'valid.epub' }, ], }, ]; function toAbsolutePath(rootPath: string, relativePath: string): string { return join(rootPath, relativePath); } function assertScenarioOutcome(rootPath: string, expected: ExpectedBook[], actual: LibraryBookState[]): void { const resolvedExpected = expected.map((book) => ({ folderPath: toAbsolutePath(rootPath, book.folderPath), status: book.status ?? 'present', primaryPath: book.primaryPath === undefined ? undefined : book.primaryPath === null ? null : toAbsolutePath(rootPath, book.primaryPath), filePaths: book.filePaths.map((path) => toAbsolutePath(rootPath, path)).sort(), })); const expectedFolderPaths = resolvedExpected.map((book) => book.folderPath).sort(); const actualFolderPaths = actual.map((book) => book.folderPath).sort(); expect(actualFolderPaths).toEqual(expectedFolderPaths); const actualByFolderPath = new Map(actual.map((book) => [book.folderPath, book])); for (const expectedBook of resolvedExpected) { const actualBook = actualByFolderPath.get(expectedBook.folderPath); expect(actualBook).toBeDefined(); expect(actualBook!.status).toBe(expectedBook.status); expect(actualBook!.filePaths).toEqual(expectedBook.filePaths); if (expectedBook.primaryPath !== undefined) { expect(actualBook!.primaryPath).toBe(expectedBook.primaryPath); } } } async function runScenario(context: ScannerE2EContext, scenario: ScannerScenario, results: ScenarioRunResult[]): Promise { const fixture = await createFixtureTree(scenario.entries, `scanner-e2e-${scenario.id}-`); const startedAt = Date.now(); try { const { libraryId } = await seedLibrary(context.db, { rootPath: fixture.rootPath, mode: scenario.mode, allowedFormats: scenario.allowedFormats, excludePatterns: scenario.excludePatterns, }); const jobId = await triggerLibraryScan(context, libraryId); await waitForScanCompletion(context.db, jobId); const actual = await loadLibraryBookState(context.db, libraryId); assertScenarioOutcome(fixture.rootPath, scenario.expected, actual); await assertNoIntegrityViolations(context.db); results.push({ id: scenario.id, mode: scenario.mode, status: 'passed', durationMs: Date.now() - startedAt, }); } catch (err) { results.push({ id: scenario.id, mode: scenario.mode, status: 'failed', durationMs: Date.now() - startedAt, error: err instanceof Error ? err.message : String(err), }); throw err; } finally { await fixture.cleanup(); } } async function writeScenarioReport(results: ScenarioRunResult[]): Promise { const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server'); await mkdir(reportDir, { recursive: true }); const reportPath = join(reportDir, 'scanner-e2e-scenarios.json'); await writeFile( reportPath, JSON.stringify( { generatedAt: new Date().toISOString(), total: results.length, passed: results.filter((result) => result.status === 'passed').length, failed: results.filter((result) => result.status === 'failed').length, results, }, null, 2, ), ); } describe('Scanner structures (e2e)', () => { let context: ScannerE2EContext | null = null; const scenarioResults: ScenarioRunResult[] = []; beforeAll(async () => { context = await createScannerE2EContext(); }); afterAll(async () => { if (context) { await assertNoIntegrityViolations(context.db); } await writeScenarioReport(scenarioResults); if (context) { await closeScannerE2EContext(context); } }); describe('organizationMode=book_per_folder', () => { for (const scenario of bookPerFolderScenarios) { it( scenario.id, async () => { if (!context) throw new Error('E2E context not initialized'); await runScenario(context, scenario, scenarioResults); }, SCENARIO_TIMEOUT_MS, ); } }); describe('a library folder named like a disc folder', () => { // unRAID mounts its drives as /mnt/disk1; such a folder was once flattened into the folder above it. const entries = [file('disk1/Dune.epub'), file('disk1/Emma.epub'), file('disk1/Saga/Saga 001.cbz')]; const expected = (library: string) => [join(library, 'Dune.epub'), join(library, 'Emma.epub'), join(library, 'Saga')].map((folderPath) => ({ folderPath, status: 'present' })); it( 'reads its loose files as books of their own, inside the library', async () => { if (!context) throw new Error('E2E context not initialized'); const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-'); try { const library = join(fixture.rootPath, 'disk1'); const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' }); await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId)); const state = await loadLibraryBookState(context.db, libraryId); expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual( expected(library), ); await assertNoIntegrityViolations(context.db); } finally { await fixture.cleanup(); } }, SCENARIO_TIMEOUT_MS, ); it( 'regroups a library scanned while it was flattened, keeping the old book on its primary file', async () => { if (!context) throw new Error('E2E context not initialized'); const fixture = await createFixtureTree(entries, 'scanner-e2e-disc-named-root-repair-'); try { const library = join(fixture.rootPath, 'disk1'); const { libraryId } = await seedLibrary(context.db, { rootPath: library, mode: 'book_per_folder' }); await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId)); // Rebuild what the flattening scan stored: one book above the library holding every loose file. const rows = await context.db.select({ id: books.id, folderPath: books.folderPath }).from(books).where(eq(books.libraryId, libraryId)); const dune = rows.find((row) => row.folderPath === join(library, 'Dune.epub'))!; const emma = rows.find((row) => row.folderPath === join(library, 'Emma.epub'))!; await context.db.update(bookFiles).set({ bookId: dune.id }).where(eq(bookFiles.bookId, emma.id)); await context.db.delete(books).where(eq(books.id, emma.id)); await context.db.update(books).set({ folderPath: fixture.rootPath }).where(eq(books.id, dune.id)); await waitForScanCompletion(context.db, await triggerLibraryScan(context, libraryId)); const state = await loadLibraryBookState(context.db, libraryId); expect(state.map(({ folderPath, status }) => ({ folderPath, status })).sort((a, b) => a.folderPath.localeCompare(b.folderPath))).toEqual( expected(library), ); const [kept] = await context.db.select({ folderPath: books.folderPath }).from(books).where(eq(books.id, dune.id)); expect(kept?.folderPath).toBe(join(library, 'Dune.epub')); expect(state.find((book) => book.folderPath === join(library, 'Emma.epub'))?.filePaths).toEqual([join(library, 'Emma.epub')]); await assertNoIntegrityViolations(context.db); } finally { await fixture.cleanup(); } }, SCENARIO_TIMEOUT_MS, ); }); describe('organizationMode=book_per_file', () => { for (const scenario of bookPerFileScenarios) { it( scenario.id, async () => { if (!context) throw new Error('E2E context not initialized'); await runScenario(context, scenario, scenarioResults); }, SCENARIO_TIMEOUT_MS, ); } }); });