0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-04 01:09:14 -05:00
This commit is contained in:
Jonathan Jogenfors 2025-01-20 14:48:22 +01:00
parent 24de62f005
commit 9af281bbdc
8 changed files with 71 additions and 34 deletions

View file

@ -1,7 +1,6 @@
import { Insertable, Updateable, UpdateResult } from 'kysely'; import { Insertable, Updateable, UpdateResult } from 'kysely';
import { AssetJobStatus, Assets, Exif } from 'src/db'; import { AssetJobStatus, Assets, Exif } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { Paginated, PaginationOptions } from 'src/utils/pagination';
@ -171,7 +170,11 @@ export interface IAssetRepository {
getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise<AssetEntity[]>;
upsertFile(file: UpsertFileOptions): Promise<void>; upsertFile(file: UpsertFileOptions): Promise<void>;
upsertFiles(files: UpsertFileOptions[]): Promise<void>; upsertFiles(files: UpsertFileOptions[]): Promise<void>;
detectOfflineExternalAssets(library: LibraryEntity): Promise<UpdateResult>; detectOfflineExternalAssets(
libraryId: string,
importPaths: string[],
exclusionPatterns: string[],
): Promise<UpdateResult>;
filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise<string[]>; filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise<string[]>;
getLibraryAssetCount(options: AssetSearchOptions): Promise<number | undefined>; getLibraryAssetCount(options: AssetSearchOptions): Promise<number | undefined>;
} }

View file

@ -13,7 +13,7 @@ if (immichApp) {
let apiProcess: ChildProcess | undefined; let apiProcess: ChildProcess | undefined;
const onError = (name: string, error: Error) => { const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}`); console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}; };
const onExit = (name: string, exitCode: number | null) => { const onExit = (name: string, exitCode: number | null) => {

View file

@ -262,8 +262,9 @@ with
from from
"assets" "assets"
where where
"assets"."deletedAt" is null "assets"."fileCreatedAt" <= $2
and "assets"."isVisible" = $2 and "assets"."deletedAt" is null
and "assets"."isVisible" = $3
) )
select select
"timeBucket", "timeBucket",
@ -283,9 +284,10 @@ from
"assets" "assets"
left join "exif" on "assets"."id" = "exif"."assetId" left join "exif" on "assets"."id" = "exif"."assetId"
where where
"assets"."deletedAt" is null "assets"."fileCreatedAt" <= $1
and "assets"."isVisible" = $1 and "assets"."deletedAt" is null
and date_trunc($2, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $3 and "assets"."isVisible" = $2
and date_trunc($3, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
order by order by
"assets"."localDateTime" desc "assets"."localDateTime" desc

View file

@ -22,7 +22,6 @@ import {
withStack, withStack,
withTags, withTags,
} from 'src/entities/asset.entity'; } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { import {
AssetDeltaSyncOptions, AssetDeltaSyncOptions,
@ -50,6 +49,8 @@ import { anyUuid, asUuid, mapUpsertColumns } from 'src/utils/database';
import { globToSqlPattern } from 'src/utils/misc'; import { globToSqlPattern } from 'src/utils/misc';
import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination';
const ASSET_CUTOFF_DATE = new Date('9000-01-01');
@Injectable() @Injectable()
export class AssetRepository implements IAssetRepository { export class AssetRepository implements IAssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@ -527,6 +528,7 @@ export class AssetRepository implements IAssetRepository {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.where('assets.fileCreatedAt', '<=', ASSET_CUTOFF_DATE)
.$call(withExif) .$call(withExif)
.where('ownerId', '=', anyUuid(userIds)) .where('ownerId', '=', anyUuid(userIds))
.where('isVisible', '=', true) .where('isVisible', '=', true)
@ -543,6 +545,7 @@ export class AssetRepository implements IAssetRepository {
.with('assets', (qb) => .with('assets', (qb) =>
qb qb
.selectFrom('assets') .selectFrom('assets')
.where('assets.fileCreatedAt', '<=', ASSET_CUTOFF_DATE)
.select(truncatedDate<Date>(options.size).as('timeBucket')) .select(truncatedDate<Date>(options.size).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
@ -592,6 +595,7 @@ export class AssetRepository implements IAssetRepository {
async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> { async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
return hasPeople(this.db, options.personId ? [options.personId] : undefined) return hasPeople(this.db, options.personId ? [options.personId] : undefined)
.selectAll('assets') .selectAll('assets')
.where('assets.fileCreatedAt', '<=', ASSET_CUTOFF_DATE)
.$call(withExif) .$call(withExif)
.$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId })) .$if(!!options.albumId, (qb) => withAlbums(qb, { albumId: options.albumId }))
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
@ -748,9 +752,16 @@ export class AssetRepository implements IAssetRepository {
.execute(); .execute();
} }
async detectOfflineExternalAssets(library: LibraryEntity): Promise<UpdateResult> { @GenerateSql({
const paths = library.importPaths.map((importPath) => `${importPath}%`); params: [{ libraryId: DummyValue.UUID, importPaths: [DummyValue.STRING], exclusionPatterns: [DummyValue.STRING] }],
const exclusions = library.exclusionPatterns.map((pattern) => globToSqlPattern(pattern)); })
async detectOfflineExternalAssets(
libraryId: string,
importPaths: string[],
exclusionPatterns: string[],
): Promise<UpdateResult> {
const paths = importPaths.map((importPath) => `${importPath}%`);
const exclusions = exclusionPatterns.map((pattern) => globToSqlPattern(pattern));
return this.db return this.db
.updateTable('assets') .updateTable('assets')
@ -760,13 +771,16 @@ export class AssetRepository implements IAssetRepository {
}) })
.where('isOffline', '=', false) .where('isOffline', '=', false)
.where('isExternal', '=', true) .where('isExternal', '=', true)
.where('libraryId', '=', asUuid(library.id)) .where('libraryId', '=', asUuid(libraryId))
.where((eb) => .where((eb) =>
eb.or([eb('originalPath', 'not like', paths.join('|')), eb('originalPath', 'like', exclusions.join('|'))]), eb.or([eb('originalPath', 'not like', paths.join('|')), eb('originalPath', 'like', exclusions.join('|'))]),
) )
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
@GenerateSql({
params: [{ libraryId: DummyValue.UUID, paths: [DummyValue.STRING] }],
})
async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise<string[]> { async filterNewExternalAssetPaths(libraryId: string, paths: string[]): Promise<string[]> {
const result = await this.db const result = await this.db
.selectFrom( .selectFrom(

View file

@ -231,7 +231,11 @@ describe(LibraryService.name, () => {
const response = await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); const response = await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id });
expect(response).toBe(JobStatus.SUCCESS); expect(response).toBe(JobStatus.SUCCESS);
expect(assetMock.detectOfflineExternalAssets).toHaveBeenCalledWith(libraryStub.externalLibrary1); expect(assetMock.detectOfflineExternalAssets).toHaveBeenCalledWith(
libraryStub.externalLibrary1.id,
libraryStub.externalLibrary1.importPaths,
libraryStub.externalLibrary1.exclusionPatterns,
);
}); });
it('should skip an empty library', async () => { it('should skip an empty library', async () => {
@ -270,7 +274,11 @@ describe(LibraryService.name, () => {
}); });
expect(response).toBe(JobStatus.SUCCESS); expect(response).toBe(JobStatus.SUCCESS);
expect(assetMock.detectOfflineExternalAssets).toHaveBeenCalledWith(libraryStub.externalLibraryWithImportPaths1); expect(assetMock.detectOfflineExternalAssets).toHaveBeenCalledWith(
libraryStub.externalLibraryWithImportPaths1.id,
libraryStub.externalLibraryWithImportPaths1.importPaths,
libraryStub.externalLibraryWithImportPaths1.exclusionPatterns,
);
}); });
it("should fail if library can't be found", async () => { it("should fail if library can't be found", async () => {

View file

@ -24,6 +24,8 @@ import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
const ASSET_IMPORT_DATE = new Date('9999-12-31');
@Injectable() @Injectable()
export class LibraryService extends BaseService { export class LibraryService extends BaseService {
private watchLibraries = false; private watchLibraries = false;
@ -181,7 +183,7 @@ export class LibraryService extends BaseService {
async getStatistics(id: string): Promise<number> { async getStatistics(id: string): Promise<number> {
const count = await this.assetRepository.getLibraryAssetCount({ libraryId: id }); const count = await this.assetRepository.getLibraryAssetCount({ libraryId: id });
if (count == undefined) { if (count === undefined) {
throw new InternalServerErrorException(`Failed to get asset count for library ${id}`); throw new InternalServerErrorException(`Failed to get asset count for library ${id}`);
} }
return count; return count;
@ -378,9 +380,9 @@ export class LibraryService extends BaseService {
checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`), checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`),
originalPath: assetPath, originalPath: assetPath,
fileCreatedAt: new Date(), fileCreatedAt: ASSET_IMPORT_DATE,
fileModifiedAt: new Date(), fileModifiedAt: ASSET_IMPORT_DATE,
localDateTime: new Date(), localDateTime: ASSET_IMPORT_DATE,
// TODO: device asset id is deprecated, remove it // TODO: device asset id is deprecated, remove it
deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''), deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''),
deviceId: 'Library Import', deviceId: 'Library Import',
@ -470,22 +472,25 @@ export class LibraryService extends BaseService {
case AssetSyncResult.CHECK_OFFLINE: { case AssetSyncResult.CHECK_OFFLINE: {
const isInImportPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); const isInImportPath = job.importPaths.find((path) => asset.originalPath.startsWith(path));
if (isInImportPath) { if (!isInImportPath) {
const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern));
if (isExcluded) {
this.logger.verbose(
`Offline asset ${asset.originalPath} is in an import path but still covered by exclusion pattern, keeping offline in library ${job.libraryId}`,
);
} else {
this.logger.debug(`Offline asset ${asset.originalPath} is now online in library ${job.libraryId}`);
assetIdsToOnline.push(asset.id);
}
} else {
this.logger.verbose( this.logger.verbose(
`Offline asset ${asset.originalPath} is still not in any import path, keeping offline in library ${job.libraryId}`, `Offline asset ${asset.originalPath} is still not in any import path, keeping offline in library ${job.libraryId}`,
); );
break;
} }
const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern));
if (!isExcluded) {
this.logger.debug(`Offline asset ${asset.originalPath} is now online in library ${job.libraryId}`);
assetIdsToOnline.push(asset.id);
break;
}
this.logger.verbose(
`Offline asset ${asset.originalPath} is in an import path but still covered by exclusion pattern, keeping offline in library ${job.libraryId}`,
);
break; break;
} }
} }
@ -696,7 +701,11 @@ export class LibraryService extends BaseService {
`Checking ${assetCount} asset(s) against import paths and exclusion patterns in library ${library.id}...`, `Checking ${assetCount} asset(s) against import paths and exclusion patterns in library ${library.id}...`,
); );
const offlineResult = await this.assetRepository.detectOfflineExternalAssets(library); const offlineResult = await this.assetRepository.detectOfflineExternalAssets(
library.id,
library.importPaths,
library.exclusionPatterns,
);
const affectedAssetCount = Number(offlineResult.numUpdatedRows); const affectedAssetCount = Number(offlineResult.numUpdatedRows);

View file

@ -175,10 +175,10 @@ export class MetadataService extends BaseService {
let fileCreatedAtDate = dateTimeOriginal; let fileCreatedAtDate = dateTimeOriginal;
let fileModifiedAtDate = modifyDate; let fileModifiedAtDate = modifyDate;
/* if (asset.isExternal) { if (asset.isExternal) {
fileCreatedAtDate = fileCreatedAt; fileCreatedAtDate = fileCreatedAt;
fileModifiedAtDate = fileModifiedAt; fileModifiedAtDate = fileModifiedAt;
} */ }
const exifData: Insertable<Exif> = { const exifData: Insertable<Exif> = {
assetId: asset.id, assetId: asset.id,

View file

@ -43,5 +43,6 @@ export const newAssetRepositoryMock = (): Mocked<IAssetRepository> => {
upsertFiles: vitest.fn(), upsertFiles: vitest.fn(),
detectOfflineExternalAssets: vitest.fn(), detectOfflineExternalAssets: vitest.fn(),
filterNewExternalAssetPaths: vitest.fn(), filterNewExternalAssetPaths: vitest.fn(),
updateByLibraryId: vitest.fn(),
}; };
}; };