Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(server): external library sync not working for large libraries #7759

Merged
merged 8 commits into from Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
49 changes: 45 additions & 4 deletions server/src/domain/library/library.service.spec.ts
Expand Up @@ -156,8 +156,7 @@ describe(LibraryService.name, () => {

libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getPathsNotInLibrary.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });

await sut.handleQueueAssetRefresh(mockLibraryJob);

Expand All @@ -183,7 +182,7 @@ describe(LibraryService.name, () => {

libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });

await sut.handleQueueAssetRefresh(mockLibraryJob);

Expand Down Expand Up @@ -233,7 +232,7 @@ describe(LibraryService.name, () => {

libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
storageMock.crawl.mockResolvedValue([]);
assetMock.getByLibraryId.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });

await sut.handleQueueAssetRefresh(mockLibraryJob);

Expand All @@ -242,6 +241,48 @@ describe(LibraryService.name, () => {
exclusionPatterns: [],
});
});

it('should set missing assets offline', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};

libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({
items: [assetStub.image],
hasNextPage: false,
});

await sut.handleQueueAssetRefresh(mockLibraryJob);

expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true });
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false });
expect(jobMock.queueAll).not.toHaveBeenCalled();
});

it('should set crawled assets that were previously offline back online', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};

libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue([assetStub.offline.originalPath]);
assetMock.getLibraryAssetPaths.mockResolvedValue({
items: [assetStub.offline],
hasNextPage: false,
});

await sut.handleQueueAssetRefresh(mockLibraryJob);

expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.offline.id], { isOffline: false });
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true });
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
});

describe('handleAssetRefresh', () => {
Expand Down
53 changes: 41 additions & 12 deletions server/src/domain/library/library.service.ts
Expand Up @@ -640,27 +640,56 @@ export class LibraryService extends EventEmitter {
.filter((validation) => validation.isValid)
.map((validation) => validation.importPath);

const rawPaths = await this.storageRepository.crawl({
let rawPaths = await this.storageRepository.crawl({
pathsToCrawl: validImportPaths,
exclusionPatterns: library.exclusionPatterns,
});
const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath));
const crawledAssetPaths = new Set<string>(rawPaths);

this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`);
const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles;
let pathsToScan: string[] = shouldScanAll ? rawPaths : [];
rawPaths = [];
mertalev marked this conversation as resolved.
Show resolved Hide resolved

await this.assetRepository.updateOfflineLibraryAssets(library.id, crawledAssetPaths);
this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`);

if (crawledAssetPaths.length > 0) {
let filteredPaths: string[] = [];
if (job.refreshAllFiles || job.refreshModifiedFiles) {
filteredPaths = crawledAssetPaths;
} else {
filteredPaths = await this.assetRepository.getPathsNotInLibrary(library.id, crawledAssetPaths);
const assetIdsToMarkOffline = [];
const assetIdsToMarkOnline = [];
const pagination = usePagination(5000, (pagination) =>
this.assetRepository.getLibraryAssetPaths(pagination, library.id),
);

for await (const page of pagination) {
for (const asset of page) {
const isOffline = !crawledAssetPaths.has(asset.originalPath);
if (isOffline && !asset.isOffline) {
assetIdsToMarkOffline.push(asset.id);
}

this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`);
if (!isOffline && asset.isOffline) {
assetIdsToMarkOnline.push(asset.id);
}

crawledAssetPaths.delete(asset.originalPath);
}
}

if (assetIdsToMarkOffline.length > 0) {
this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`);
await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true });
}

if (assetIdsToMarkOnline.length > 0) {
this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`);
await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false });
}

if (!shouldScanAll) {
pathsToScan = [...crawledAssetPaths];
this.logger.debug(`Will import ${pathsToScan.length} new asset(s)`);
}

await this.scanAssets(job.id, filteredPaths, library.ownerId, job.refreshAllFiles ?? false);
if (pathsToScan.length > 0) {
await this.scanAssets(job.id, pathsToScan, library.ownerId, job.refreshAllFiles ?? false);
}

await this.repository.update({ id: job.id, refreshedAt: new Date() });
Expand Down
4 changes: 1 addition & 3 deletions server/src/domain/repositories/asset.repository.ts
Expand Up @@ -129,10 +129,8 @@ export interface IAssetRepository {
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]>;
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetEntity>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise<string[]>;
updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise<void>;
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
Expand Down
8 changes: 4 additions & 4 deletions server/src/infra/repositories/asset.repository.ts
Expand Up @@ -184,10 +184,10 @@ export class AssetRepository implements IAssetRepository {
}

@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { library: { id: In(libraryIds) } },
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetEntity> {
mertalev marked this conversation as resolved.
Show resolved Hide resolved
return paginate(this.repository, pagination, {
select: { id: true, originalPath: true, isOffline: true },
where: { library: { id: libraryId } },
});
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/infra/repositories/filesystem.provider.ts
Expand Up @@ -123,7 +123,7 @@ export class FilesystemProvider implements IStorageRepository {

crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
if (!pathsToCrawl) {
if (pathsToCrawl.length === 0) {
return Promise.resolve([]);
}

Expand Down
47 changes: 0 additions & 47 deletions server/src/infra/sql/asset.repository.sql
Expand Up @@ -293,53 +293,6 @@ DELETE FROM "assets"
WHERE
"ownerId" = $1

-- AssetRepository.getByLibraryId
SELECT
"AssetEntity"."id" AS "AssetEntity_id",
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
"AssetEntity"."ownerId" AS "AssetEntity_ownerId",
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."resizePath" AS "AssetEntity_resizePath",
"AssetEntity"."webpPath" AS "AssetEntity_webpPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
"AssetEntity"."createdAt" AS "AssetEntity_createdAt",
"AssetEntity"."updatedAt" AS "AssetEntity_updatedAt",
"AssetEntity"."deletedAt" AS "AssetEntity_deletedAt",
"AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt",
"AssetEntity"."localDateTime" AS "AssetEntity_localDateTime",
"AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt",
"AssetEntity"."isFavorite" AS "AssetEntity_isFavorite",
"AssetEntity"."isArchived" AS "AssetEntity_isArchived",
"AssetEntity"."isExternal" AS "AssetEntity_isExternal",
"AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly",
"AssetEntity"."isOffline" AS "AssetEntity_isOffline",
"AssetEntity"."checksum" AS "AssetEntity_checksum",
"AssetEntity"."duration" AS "AssetEntity_duration",
"AssetEntity"."isVisible" AS "AssetEntity_isVisible",
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
"AssetEntity"."stackId" AS "AssetEntity_stackId"
FROM
"assets" "AssetEntity"
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
AND (
"AssetEntity__AssetEntity_library"."deletedAt" IS NULL
)
WHERE
(
(
(
(("AssetEntity__AssetEntity_library"."id" IN ($1)))
)
)
)
AND ("AssetEntity"."deletedAt" IS NULL)

-- AssetRepository.getByLibraryIdAndOriginalPath
SELECT DISTINCT
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
Expand Down
4 changes: 1 addition & 3 deletions server/test/repositories/asset.repository.mock.ts
Expand Up @@ -20,10 +20,8 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
getAllByDeviceId: jest.fn(),
updateAll: jest.fn(),
getByLibraryId: jest.fn(),
getLibraryAssetPaths: jest.fn(),
getByLibraryIdAndOriginalPath: jest.fn(),
updateOfflineLibraryAssets: jest.fn(),
getPathsNotInLibrary: jest.fn(),
deleteAll: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
Expand Down