mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
faee78370d
Pick folders from a lazily loaded tree with find-by-name and typed paths, beside a plan showing the books each organization mode would import, overlaps with other libraries, and folders no library reads yet. New ManageLibraries /path routes walk folders within entry and time bounds. Fix the scanner filing a disc-named library folder such as /mnt/disk1 under the folder above it, and move books already filed there onto their primary file on the next scan.
545 lines
21 KiB
TypeScript
545 lines
21 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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,
|
|
);
|
|
}
|
|
});
|
|
});
|