refactor(server): non-nullable file metadata (#17598)

This commit is contained in:
Mert 2025-04-15 07:03:31 -04:00 committed by GitHub
parent bd92748ddd
commit c3d10c5be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 49 additions and 66 deletions

View File

@ -93,7 +93,7 @@ const create = (path: string, up: string[], down: string[]) => {
const filename = `${timestamp}-${name}.ts`; const filename = `${timestamp}-${name}.ts`;
const folder = dirname(path); const folder = dirname(path);
const fullPath = join(folder, filename); const fullPath = join(folder, filename);
writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down })); writeFileSync(fullPath, asMigration('typeorm', { name, timestamp, up, down }));
console.log(`Wrote ${fullPath}`); console.log(`Wrote ${fullPath}`);
}; };

View File

@ -313,8 +313,5 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
) )
.$if(!!options.withExif, withExifInner) .$if(!!options.withExif, withExifInner)
.$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)) .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null);
} }

View File

@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MakeFileMetadataNonNullable1744662638410 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM assets WHERE "fileCreatedAt" IS NULL OR "fileModifiedAt" IS NULL OR "localDateTime" IS NULL`,
);
await queryRunner.query(`
ALTER TABLE assets
ALTER COLUMN "fileCreatedAt" SET NOT NULL,
ALTER COLUMN "fileModifiedAt" SET NOT NULL,
ALTER COLUMN "localDateTime" SET NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ALTER COLUMN "fileCreatedAt" DROP NOT NULL,
ALTER COLUMN "fileModifiedAt" DROP NOT NULL,
ALTER COLUMN "localDateTime" DROP NOT NULL`);
}
}

View File

@ -71,6 +71,3 @@ where
and "activity"."albumId" = $2 and "activity"."albumId" = $2
and "activity"."isLiked" = $3 and "activity"."isLiked" = $3
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null

View File

@ -171,9 +171,6 @@ where
"ownerId" = $1::uuid "ownerId" = $1::uuid
and "deviceId" = $2 and "deviceId" = $2
and "isVisible" = $3 and "isVisible" = $3
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
and "deletedAt" is null and "deletedAt" is null
-- AssetRepository.getLivePhotoCount -- AssetRepository.getLivePhotoCount
@ -334,9 +331,6 @@ with
where where
"assets"."deletedAt" is null "assets"."deletedAt" is null
and "assets"."isVisible" = $2 and "assets"."isVisible" = $2
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
) )
select select
"timeBucket", "timeBucket",
@ -490,9 +484,6 @@ from
where where
"assets"."ownerId" = $1::uuid "assets"."ownerId" = $1::uuid
and "assets"."isVisible" = $2 and "assets"."isVisible" = $2
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
and "assets"."updatedAt" <= $3 and "assets"."updatedAt" <= $3
and "assets"."id" > $4 and "assets"."id" > $4
order by order by
@ -523,9 +514,6 @@ from
where where
"assets"."ownerId" = any ($1::uuid[]) "assets"."ownerId" = any ($1::uuid[])
and "assets"."isVisible" = $2 and "assets"."isVisible" = $2
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
and "assets"."updatedAt" > $3 and "assets"."updatedAt" > $3
limit limit
$4 $4

View File

@ -13,9 +13,6 @@ where
and "assets"."isFavorite" = $4 and "assets"."isFavorite" = $4
and "assets"."isArchived" = $5 and "assets"."isArchived" = $5
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
order by order by
"assets"."fileCreatedAt" desc "assets"."fileCreatedAt" desc
limit limit
@ -37,9 +34,6 @@ offset
and "assets"."isFavorite" = $4 and "assets"."isFavorite" = $4
and "assets"."isArchived" = $5 and "assets"."isArchived" = $5
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
and "assets"."id" < $6 and "assets"."id" < $6
order by order by
random() random()
@ -60,9 +54,6 @@ union all
and "assets"."isFavorite" = $11 and "assets"."isFavorite" = $11
and "assets"."isArchived" = $12 and "assets"."isArchived" = $12
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
and "assets"."id" > $13 and "assets"."id" > $13
order by order by
random() random()
@ -86,9 +77,6 @@ where
and "assets"."isFavorite" = $4 and "assets"."isFavorite" = $4
and "assets"."isArchived" = $5 and "assets"."isArchived" = $5
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."fileCreatedAt" is not null
and "assets"."fileModifiedAt" is not null
and "assets"."localDateTime" is not null
order by order by
smart_search.embedding <=> $6 smart_search.embedding <=> $6
limit limit

View File

@ -76,9 +76,6 @@ export class ActivityRepository {
.where('activity.albumId', '=', albumId) .where('activity.albumId', '=', albumId)
.where('activity.isLiked', '=', false) .where('activity.isLiked', '=', false)
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
return count; return count;

View File

@ -457,9 +457,6 @@ export class AssetRepository {
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('deviceId', '=', deviceId) .where('deviceId', '=', deviceId)
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.execute(); .execute();
@ -674,8 +671,7 @@ export class AssetRepository {
'exif.timeZone', 'exif.timeZone',
'exif.fileSizeInByte', 'exif.fileSizeInByte',
]) ])
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null);
.where('assets.fileCreatedAt', 'is not', null);
} }
getStorageTemplateAsset(id: string): Promise<StorageAsset | undefined> { getStorageTemplateAsset(id: string): Promise<StorageAsset | undefined> {
@ -712,10 +708,7 @@ export class AssetRepository {
.where('job_status.duplicatesDetectedAt', 'is', null) .where('job_status.duplicatesDetectedAt', 'is', null)
.where('job_status.previewAt', 'is not', null) .where('job_status.previewAt', 'is not', null)
.where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id')))) .where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id'))))
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true),
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null),
) )
.$if(property === WithoutProperty.ENCODED_VIDEO, (qb) => .$if(property === WithoutProperty.ENCODED_VIDEO, (qb) =>
qb qb
@ -778,9 +771,6 @@ export class AssetRepository {
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null)
.where('isVisible', '=', true) .where('isVisible', '=', true)
.$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!)) .$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!))
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
@ -813,9 +803,6 @@ export class AssetRepository {
.$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)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null)
.$if(!!options.albumId, (qb) => .$if(!!options.albumId, (qb) =>
qb qb
.innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId')
@ -1009,9 +996,6 @@ export class AssetRepository {
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
.where('assets.ownerId', '=', asUuid(ownerId)) .where('assets.ownerId', '=', asUuid(ownerId))
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null)
.where('assets.updatedAt', '<=', updatedUntil) .where('assets.updatedAt', '<=', updatedUntil)
.$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!)) .$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
.orderBy('assets.id') .orderBy('assets.id')
@ -1040,9 +1024,6 @@ export class AssetRepository {
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
.where('assets.ownerId', '=', anyUuid(options.userIds)) .where('assets.ownerId', '=', anyUuid(options.userIds))
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.fileCreatedAt', 'is not', null)
.where('assets.fileModifiedAt', 'is not', null)
.where('assets.localDateTime', 'is not', null)
.where('assets.updatedAt', '>', options.updatedAfter) .where('assets.updatedAt', '>', options.updatedAfter)
.limit(options.limit) .limit(options.limit)
.execute() as any as Promise<AssetEntity[]>; .execute() as any as Promise<AssetEntity[]>;

View File

@ -79,10 +79,10 @@ export class AssetTable {
originalPath!: string; originalPath!: string;
@ColumnIndex('idx_asset_file_created_at') @ColumnIndex('idx_asset_file_created_at')
@Column({ type: 'timestamp with time zone', default: null }) @Column({ type: 'timestamp with time zone' })
fileCreatedAt!: Date; fileCreatedAt!: Date;
@Column({ type: 'timestamp with time zone', default: null }) @Column({ type: 'timestamp with time zone' })
fileModifiedAt!: Date; fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
@ -135,7 +135,7 @@ export class AssetTable {
@DeleteDateColumn() @DeleteDateColumn()
deletedAt!: Date | null; deletedAt!: Date | null;
@Column({ type: 'timestamp with time zone', default: null }) @Column({ type: 'timestamp with time zone' })
localDateTime!: Date; localDateTime!: Date;
@ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) @ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })

View File

@ -1,10 +1,12 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely';
import { R_OK } from 'node:constants'; import { R_OK } from 'node:constants';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import path, { basename, isAbsolute, parse } from 'node:path'; import path, { basename, isAbsolute, parse } from 'node:path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Assets } from 'src/db';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { import {
CreateLibraryDto, CreateLibraryDto,
@ -236,7 +238,14 @@ export class LibraryService extends BaseService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const assetImports = job.paths.map((assetPath) => this.processEntity(assetPath, library.ownerId, job.libraryId)); const assetImports: Insertable<Assets>[] = [];
await Promise.all(
job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId)
.then((asset) => assetImports.push(asset))
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)),
),
);
const assetIds: string[] = []; const assetIds: string[] = [];
@ -374,8 +383,9 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private processEntity(filePath: string, ownerId: string, libraryId: string) { private async processEntity(filePath: string, ownerId: string, libraryId: string) {
const assetPath = path.normalize(filePath); const assetPath = path.normalize(filePath);
const stat = await this.storageRepository.stat(assetPath);
return { return {
ownerId, ownerId,
@ -383,9 +393,9 @@ export class LibraryService extends BaseService {
checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`), checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`),
originalPath: assetPath, originalPath: assetPath,
fileCreatedAt: null, fileCreatedAt: stat.mtime,
fileModifiedAt: null, fileModifiedAt: stat.mtime,
localDateTime: null, localDateTime: stat.mtime,
// 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',

View File

@ -95,6 +95,9 @@ export class TestFactory {
originalPath: '/path/to/something.jpg', originalPath: '/path/to/something.jpg',
ownerId: '@immich.cloud', ownerId: '@immich.cloud',
isVisible: true, isVisible: true,
fileCreatedAt: new Date('2000-01-01T00:00:00Z'),
fileModifiedAt: new Date('2000-01-01T00:00:00Z'),
localDateTime: new Date('2000-01-01T00:00:00Z'),
}; };
return { return {

View File

@ -418,7 +418,7 @@ describe(SyncService.name, () => {
fileModifiedAt: date, fileModifiedAt: date,
isFavorite: false, isFavorite: false,
isVisible: true, isVisible: true,
localDateTime: null, localDateTime: '2000-01-01T00:00:00.000Z',
type: asset.type, type: asset.type,
}, },
type: 'AssetV1', type: 'AssetV1',
@ -521,7 +521,7 @@ describe(SyncService.name, () => {
fileModifiedAt: date, fileModifiedAt: date,
isFavorite: false, isFavorite: false,
isVisible: true, isVisible: true,
localDateTime: null, localDateTime: '2000-01-01T00:00:00.000Z',
type: asset.type, type: asset.type,
}, },
type: SyncEntityType.PartnerAssetV1, type: SyncEntityType.PartnerAssetV1,