fix(library): ignore current library during folder prescan

Prevent an edited library from treating its own persisted folders as conflicts while preserving overlap warnings for other libraries.

Fixes #1340
This commit is contained in:
neon
2026-09-10 11:02:32 +00:00
parent 3a4d467255
commit e514be151a
7 changed files with 83 additions and 4 deletions
@@ -1,6 +1,11 @@
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ArrayMinSize, IsArray, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class PrescanLibraryDto {
@IsOptional()
@IsInt()
@Min(1)
libraryId?: number;
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
@@ -61,9 +61,13 @@ describe('Library DTO validation', () => {
expect(await hasErrors(plainToInstance(UpdateLibraryAccessDto, { accessLevel: 'owner' }))).toBe(false);
});
it('PrescanLibraryDto requires at least one non-empty path', async () => {
it('PrescanLibraryDto requires at least one non-empty path and validates an optional library ID', async () => {
expect(await hasErrors(plainToInstance(PrescanLibraryDto, { paths: [''] }))).toBe(true);
expect(await hasErrors(plainToInstance(PrescanLibraryDto, { paths: ['/books'] }))).toBe(false);
expect(await hasErrors(plainToInstance(PrescanLibraryDto, { paths: ['/books'], libraryId: 12 }))).toBe(false);
expect(await hasErrors(plainToInstance(PrescanLibraryDto, { paths: ['/books'], libraryId: 0 }))).toBe(true);
expect(await hasErrors(plainToInstance(PrescanLibraryDto, { paths: ['/books'], libraryId: 1.5 }))).toBe(true);
expect(await hasErrors(plainToInstance(PrescanLibraryDto, { paths: ['/books'], libraryId: '12' }))).toBe(true);
});
it('ReorderLibrariesDto validates nested order items', async () => {
@@ -462,7 +462,7 @@ describe('LibraryService', () => {
});
it('prescan counts primary files recursively and flags overlapping paths', async () => {
libraryRepo.findAllFolderPaths.mockResolvedValue([{ path: '/books/existing', libraryName: 'Existing Library' }]);
libraryRepo.findAllFolderPaths.mockResolvedValue([{ libraryId: 1, path: '/books/existing', libraryName: 'Existing Library' }]);
mockReaddir.mockImplementation((path: Parameters<typeof readdir>[0]) => {
if (path === '/books/new') {
@@ -483,6 +483,34 @@ describe('LibraryService', () => {
expect(result.paths[1]).toEqual(expect.objectContaining({ overlapLibrary: 'Existing Library' }));
});
it('prescan ignores persisted folders from the library being edited', async () => {
libraryRepo.findAllFolderPaths.mockResolvedValue([
{ libraryId: 10, path: '/books/audiobooks', libraryName: 'Audiobooks' },
{ libraryId: 20, path: '/books/ebooks', libraryName: 'eBooks' },
]);
const result = await service.prescan({ paths: ['/books/audiobooks'], libraryId: 10 });
expect(result.paths[0]).toEqual({
path: '/books/audiobooks',
accessible: true,
fileCount: 0,
overlapLibrary: undefined,
error: undefined,
});
});
it('prescan still reports a different overlapping library after ignoring the edited library', async () => {
libraryRepo.findAllFolderPaths.mockResolvedValue([
{ libraryId: 10, path: '/books/audiobooks', libraryName: 'Audiobooks' },
{ libraryId: 20, path: '/books', libraryName: 'All Books' },
]);
const result = await service.prescan({ paths: ['/books/audiobooks'], libraryId: 10 });
expect(result.paths[0]).toEqual(expect.objectContaining({ overlapLibrary: 'All Books' }));
});
it('prescan reports paths outside the configured browse root without touching the filesystem', async () => {
libraryRepo.findAllFolderPaths.mockResolvedValue([]);
pathPolicy.resolveBrowsePath.mockRejectedValue(new ForbiddenException('outside root'));
@@ -292,6 +292,7 @@ export class LibraryService {
}
for (const existing of allFolderPaths) {
if (dto.libraryId !== undefined && existing.libraryId === dto.libraryId) continue;
if (pathsOverlap(resolvedInputPath, existing.path)) {
overlapLibrary = existing.libraryName;
break;
@@ -308,6 +308,25 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
},
]);
const editPrescanResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/libraries/prescan',
headers: authHeader(manager.accessToken),
payload: { paths: [overlapLibrary.folderPath], libraryId: overlapLibrary.libraryId },
});
expect(editPrescanResponse.statusCode).toBe(201);
expect(editPrescanResponse.json()).toEqual({
totalFiles: 0,
paths: [
{
path: overlapLibrary.folderPath,
accessible: true,
fileCount: 0,
},
],
});
const forbiddenPrescanResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/libraries/prescan',