fix(scanner): prevent nested folders from being marked missing

Virtual sibling cleanup is now deferred until the final missing pass instead of being handled eagerly during upsert. This ensures that real nested book folders are not prematurely marked as missing before their own candidates can be processed. A new validation step against the disk state guarantees only genuinely missing books are removed.

Closes #170
This commit is contained in:
neon
2026-06-02 04:52:25 +00:00
parent beb969a744
commit 3a29ac7679
3 changed files with 302 additions and 34 deletions
@@ -259,6 +259,66 @@ describe('missing book detection', () => {
expect(repo.markBooksAsMissing).not.toHaveBeenCalled();
});
it('does not mark books as missing when their tracked content files still exist on disk', async () => {
const folderPath = '/library/Author/Book';
const file = makeBookFile({ id: 10, bookId: 5, absolutePath: `${folderPath}/book.epub`, relPath: 'Author/Book/book.epub' });
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([{ id: 5, libraryId: 1, libraryFolderId: 1, folderPath, status: 'present' }]),
findBookFilesByBookIds: vi.fn().mockResolvedValue([file]),
});
mockFindCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.findBookFilesByBookIds).toHaveBeenCalledWith([5]);
expect(repo.markBooksAsMissing).not.toHaveBeenCalled();
});
it('marks books as missing when their tracked content files no longer exist on disk', async () => {
const folderPath = '/library/Author/Book';
const file = makeBookFile({ id: 10, bookId: 5, absolutePath: `${folderPath}/book.epub`, relPath: 'Author/Book/book.epub' });
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([{ id: 5, libraryId: 1, libraryFolderId: 1, folderPath, status: 'present' }]),
findBookFilesByBookIds: vi.fn().mockResolvedValue([file]),
});
mockFindCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
mockStat.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.markBooksAsMissing).toHaveBeenCalledWith([5]);
});
it('does not let existing non-content files keep a book present when content is gone', async () => {
const folderPath = '/library/Author/Book';
const cover = makeBookFile({
id: 10,
bookId: 5,
absolutePath: `${folderPath}/cover.jpg`,
relPath: 'Author/Book/cover.jpg',
format: 'jpg',
role: 'cover',
});
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([{ id: 5, libraryId: 1, libraryFolderId: 1, folderPath, status: 'present' }]),
findBookFilesByBookIds: vi.fn().mockResolvedValue([cover]),
});
mockFindCandidates.mockResolvedValue({ candidates: [], skippedDirs: new Set(), unchangedDirs: new Set(), dirMtimes: new Map() });
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.markBooksAsMissing).toHaveBeenCalledWith([5]);
});
});
describe('books unavailable notifications', () => {
@@ -1484,6 +1544,89 @@ describe('cross-library transfer', () => {
// ── Virtual sibling drain / merge ────────────────────────────────────────────
describe('virtual sibling drain', () => {
it('does not merge nested child records into a new parent when child candidates are also seen', async () => {
const parentFolder = '/library/Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)';
const childFolder = `${parentFolder}/City of Thorns (2021)`;
const parentFile = makeFileStat({
absolutePath: `${parentFolder}/Rising Queen.epub`,
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/Rising Queen.epub',
ino: 1001,
});
const childFile = makeFileStat({
absolutePath: `${childFolder}/City of Thorns - C.N. Crawford.epub`,
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/City of Thorns (2021)/City of Thorns - C.N. Crawford.epub',
ino: 1002,
});
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([{ id: 2, libraryId: 1, libraryFolderId: 1, folderPath: childFolder, status: 'present' }]),
findBookFilesByLibraryFolder: vi
.fn()
.mockResolvedValue([
makeBookFile({ id: 12, bookId: 2, absolutePath: childFile.absolutePath, relPath: childFile.relPath, ino: childFile.ino }),
]),
createBook: vi.fn().mockResolvedValue({ id: 1, status: 'present', libraryFolderId: 1, folderPath: parentFolder, libraryId: 1 }),
});
mockFindCandidates.mockResolvedValue({
candidates: [makeCandidate(parentFolder, [parentFile]), makeCandidate(childFolder, [childFile])],
skippedDirs: new Set(),
unchangedDirs: new Set(),
dirMtimes: new Map(),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.updateBookFolderPath).not.toHaveBeenCalledWith(2, parentFolder);
expect(repo.markBooksAsMissing).not.toHaveBeenCalled();
expect(repo.completeScanJob).toHaveBeenCalledWith(100, expect.objectContaining({ missingCount: 0 }));
});
it('does not mark nested real book folders missing when parent and child candidates are both seen', async () => {
const parentFolder = '/library/Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)';
const childFolder = `${parentFolder}/City of Thorns (2021)`;
const parentFile = makeFileStat({
absolutePath: `${parentFolder}/Rising Queen.epub`,
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/Rising Queen.epub',
ino: 1001,
});
const childFile = makeFileStat({
absolutePath: `${childFolder}/City of Thorns - C.N. Crawford.epub`,
relPath: 'Rising Queen (Court of the Sea Fae Trilogy Book 3) (2020)/City of Thorns (2021)/City of Thorns - C.N. Crawford.epub',
ino: 1002,
});
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: parentFolder, status: 'present' },
{ id: 2, libraryId: 1, libraryFolderId: 1, folderPath: childFolder, status: 'present' },
]),
findBookFilesByLibraryFolder: vi
.fn()
.mockResolvedValue([
makeBookFile({ id: 11, bookId: 1, absolutePath: parentFile.absolutePath, relPath: parentFile.relPath, ino: parentFile.ino }),
makeBookFile({ id: 12, bookId: 2, absolutePath: childFile.absolutePath, relPath: childFile.relPath, ino: childFile.ino }),
]),
});
mockFindCandidates.mockResolvedValue({
candidates: [makeCandidate(parentFolder, [parentFile]), makeCandidate(childFolder, [childFile])],
skippedDirs: new Set(),
unchangedDirs: new Set(),
dirMtimes: new Map(),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.markBooksAsMissing).not.toHaveBeenCalled();
expect(mockGateway.emitBookMissing).not.toHaveBeenCalled();
expect(repo.completeScanJob).toHaveBeenCalledWith(100, expect.objectContaining({ missingCount: 0 }));
});
it('marks virtual children missing when real folder book exists (drain path)', async () => {
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
@@ -1509,14 +1652,61 @@ describe('virtual sibling drain', () => {
});
it('picks lowest-id virtual child as survivor and updates its folderPath when no exact match exists (merge path)', async () => {
const mergedFile = makeFileStat({ absolutePath: '/library/Series/book.epub', relPath: 'Series/book.epub' });
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
{ id: 3, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleOne', status: 'present' },
{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleTwo', status: 'present' },
]),
findBookFilesByLibraryFolder: vi
.fn()
.mockResolvedValue([
makeBookFile({ id: 10, bookId: 1, absolutePath: mergedFile.absolutePath, relPath: mergedFile.relPath, ino: mergedFile.ino }),
]),
});
mockFindCandidates.mockResolvedValue({
candidates: [makeCandidate('/library/Series', [makeFileStat({ absolutePath: '/library/Series/book.epub', relPath: 'Series/book.epub' })])],
candidates: [makeCandidate('/library/Series', [mergedFile])],
skippedDirs: new Set(),
unchangedDirs: new Set(),
dirMtimes: new Map(),
});
const done = awaitScan(repo);
const { service } = makeService(repo);
await service.startScan(1, 'manual');
await done;
expect(repo.updateBookFolderPath).toHaveBeenCalledWith(1, '/library/Series');
expect(repo.createBook).not.toHaveBeenCalled();
});
it('uses inode ownership to merge renamed virtual children into the lowest-id survivor', async () => {
const currentFirst = makeFileStat({ absolutePath: '/library/Series/renamed-one.epub', relPath: 'Series/renamed-one.epub', ino: 3003 });
const currentSecond = makeFileStat({ absolutePath: '/library/Series/renamed-two.epub', relPath: 'Series/renamed-two.epub', ino: 1001 });
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
{ id: 3, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleOne', status: 'present' },
{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleTwo', status: 'present' },
]),
findBookFilesByLibraryFolder: vi.fn().mockResolvedValue([
makeBookFile({
id: 30,
bookId: 3,
absolutePath: '/library/Series/TitleOne/old-one.epub',
relPath: 'Series/TitleOne/old-one.epub',
ino: 3003,
}),
makeBookFile({
id: 10,
bookId: 1,
absolutePath: '/library/Series/TitleTwo/old-two.epub',
relPath: 'Series/TitleTwo/old-two.epub',
ino: 1001,
}),
]),
});
mockFindCandidates.mockResolvedValue({
candidates: [makeCandidate('/library/Series', [currentFirst, currentSecond])],
skippedDirs: new Set(),
unchangedDirs: new Set(),
dirMtimes: new Map(),
@@ -1532,14 +1722,20 @@ describe('virtual sibling drain', () => {
});
it('restores the survivor to present when it was missing during the merge', async () => {
const mergedFile = makeFileStat({ absolutePath: '/library/Series/book.epub', relPath: 'Series/book.epub' });
const repo = makeRepo({
findBooksByLibraryFolder: vi.fn().mockResolvedValue([
{ id: 1, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleOne', status: 'missing' },
{ id: 2, libraryId: 1, libraryFolderId: 1, folderPath: '/library/Series/TitleTwo', status: 'present' },
]),
findBookFilesByLibraryFolder: vi
.fn()
.mockResolvedValue([
makeBookFile({ id: 10, bookId: 1, absolutePath: mergedFile.absolutePath, relPath: mergedFile.relPath, ino: mergedFile.ino }),
]),
});
mockFindCandidates.mockResolvedValue({
candidates: [makeCandidate('/library/Series', [makeFileStat({ absolutePath: '/library/Series/book.epub', relPath: 'Series/book.epub' })])],
candidates: [makeCandidate('/library/Series', [mergedFile])],
skippedDirs: new Set(),
unchangedDirs: new Set(),
dirMtimes: new Map(),
+89 -19
View File
@@ -74,6 +74,7 @@ const BOOK_EMIT_FLUSH_INTERVAL_MS = 1000;
const BOOK_STATUS_NOTIFY_DEBOUNCE_MS = 1000;
const WATCHER_NOTIFY_DEBOUNCE_MS = 30_000;
const TARGETED_BOOK_SCAN_MAX_CONCURRENCY = 8;
const MISSING_FILE_STAT_BATCH_SIZE = 50;
type OrganizationMode = 'book_per_file' | 'book_per_folder';
interface ScanCounts {
@@ -688,8 +689,9 @@ export class ScannerService implements OnApplicationBootstrap {
const totals = { added: 0, updated: 0 };
const allRetainedFileIds = new Set<number>();
const seenBookIds = new Set<number>();
const candidateFolderPaths = new Set(candidates.map((candidate) => candidate.folderPath));
for (const candidate of candidates) {
const result = await this.processCandidate(candidate, libraryId, libraryFolder.id, maps, settings.formatPriority, false);
const result = await this.processCandidate(candidate, libraryId, libraryFolder.id, maps, settings.formatPriority, false, candidateFolderPaths);
this.emitTargetedScanResult(libraryId, result);
seenBookIds.add(result.bookId);
for (const fid of result.retainedFileIds) allRetainedFileIds.add(fid);
@@ -783,7 +785,15 @@ export class ScannerService implements OnApplicationBootstrap {
};
const maps = await this.loadCandidateMaps(filePath, libraryId);
const result = await this.processCandidate(candidate, libraryId, libraryFolder.id, maps, settings.formatPriority, false);
const result = await this.processCandidate(
candidate,
libraryId,
libraryFolder.id,
maps,
settings.formatPriority,
false,
new Set([candidate.folderPath]),
);
await this.pruneMissingBookFiles(result.bookId, result.retainedFileIds, maps.fileIdsByBookId, maps.fileByPath, maps.fileByIno, {
added: 0,
updated: 0,
@@ -842,7 +852,15 @@ export class ScannerService implements OnApplicationBootstrap {
};
const maps = await this.loadCandidateMaps(filePath, libraryId);
const result = await this.processCandidate(candidate, libraryId, libraryFolder.id, maps, settings.formatPriority, false);
const result = await this.processCandidate(
candidate,
libraryId,
libraryFolder.id,
maps,
settings.formatPriority,
false,
new Set([candidate.folderPath]),
);
await this.pruneMissingBookFiles(result.bookId, result.retainedFileIds, maps.fileIdsByBookId, maps.fileByPath, maps.fileByIno, {
added: 0,
updated: 0,
@@ -921,7 +939,15 @@ export class ScannerService implements OnApplicationBootstrap {
}
const maps = await this.loadCandidateMaps(resolvedBookFolder, libraryId);
const result = await this.processCandidate(candidate, libraryId, libraryFolder.id, maps, settings.formatPriority, false);
const result = await this.processCandidate(
candidate,
libraryId,
libraryFolder.id,
maps,
settings.formatPriority,
false,
new Set([candidate.folderPath]),
);
await this.pruneMissingBookFiles(result.bookId, result.retainedFileIds, maps.fileIdsByBookId, maps.fileByPath, maps.fileByIno, {
added: 0,
updated: 0,
@@ -1107,9 +1133,10 @@ export class ScannerService implements OnApplicationBootstrap {
const seenBookIds = new Set<number>();
const allRetainedFileIds = new Set<number>();
const candidateFolderPaths = new Set(candidates.map((candidate) => candidate.folderPath));
for (const candidate of candidates) {
const result = await this.processCandidate(candidate, libraryId, libraryFolderId, maps, formatPriority, isFirstScan);
const result = await this.processCandidate(candidate, libraryId, libraryFolderId, maps, formatPriority, isFirstScan, candidateFolderPaths);
seenBookIds.add(result.bookId);
for (const fid of result.retainedFileIds) allRetainedFileIds.add(fid);
counts.addedCount += result.added;
@@ -1150,9 +1177,10 @@ export class ScannerService implements OnApplicationBootstrap {
if (unchangedDirs.has(parentDir)) return true;
return false;
};
const missingIds = knownBooks
const missingCandidates = knownBooks
.filter((b) => !seenBookIds.has(b.id) && !isUnderSkippedDir(b.folderPath) && !isInUnchangedDir(b.folderPath))
.map((b) => b.id);
const missingIds = await this.filterBookIdsMissingOnDisk(missingCandidates);
if (missingIds.length > 0) {
await this.scannerRepo.markBooksAsMissing(missingIds);
counts.missingCount += missingIds.length;
@@ -1182,13 +1210,24 @@ export class ScannerService implements OnApplicationBootstrap {
maps: ScanLookupMaps,
formatPriority: string[],
isFirstScan: boolean,
candidateFolderPaths: Set<string>,
): Promise<{ bookId: number; added: number; updated: number; retainedFileIds: Set<number>; becameVisible: boolean }> {
const { bookByFolderPath, booksByParentDir, fileByPath, fileByIno } = maps;
const counts = { added: 0, updated: 0 };
const fileCounts: ScanCounts = { addedCount: 0, updatedCount: 0, missingCount: 0 };
const retainedFileIds = new Set<number>();
const book = await this.upsertBook(candidate, libraryId, libraryFolderId, bookByFolderPath, booksByParentDir, fileByPath, fileByIno, fileCounts);
const book = await this.upsertBook(
candidate,
libraryId,
libraryFolderId,
bookByFolderPath,
booksByParentDir,
fileByPath,
fileByIno,
fileCounts,
candidateFolderPaths,
);
// If the book was transferred from another library, its files exist globally
// but not in our local maps - we need global lookups even on a "first scan"
const skipGlobalLookups = isFirstScan && fileCounts.addedCount > 0;
@@ -1348,8 +1387,18 @@ export class ScannerService implements OnApplicationBootstrap {
fileByPath: Map<string, FileByPathEntry>,
fileByIno: Map<number, FileByInoEntry>,
counts: ScanCounts,
candidateFolderPaths: Set<string>,
) {
const existing = bookByFolderPath.get(candidate.folderPath);
const candidateOwnedBookIds = new Set<number>();
for (const file of candidate.files) {
const byPath = fileByPath.get(file.absolutePath);
if (byPath) candidateOwnedBookIds.add(byPath.bookId);
if (file.ino !== 0) {
const byIno = fileByIno.get(file.ino);
if (byIno) candidateOwnedBookIds.add(byIno.bookId);
}
}
if (!existing) {
// Detect series-to-single-book merge: files were renamed so all stems match,
@@ -1358,7 +1407,11 @@ export class ScannerService implements OnApplicationBootstrap {
// and pick the lowest-ID one as the survivor to preserve its reading progress.
// Use pre-built parent-dir index instead of O(N) filter
const virtualChildren = (booksByParentDir.get(candidate.folderPath) ?? []).filter(
(b) => b.folderPath !== candidate.folderPath && b.folderPath.startsWith(candidate.folderPath + sep),
(b) =>
b.folderPath !== candidate.folderPath &&
b.folderPath.startsWith(candidate.folderPath + sep) &&
!candidateFolderPaths.has(b.folderPath) &&
candidateOwnedBookIds.has(b.id),
);
if (virtualChildren.length > 0) {
@@ -1408,21 +1461,19 @@ export class ScannerService implements OnApplicationBootstrap {
this.bufferBooksRestoredNotification(libraryId, [existing.id]);
}
// Drain any virtual siblings that share this real folder (created by old stem-split
// logic or by detectMovedFile updating a book's folderPath to the real directory).
// Marking them missing here ensures processFile will reassign their files to
// "existing", and reconcile cannot restore them once their files are gone.
// Use pre-built parent-dir index instead of O(N) filter
// Defer virtual sibling cleanup until the final missing pass so real nested
// book folders can still be processed when their own candidates appear later.
// Use pre-built parent-dir index instead of O(N) filter.
const virtualSiblings = (booksByParentDir.get(candidate.folderPath) ?? []).filter(
(b) => b.id !== existing.id && b.folderPath !== candidate.folderPath && b.folderPath.startsWith(candidate.folderPath + sep),
(b) =>
b.id !== existing.id &&
b.folderPath !== candidate.folderPath &&
b.folderPath.startsWith(candidate.folderPath + sep) &&
!candidateFolderPaths.has(b.folderPath),
);
if (virtualSiblings.length > 0) {
const siblingIds = virtualSiblings.map((b) => b.id);
await this.scannerRepo.markBooksAsMissing(siblingIds);
this.scanGateway.emitBookMissing({ libraryId, bookIds: siblingIds });
this.bufferBooksUnavailableNotification(libraryId, siblingIds);
this.logger.log(
`[scanner.upsert_book] [end] libraryId=${libraryId} bookId=${existing.id} folder="${sanitizeLogValue(candidate.folderPath)}" drainedCount=${virtualSiblings.length} action=drain_virtual_siblings - virtual siblings drained`,
`[scanner.upsert_book] [end] libraryId=${libraryId} bookId=${existing.id} folder="${sanitizeLogValue(candidate.folderPath)}" siblingCount=${virtualSiblings.length} action=defer_virtual_siblings - virtual sibling cleanup deferred`,
);
}
@@ -1515,6 +1566,25 @@ export class ScannerService implements OnApplicationBootstrap {
return null;
}
private async filterBookIdsMissingOnDisk(bookIds: number[]): Promise<number[]> {
if (bookIds.length === 0) return [];
const files = (await this.scannerRepo.findBookFilesByBookIds(bookIds)).filter((file) => file.role === 'content');
const presentBookIds = new Set<number>();
for (let i = 0; i < files.length; i += MISSING_FILE_STAT_BATCH_SIZE) {
const batch = files.slice(i, i + MISSING_FILE_STAT_BATCH_SIZE);
await Promise.all(
batch.map(async (file) => {
const fileStat = await stat(file.absolutePath).catch(() => null);
if (fileStat?.isFile()) presentBookIds.add(file.bookId);
}),
);
}
return bookIds.filter((bookId) => !presentBookIds.has(bookId));
}
private async tryTransferMissingBook(
candidate: BookCandidate,
libraryId: number,
+15 -13
View File
@@ -143,10 +143,11 @@ const structuralScenarios: StructuralScenario[] = [
operations: [{ type: 'move', from: 'lib-a/Old', to: 'lib-a/New' }],
expected: {
a: {
statusByFolder: { Old: 'missing', New: 'present' },
statusByFolder: { New: 'present' },
absentFolders: ['Old'],
fileOwners: { 'New/book.epub': 'New' },
presentCount: 1,
missingCount: 1,
missingCount: 0,
},
},
},
@@ -158,10 +159,11 @@ const structuralScenarios: StructuralScenario[] = [
operations: [{ type: 'move', from: 'lib-a/book.epub', to: 'lib-a/Shelf/book.epub' }],
expected: {
a: {
statusByFolder: { 'book.epub': 'missing', Shelf: 'present' },
statusByFolder: { Shelf: 'present' },
absentFolders: ['book.epub'],
fileOwners: { 'Shelf/book.epub': 'Shelf' },
presentCount: 1,
missingCount: 1,
missingCount: 0,
},
},
},
@@ -173,10 +175,11 @@ const structuralScenarios: StructuralScenario[] = [
operations: [{ type: 'move', from: 'lib-a/Shelf/book.epub', to: 'lib-a/book.epub' }],
expected: {
a: {
statusByFolder: { Shelf: 'missing', 'book.epub': 'present' },
statusByFolder: { 'book.epub': 'present' },
absentFolders: ['Shelf'],
fileOwners: { 'book.epub': 'book.epub' },
presentCount: 1,
missingCount: 1,
missingCount: 0,
},
},
},
@@ -233,16 +236,16 @@ const structuralScenarios: StructuralScenario[] = [
a: {
statusByFolder: {
'A/One': 'missing',
'A/Two': 'missing',
'B/Two': 'present',
C: 'present',
},
absentFolders: ['A/Two'],
fileOwners: {
'B/Two/two.epub': 'B/Two',
'C/three.epub': 'C',
},
presentCount: 2,
missingCount: 2,
missingCount: 1,
},
},
},
@@ -258,13 +261,13 @@ const structuralScenarios: StructuralScenario[] = [
expected: {
a: {
statusByFolder: {
'track1.mp3': 'missing',
'track2.mp3': 'missing',
'Archive/track1.mp3': 'present',
},
absentFolders: ['track1.mp3'],
fileOwners: { 'Archive/track1.mp3': 'Archive/track1.mp3' },
presentCount: 1,
missingCount: 2,
missingCount: 1,
},
},
},
@@ -355,17 +358,16 @@ const structuralScenarios: StructuralScenario[] = [
expected: {
a: {
statusByFolder: {
'Batch/one.mp3': 'missing',
'Batch/two.mp3': 'missing',
'Archive/Batch/one.mp3': 'present',
'Archive/Batch/two.mp3': 'present',
},
absentFolders: ['Batch/one.mp3', 'Batch/two.mp3'],
fileOwners: {
'Archive/Batch/one.mp3': 'Archive/Batch/one.mp3',
'Archive/Batch/two.mp3': 'Archive/Batch/two.mp3',
},
presentCount: 2,
missingCount: 2,
missingCount: 0,
},
},
},