feat(server): Nullable asset dates (#15669)

* nullable dates

* wip

* don't search for null dates

* Add placeholder type

* cleanup
This commit is contained in:
Jonathan Jogenfors 2025-02-13 22:30:12 +01:00 committed by GitHub
parent f5edc87e4d
commit 5407a28533
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 100 additions and 11 deletions

View File

@ -298,6 +298,7 @@ describe('/libraries', () => {
expect(status).toBe(204); expect(status).toBe(204);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const { assets } = await utils.searchAssets(admin.accessToken, { const { assets } = await utils.searchAssets(admin.accessToken, {
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,

6
server/src/db.d.ts vendored
View File

@ -122,8 +122,8 @@ export interface Assets {
duplicateId: string | null; duplicateId: string | null;
duration: string | null; duration: string | null;
encodedVideoPath: Generated<string | null>; encodedVideoPath: Generated<string | null>;
fileCreatedAt: Timestamp; fileCreatedAt: Timestamp | null;
fileModifiedAt: Timestamp; fileModifiedAt: Timestamp | null;
id: Generated<string>; id: Generated<string>;
isArchived: Generated<boolean>; isArchived: Generated<boolean>;
isExternal: Generated<boolean>; isExternal: Generated<boolean>;
@ -132,7 +132,7 @@ export interface Assets {
isVisible: Generated<boolean>; isVisible: Generated<boolean>;
libraryId: string | null; libraryId: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
localDateTime: Timestamp; localDateTime: Timestamp | null;
originalFileName: string; originalFileName: string;
originalPath: string; originalPath: string;
ownerId: string; ownerId: string;

View File

@ -100,13 +100,13 @@ export class AssetEntity {
deletedAt!: Date | null; deletedAt!: Date | null;
@Index('idx_asset_file_created_at') @Index('idx_asset_file_created_at')
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz', nullable: true, default: null })
fileCreatedAt!: Date; fileCreatedAt!: Date;
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz', nullable: true, default: null })
localDateTime!: Date; localDateTime!: Date;
@Column({ type: 'timestamptz' }) @Column({ type: 'timestamptz', nullable: true, default: null })
fileModifiedAt!: Date; fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
@ -180,6 +180,12 @@ export class AssetEntity {
duplicateId!: string | null; duplicateId!: string | null;
} }
export type AssetEntityPlaceholder = AssetEntity & {
fileCreatedAt: Date | null;
fileModifiedAt: Date | null;
localDateTime: Date | null;
};
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) { export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo')); return qb.leftJoin('exif', 'assets.id', 'exif.assetId').select((eb) => eb.fn.toJson(eb.table('exif')).as('exifInfo'));
} }
@ -419,5 +425,8 @@ 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,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class NullableDates1737845696644 implements MigrationInterface {
name = 'NullableDates1737845696644'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileCreatedAt" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileModifiedAt" DROP NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileModifiedAt" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "fileCreatedAt" SET NOT NULL`);
}
}

View File

@ -43,3 +43,6 @@ 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

@ -159,6 +159,9 @@ 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
@ -260,6 +263,9 @@ 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",

View File

@ -13,6 +13,9 @@ 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
@ -34,6 +37,9 @@ 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()
@ -54,6 +60,9 @@ 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()
@ -77,6 +86,9 @@ 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

@ -10,6 +10,9 @@ where
and "isVisible" = $3 and "isVisible" = $3
and "isArchived" = $4 and "isArchived" = $4
and "deletedAt" is null and "deletedAt" is null
and "fileModifiedAt" is not null
and "fileModifiedAt" is not null
and "localDateTime" is not null
-- ViewRepository.getAssetsByOriginalPath -- ViewRepository.getAssetsByOriginalPath
select select
@ -23,6 +26,9 @@ where
and "isVisible" = $2 and "isVisible" = $2
and "isArchived" = $3 and "isArchived" = $3
and "deletedAt" is null and "deletedAt" is null
and "fileModifiedAt" is not null
and "fileModifiedAt" is not null
and "localDateTime" is not null
and "originalPath" like $4 and "originalPath" like $4
and "originalPath" not like $5 and "originalPath" not like $5
order by order by

View File

@ -65,6 +65,9 @@ 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 as number; return count as number;

View File

@ -7,6 +7,7 @@ import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { import {
AssetEntity, AssetEntity,
AssetEntityPlaceholder,
hasPeople, hasPeople,
searchAssetBuilder, searchAssetBuilder,
truncatedDate, truncatedDate,
@ -183,8 +184,12 @@ export class AssetRepository {
.execute(); .execute();
} }
create(asset: Insertable<Assets>): Promise<AssetEntity> { create(asset: Insertable<Assets>): Promise<AssetEntityPlaceholder> {
return this.db.insertInto('assets').values(asset).returningAll().executeTakeFirst() as any as Promise<AssetEntity>; return this.db
.insertInto('assets')
.values(asset)
.returningAll()
.executeTakeFirst() as any as Promise<AssetEntityPlaceholder>;
} }
@GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] })
@ -395,6 +400,9 @@ 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();
@ -562,7 +570,10 @@ 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
@ -656,6 +667,9 @@ export class AssetRepository {
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
.select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) .select((eb) => eb.fn.countAll().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!))
@ -688,6 +702,9 @@ 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')

View File

@ -18,6 +18,9 @@ export class ViewRepository {
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('isArchived', '=', false) .where('isArchived', '=', false)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('fileModifiedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null)
.where('localDateTime', 'is not', null)
.execute(); .execute();
return results.map((row) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); return results.map((row) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
@ -35,6 +38,9 @@ export class ViewRepository {
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('isArchived', '=', false) .where('isArchived', '=', false)
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('fileModifiedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null)
.where('localDateTime', 'is not', null)
.where('originalPath', 'like', `%${normalizedPath}/%`) .where('originalPath', 'like', `%${normalizedPath}/%`)
.where('originalPath', 'not like', `%${normalizedPath}/%/%`) .where('originalPath', 'not like', `%${normalizedPath}/%/%`)
.orderBy( .orderBy(

View File

@ -503,7 +503,7 @@ export class LibraryService extends BaseService {
} }
const mtime = stat.mtime; const mtime = stat.mtime;
const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); const isAssetModified = !asset.fileModifiedAt || mtime.toISOString() !== asset.fileModifiedAt.toISOString();
if (asset.isOffline || isAssetModified) { if (asset.isOffline || isAssetModified) {
this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`);

View File

@ -171,6 +171,14 @@ export class MetadataService extends BaseService {
this.logger.verbose('Exif Tags', exifTags); this.logger.verbose('Exif Tags', exifTags);
if (!asset.fileCreatedAt) {
asset.fileCreatedAt = stats.mtime;
}
if (!asset.fileModifiedAt) {
asset.fileModifiedAt = stats.mtime;
}
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);