Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros
82cab7b81d fix(mobile): closing editor goes back to main page 2025-10-04 19:36:05 +02:00
23 changed files with 121 additions and 326 deletions

View File

@@ -1006,7 +1006,7 @@ describe('/libraries', () => {
rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
});
it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => {
it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
const library = await utils.createLibrary(admin.accessToken, {
ownerId: admin.userId,
importPaths: [`${testAssetDirInternal}/temp/xmp`],

View File

@@ -91,7 +91,7 @@ class DriftEditImagePage extends ConsumerWidget {
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
onPressed: () => context.navigator.pop(),
),
actions: <Widget>[
TextButton(

View File

@@ -305,7 +305,7 @@ export class StorageCore {
return this.assetRepository.update({ id, encodedVideoPath: newPath });
}
case AssetPathType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
return this.assetRepository.update({ id, sidecarPath: newPath });
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });

View File

@@ -117,6 +117,7 @@ export type Asset = {
originalFileName: string;
originalPath: string;
ownerId: string;
sidecarPath: string | null;
type: AssetType;
};
@@ -301,6 +302,7 @@ export const columns = {
'asset.originalFileName',
'asset.originalPath',
'asset.ownerId',
'asset.sidecarPath',
'asset.type',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],

View File

@@ -124,6 +124,7 @@ export type MapAsset = {
originalPath: string;
owner?: User | null;
ownerId: string;
sidecarPath: string | null;
stack?: Stack | null;
stackId: string | null;
tags?: Tag[];

View File

@@ -43,7 +43,6 @@ export enum AssetFileType {
FullSize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
}
export enum AlbumUserRole {

View File

@@ -20,23 +20,8 @@ limit
-- AssetJobRepository.getForSidecarWriteJob
select
"id",
"sidecarPath",
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files",
(
select
coalesce(json_agg(agg), '[]')
@@ -54,36 +39,21 @@ select
from
"asset"
where
"asset"."id" = $2::uuid
"asset"."id" = $1::uuid
limit
$3
$2
-- AssetJobRepository.getForSidecarCheckJob
select
"id",
"originalPath",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
"sidecarPath",
"originalPath"
from
"asset"
where
"asset"."id" = $2::uuid
"asset"."id" = $1::uuid
limit
$3
$2
-- AssetJobRepository.streamForThumbnailJob
select
@@ -188,6 +158,7 @@ select
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."sidecarPath",
"asset"."type",
(
select
@@ -202,27 +173,11 @@ select
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
) as agg
) as "faces",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as agg
) as "files"
) as "faces"
from
"asset"
where
"asset"."id" = $2
"asset"."id" = $1
-- AssetJobRepository.getAlbumThumbnailFiles
select
@@ -350,6 +305,7 @@ select
"asset"."libraryId",
"asset"."ownerId",
"asset"."livePhotoVideoId",
"asset"."sidecarPath",
"asset"."encodedVideoPath",
"asset"."originalPath",
to_json("asset_exif") as "exifInfo",
@@ -460,28 +416,18 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."sidecarPath",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
"asset_exif"."timeZone",
"asset_exif"."fileSizeInByte",
(
select
"asset_file"."path"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
limit
$2
) as "sidecarPath"
"asset_exif"."fileSizeInByte"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."deletedAt" is null
and "asset"."id" = $3
and "asset"."id" = $1
-- AssetJobRepository.streamForStorageTemplateJob
select
@@ -491,22 +437,12 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."sidecarPath",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
"asset_exif"."timeZone",
"asset_exif"."fileSizeInByte",
(
select
"asset_file"."path"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
limit
$2
) as "sidecarPath"
"asset_exif"."fileSizeInByte"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
@@ -528,15 +464,11 @@ select
from
"asset"
where
not exists (
select
"asset_file"."id"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
(
"asset"."sidecarPath" = $1
or "asset"."sidecarPath" is null
)
and "asset"."visibility" != $2
-- AssetJobRepository.streamForDetectFacesJob
select

View File

@@ -39,8 +39,7 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.select(['id', 'sidecarPath', 'originalPath'])
.select((eb) =>
jsonArrayFrom(
eb
@@ -59,8 +58,7 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.where('asset.id', '=', asUuid(id))
.select(['id', 'originalPath'])
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.select(['id', 'sidecarPath', 'originalPath'])
.limit(1)
.executeTakeFirst();
}
@@ -123,7 +121,6 @@ export class AssetJobRepository {
.selectFrom('asset')
.select(columns.asset)
.select(withFaces)
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
.where('asset.id', '=', id)
.executeTakeFirst();
}
@@ -221,6 +218,7 @@ export class AssetJobRepository {
'asset.libraryId',
'asset.ownerId',
'asset.livePhotoVideoId',
'asset.sidecarPath',
'asset.encodedVideoPath',
'asset.originalPath',
])
@@ -298,19 +296,12 @@ export class AssetJobRepository {
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.sidecarPath',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
'asset_exif.timeZone',
'asset_exif.fileSizeInByte',
(eb) =>
eb
.selectFrom('asset_file')
.select('asset_file.path')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Sidecar)
.limit(1)
.as('sidecarPath'),
])
.where('asset.deletedAt', 'is', null);
}
@@ -342,18 +333,9 @@ export class AssetJobRepository {
.selectFrom('asset')
.select(['asset.id'])
.$if(!force, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_file')
.select('asset_file.id')
.whereRef('asset_file.assetId', '=', 'asset.id')
.where('asset_file.type', '=', AssetFileType.Sidecar),
),
),
),
qb.where((eb) => eb.or([eb('asset.sidecarPath', '=', ''), eb('asset.sidecarPath', 'is', null)])),
)
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.stream();
}

View File

@@ -840,14 +840,6 @@ export class AssetRepository {
.execute();
}
async deleteFile(file: Pick<Selectable<AssetFileTable>, 'assetId' | 'type'>): Promise<void> {
await this.db
.deleteFrom('asset_file')
.where('assetId', '=', asUuid(file.assetId))
.where('type', '=', file.type)
.execute();
}
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
if (files.length === 0) {
return;

View File

@@ -414,6 +414,7 @@ export class DatabaseRepository {
.set((eb) => ({
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]),
}))
.execute();

View File

@@ -1,24 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`INSERT INTO asset_file ("assetId", path, type)
SELECT
id, "sidecarPath", 'sidecar'
FROM asset
WHERE "sidecarPath" IS NOT NULL;`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "sidecarPath";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "sidecarPath" character varying;`.execute(db);
await sql`
UPDATE asset
SET "sidecarPath" = asset_file.path
FROM asset_file
WHERE asset.id = asset_file."assetId";
`.execute(db);
await sql`DELETE FROM asset_file WHERE type = 'sidecar';`.execute(db);
}

View File

@@ -105,6 +105,9 @@ export class AssetTable {
@Column({ index: true })
originalFileName!: string;
@Column({ nullable: true })
sidecarPath!: string | null;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;

View File

@@ -173,6 +173,7 @@ const assetEntity = Object.freeze({
longitude: 10.703_075,
},
livePhotoVideoId: null,
sidecarPath: null,
} as MapAsset);
const existingAsset = Object.freeze({
@@ -710,22 +711,18 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: existingAsset.id,
sidecarPath: null,
originalFileName: 'photo1.jpeg',
originalPath: 'fake_path/photo1.jpeg',
}),
);
expect(mocks.asset.create).toHaveBeenCalledWith(
expect.objectContaining({
sidecarPath: null,
originalFileName: 'existing-filename.jpeg',
originalPath: 'fake_path/asset_1.jpeg',
}),
);
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
@@ -762,13 +759,6 @@ describe(AssetMediaService.name, () => {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -798,12 +788,6 @@ describe(AssetMediaService.name, () => {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -833,9 +817,6 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [updatedFile.originalPath, undefined] },

View File

@@ -21,16 +21,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFileType,
AssetStatus,
AssetType,
AssetVisibility,
CacheControl,
JobName,
Permission,
StorageFolder,
} from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
@@ -363,12 +354,9 @@ export class AssetMediaService extends BaseService {
duration: dto.duration || null,
livePhotoVideoId: null,
sidecarPath: sidecarPath || null,
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, path: sidecarPath, type: AssetFileType.Sidecar })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
await this.jobRepository.queue({
@@ -396,6 +384,7 @@ export class AssetMediaService extends BaseService {
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
livePhotoVideoId: asset.livePhotoVideoId,
sidecarPath: asset.sidecarPath,
});
const { size } = await this.storageRepository.stat(created.originalPath);
@@ -425,6 +414,7 @@ export class AssetMediaService extends BaseService {
visibility: dto.visibility ?? AssetVisibility.Timeline,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: dto.filename || file.originalName,
sidecarPath: sidecarFile?.originalPath,
});
if (dto.metadata) {
@@ -432,11 +422,6 @@ export class AssetMediaService extends BaseService {
}
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));

View File

@@ -585,8 +585,8 @@ describe(AssetService.name, () => {
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
assetWithFace.encodedVideoPath, // this value is null
undefined, // no sidecar path
assetWithFace.encodedVideoPath,
assetWithFace.sidecarPath,
assetWithFace.originalPath,
],
},

View File

@@ -258,11 +258,11 @@ export class AssetService extends BaseService {
}
}
const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []);
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []);
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) {
files.push(sidecarFile?.path, asset.originalPath);
files.push(asset.sidecarPath, asset.originalPath);
}
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } });

View File

@@ -3,16 +3,7 @@ import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto';
import {
AssetFileType,
AssetType,
AssetVisibility,
ExifOrientation,
ImmichWorker,
JobName,
JobStatus,
SourceType,
} from 'src/enum';
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -23,24 +14,17 @@ import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
function removeNonSidecarFiles(asset: any) {
return {
...asset,
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
};
}
const forSidecarJob = (
asset: {
id?: string;
originalPath?: string;
files?: { id: string; type: AssetFileType; path: string }[];
sidecarPath?: string | null;
} = {},
) => {
return {
id: factory.uuid(),
originalPath: '/path/to/IMG_123.jpg',
files: [],
sidecarPath: null,
...asset,
};
};
@@ -181,7 +165,7 @@ describe(MetadataService.name, () => {
it('should handle a date in a sidecar file', async () => {
const originalDate = new Date('2023-11-21T16:13:17.517Z');
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -200,7 +184,7 @@ describe(MetadataService.name, () => {
it('should take the file modification date when missing exif and earlier than creation date', async () => {
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@@ -226,7 +210,7 @@ describe(MetadataService.name, () => {
it('should take the file creation date when missing exif and earlier than modification date', async () => {
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@@ -249,7 +233,7 @@ describe(MetadataService.name, () => {
it('should account for the server being in a non-UTC timezone', async () => {
process.env.TZ = 'America/Los_Angeles';
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar);
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -267,7 +251,7 @@ describe(MetadataService.name, () => {
});
it('should handle lists of numbers', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.image.fileModifiedAt,
@@ -320,7 +304,7 @@ describe(MetadataService.name, () => {
});
it('should apply reverse geocoding', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
mocks.storage.stat.mockResolvedValue({
@@ -349,7 +333,7 @@ describe(MetadataService.name, () => {
});
it('should discard latitude and longitude on null island', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation);
mockReadTags({
GPSLatitude: 0,
GPSLongitude: 0,
@@ -361,7 +345,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -371,7 +355,7 @@ describe(MetadataService.name, () => {
});
it('should extract hierarchy from TagsList', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -391,7 +375,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from Keywords as a string', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -401,7 +385,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from Keywords as a list', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -411,7 +395,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from Keywords as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -422,7 +406,7 @@ describe(MetadataService.name, () => {
});
it('should extract hierarchal tags from Keywords', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -441,7 +425,7 @@ describe(MetadataService.name, () => {
});
it('should ignore Keywords when TagsList is present', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -460,7 +444,7 @@ describe(MetadataService.name, () => {
});
it('should extract hierarchy from HierarchicalSubject', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert);
@@ -481,7 +465,7 @@ describe(MetadataService.name, () => {
});
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image));
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@@ -1518,25 +1502,18 @@ describe(MetadataService.name, () => {
});
it('should detect a new sidecar at .jpg.xmp', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', files: [] });
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: asset.id,
type: AssetFileType.Sidecar,
path: '/path/to/IMG_123.jpg.xmp',
});
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
});
it('should detect a new sidecar at .xmp', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [],
});
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
@@ -1544,44 +1521,33 @@ describe(MetadataService.name, () => {
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: asset.id,
type: AssetFileType.Sidecar,
path: '/path/to/IMG_123.xmp',
});
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
});
it('should unset sidecar path if file no longer exist', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
});
it('should unset sidecar path if file does not exist anymore', async () => {
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValue(false);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({ assetId: asset.id, type: AssetFileType.Sidecar });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
});
it('should do nothing if the sidecar file still exists', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
});
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled();
});
});
describe('handleSidecarWrite', () => {
it('should skip assets that no longer exist', async () => {
it('should skip assets that do not exist anymore', async () => {
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();

View File

@@ -8,10 +8,9 @@ import { constants } from 'node:fs/promises';
import { join, parse } from 'node:path';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Asset, AssetFace, AssetFile } from 'src/database';
import { Asset, AssetFace } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import {
AssetFileType,
AssetType,
AssetVisibility,
DatabaseLock,
@@ -361,21 +360,17 @@ export class MetadataService extends BaseService {
break;
}
const existingSidecar = asset.files ? asset.files.find((file) => file.type === AssetFileType.Sidecar) : null;
const isChanged = sidecarPath !== existingSidecar?.path;
const isChanged = sidecarPath !== asset.sidecarPath;
this.logger.debug(
`Sidecar check found old=${existingSidecar?.path}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
`Sidecar check found old=${asset.sidecarPath}, new=${sidecarPath} will ${isChanged ? 'update' : 'do nothing for'} asset ${asset.id}: ${asset.originalPath}`,
);
if (!isChanged) {
return JobStatus.Skipped;
}
await (sidecarPath === null
? this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar })
: this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }));
await this.assetRepository.update({ id: asset.id, sidecarPath });
return JobStatus.Success;
}
@@ -400,7 +395,7 @@ export class MetadataService extends BaseService {
const tagsList = (asset.tags || []).map((tag) => tag.value);
const sidecarPath = asset.files[0]?.path || `${asset.originalPath}.xmp`;
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
const exif = _.omitBy(
<Tags>{
Description: description,
@@ -420,20 +415,18 @@ export class MetadataService extends BaseService {
await this.metadataRepository.writeTags(sidecarPath, exif);
if (asset.files.length === 0) {
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath });
if (!asset.sidecarPath) {
await this.assetRepository.update({ id, sidecarPath });
}
return JobStatus.Success;
}
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[] | null; originalPath: string }) {
private getSidecarCandidates({ sidecarPath, originalPath }: { sidecarPath: string | null; originalPath: string }) {
const candidates: string[] = [];
const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar);
if (existingSidecar) {
candidates.push(existingSidecar.path);
if (sidecarPath) {
candidates.push(sidecarPath);
}
const assetPath = parse(originalPath);
@@ -461,17 +454,13 @@ export class MetadataService extends BaseService {
return { width, height };
}
private getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise<ImmichTags> {
if (asset.type === AssetType.Image) {
let hasSidecar = false;
if (asset.files && asset.files.length > 0) {
hasSidecar = asset.files.some((file) => file.type === AssetFileType.Sidecar);
}
if (!hasSidecar) {
return this.metadataRepository.readTags(asset.originalPath);
}
private getExifTags(asset: {
originalPath: string;
sidecarPath: string | null;
type: AssetType;
}): Promise<ImmichTags> {
if (!asset.sidecarPath && asset.type === AssetType.Image) {
return this.metadataRepository.readTags(asset.originalPath);
}
return this.mergeExifTags(asset);
@@ -479,16 +468,12 @@ export class MetadataService extends BaseService {
private async mergeExifTags(asset: {
originalPath: string;
files: AssetFile[];
sidecarPath: string | null;
type: AssetType;
}): Promise<ImmichTags> {
if (asset.files && asset.files.length > 1) {
throw new Error(`Asset ${asset.originalPath} has multiple sidecar files`);
}
const [mediaTags, sidecarTags, videoTags] = await Promise.all([
this.metadataRepository.readTags(asset.originalPath),
asset.files && asset.files.length > 0 ? this.metadataRepository.readTags(asset.files[0].path) : null,
asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null,
asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null,
]);

View File

@@ -450,7 +450,7 @@ export type StorageAsset = {
fileCreatedAt: Date;
originalPath: string;
originalFileName: string;
sidecarPath?: string | null;
sidecarPath: string | null;
fileSizeInByte: number | null;
};

View File

@@ -21,7 +21,6 @@ export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
previewFile: getAssetFile(files, AssetFileType.Preview),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
});
export const addAssets = async (

View File

@@ -24,18 +24,6 @@ const fullsizeFile: AssetFile = {
path: '/uploads/user-id/fullsize/path.webp',
};
const sidecarFileWithExt: AssetFile = {
id: 'sidecar-with-ext',
type: AssetFileType.Sidecar,
path: '/original/path.ext.xmp',
};
const sidecarFileWithoutExt: AssetFile = {
id: 'sidecar-without-ext',
type: AssetFileType.Sidecar,
path: '/original/path.xmp',
};
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
@@ -63,6 +51,7 @@ export const assetStub = {
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/original/path.jpg',
originalFileName: 'IMG_123.jpg',
sidecarPath: null,
fileSizeInByte: 12_345,
...asset,
}),
@@ -92,6 +81,7 @@ export const assetStub = {
sharedLinks: [],
faces: [],
exifInfo: {} as Exif,
sidecarPath: null,
deletedAt: null,
isExternal: false,
duplicateId: null,
@@ -127,6 +117,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'IMG_456.jpg',
faces: [],
sidecarPath: null,
isExternal: false,
exifInfo: {
fileSizeInByte: 123_000,
@@ -166,6 +157,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
deletedAt: null,
duplicateId: null,
isOffline: false,
@@ -202,6 +194,7 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 1000,
@@ -250,6 +243,7 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -291,6 +285,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -333,6 +328,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -371,6 +367,7 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
@@ -412,6 +409,7 @@ export const assetStub = {
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -450,6 +448,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -491,6 +490,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -526,6 +526,7 @@ export const assetStub = {
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 100_000,
exifImageHeight: 2160,
@@ -572,7 +573,7 @@ export const assetStub = {
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
} as MapAsset & { faces: AssetFace[] }),
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',
@@ -591,7 +592,7 @@ export const assetStub = {
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
} as MapAsset & { faces: AssetFace[] }),
withLocation: Object.freeze({
id: 'asset-with-favorite-id',
@@ -604,6 +605,7 @@ export const assetStub = {
deviceId: 'device-id',
checksum: Buffer.from('file hash', 'utf8'),
originalPath: '/original/path.ext',
sidecarPath: null,
type: AssetType.Image,
files: [previewFile],
thumbhash: null,
@@ -650,7 +652,7 @@ export const assetStub = {
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile, sidecarFileWithExt],
files: [previewFile],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@@ -663,6 +665,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
duplicateId: null,
isOffline: false,
@@ -685,7 +688,7 @@ export const assetStub = {
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile, sidecarFileWithoutExt],
files: [previewFile],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@@ -698,6 +701,7 @@ export const assetStub = {
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.xmp',
deletedAt: null,
duplicateId: null,
isOffline: false,
@@ -730,6 +734,7 @@ export const assetStub = {
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 100_000,
} as Exif,
@@ -771,6 +776,7 @@ export const assetStub = {
originalFileName: 'photo.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
@@ -806,6 +812,7 @@ export const assetStub = {
originalFileName: 'asset-id.dng',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
@@ -846,6 +853,7 @@ export const assetStub = {
originalFileName: 'asset-id.hif',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',

View File

@@ -36,7 +36,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getChangedDeltaSync: vitest.fn(),
upsertFile: vitest.fn(),
upsertFiles: vitest.fn(),
deleteFile: vitest.fn(),
deleteFiles: vitest.fn(),
detectOfflineExternalAssets: vitest.fn(),
filterNewExternalAssetPaths: vitest.fn(),

View File

@@ -14,16 +14,7 @@ import {
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFileType,
AssetStatus,
AssetType,
AssetVisibility,
MemoryType,
Permission,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { v4, v7 } from 'uuid';
@@ -314,13 +305,6 @@ const assetSidecarWriteFactory = (asset: Partial<SidecarWriteAsset> = {}) => ({
sidecarPath: '/path/to/original-path.jpg.xmp',
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
files: [
{
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
},
],
...asset,
});