Compare commits

..

4 Commits

Author SHA1 Message Date
mertalev b803a35bdc preserve file extension 2025-04-01 16:37:06 -04:00
mertalev 0da1c3b279 add empty array fallback just in case for now 2025-04-01 15:58:32 -04:00
mertalev 33c9ea1c9c update tests 2025-04-01 15:51:08 -04:00
mertalev e7503ce3dc fix file path logic 2025-04-01 15:51:08 -04:00
25 changed files with 187 additions and 139 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.60",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.60",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.131.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.60",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
-4
View File
@@ -1,8 +1,4 @@
[ [
{
"label": "v1.131.3",
"url": "https://v1.131.3.archive.immich.app"
},
{ {
"label": "v1.131.2", "label": "v1.131.2",
"url": "https://v1.131.2.archive.immich.app" "url": "https://v1.131.2.archive.immich.app"
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.131.3", "version": "1.131.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.131.3", "version": "1.131.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -44,7 +44,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.61", "version": "2.2.60",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -93,7 +93,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.131.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.131.3", "version": "1.131.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 193, "android.injected.version.code" => 192,
"android.injected.version.name" => "1.131.3", "android.injected.version.name" => "1.131.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.131.3" version_number: "1.131.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.131.3 - API version: 1.131.2
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.131.3+193 version: 1.131.2+192
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
+1 -1
View File
@@ -7656,7 +7656,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.131.3", "version": "1.131.2",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.131.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.131.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.131.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.131.3 * 1.131.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.131.3", "version": "1.131.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.131.3", "version": "1.131.2",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.131.3", "version": "1.131.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+11 -8
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import path, { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants'; import { APP_MEDIA_LOCATION } from 'src/constants';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
@@ -115,18 +115,21 @@ export class StorageCore {
return normalizedPath.startsWith(normalizedAppMediaLocation); return normalizedPath.startsWith(normalizedAppMediaLocation);
} }
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType) {
const { id: entityId, files } = asset; const oldFile = getAssetFile(asset.files, pathType);
const oldFile = getAssetFile(files, pathType); if (!oldFile?.path) {
return;
}
return this.moveFile({ return this.moveFile({
entityId, entityId: asset.id,
pathType, pathType,
oldPath: oldFile?.path || null, oldPath: oldFile.path,
newPath: StorageCore.getImagePath(asset, pathType, format), newPath: StorageCore.getImagePath(asset, pathType, path.extname(oldFile.path).slice(1) as ImageFormat),
}); });
} }
async moveAssetVideo(asset: AssetEntity) { moveAssetVideo(asset: AssetEntity) {
return this.moveFile({ return this.moveFile({
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ENCODED_VIDEO, pathType: AssetPathType.ENCODED_VIDEO,
+41 -1
View File
@@ -32,7 +32,47 @@ where
"asset_stack"."ownerId" = $1 "asset_stack"."ownerId" = $1
-- StackRepository.delete -- StackRepository.delete
delete from "asset_stack" select
*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"assets".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"tags".*
from
"tags"
inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId"
where
"tag_asset"."assetsId" = "assets"."id"
) as agg
) as "tags",
to_json("exifInfo") as "exifInfo"
from
"assets"
inner join lateral (
select
"exif".*
from
"exif"
where
"exif"."assetId" = "assets"."id"
) as "exifInfo" on true
where
"assets"."deletedAt" is null
and "assets"."stackId" = "asset_stack"."id"
) as agg
) as "assets"
from
"asset_stack"
where where
"id" = $1::uuid "id" = $1::uuid
+28 -1
View File
@@ -122,11 +122,38 @@ export class StackRepository {
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
const stack = await this.getById(id);
if (!stack) {
return;
}
const assetIds = stack.assets.map(({ id }) => id);
await this.db.deleteFrom('asset_stack').where('id', '=', asUuid(id)).execute(); await this.db.deleteFrom('asset_stack').where('id', '=', asUuid(id)).execute();
await this.db
.updateTable('assets')
.set({ stackId: null, updatedAt: new Date() })
.where('id', 'in', assetIds)
.execute();
} }
async deleteAll(ids: string[]): Promise<void> { async deleteAll(ids: string[]): Promise<void> {
await this.db.deleteFrom('asset_stack').where('id', 'in', ids).execute(); const assetIds = [];
for (const id of ids) {
const stack = await this.getById(id);
if (!stack) {
continue;
}
assetIds.push(...stack.assets.map(({ id }) => id));
}
await this.db
.updateTable('assets')
.set({ updatedAt: new Date(), stackId: null })
.where('id', 'in', assetIds)
.where('stackId', 'in', ids)
.execute();
} }
update(id: string, entity: Updateable<StackEntity>): Promise<StackEntity> { update(id: string, entity: Updateable<StackEntity>): Promise<StackEntity> {
+3 -3
View File
@@ -238,19 +238,19 @@ describe(MediaService.name, () => {
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.FULLSIZE, pathType: AssetPathType.FULLSIZE,
oldPath: '/uploads/user-id/fullsize/path.webp', oldPath: '/uploads/user-id/fullsize/path.webp',
newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp',
}); });
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.PREVIEW, pathType: AssetPathType.PREVIEW,
oldPath: '/uploads/user-id/thumbs/path.jpg', oldPath: '/uploads/user-id/thumbs/path.jpg',
newPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', newPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpg',
}); });
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.THUMBNAIL, pathType: AssetPathType.THUMBNAIL,
oldPath: '/uploads/user-id/webp/path.ext', oldPath: '/uploads/user-id/webp/path.ext',
newPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', newPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.ext',
}); });
expect(mocks.move.create).toHaveBeenCalledTimes(3); expect(mocks.move.create).toHaveBeenCalledTimes(3);
}); });
+3 -4
View File
@@ -134,15 +134,14 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION }) @OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION })
async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> { async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> {
const { image } = await this.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id], { files: true }); const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, image.fullsize.format); await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE);
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW);
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL);
await this.storageCore.moveAssetVideo(asset); await this.storageCore.moveAssetVideo(asset);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
+25 -57
View File
@@ -1,6 +1,5 @@
import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
@@ -22,8 +21,14 @@ describe(MetadataService.name, () => {
let mocks: ServiceMocks; let mocks: ServiceMocks;
const mockReadTags = (exifData?: Partial<ImmichTags>, sidecarData?: Partial<ImmichTags>) => { const mockReadTags = (exifData?: Partial<ImmichTags>, sidecarData?: Partial<ImmichTags>) => {
exifData = {
FileSize: '123456',
FileCreateDate: '2024-01-01T00:00:00.000Z',
FileModifyDate: '2024-01-01T00:00:00.000Z',
...exifData,
};
mocks.metadata.readTags.mockReset(); mocks.metadata.readTags.mockReset();
mocks.metadata.readTags.mockResolvedValueOnce(exifData ?? {}); mocks.metadata.readTags.mockResolvedValueOnce(exifData);
mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {}); mocks.metadata.readTags.mockResolvedValueOnce(sidecarData ?? {});
}; };
@@ -109,17 +114,6 @@ describe(MetadataService.name, () => {
}); });
describe('handleMetadataExtraction', () => { describe('handleMetadataExtraction', () => {
beforeEach(() => {
const time = new Date('2022-01-01T00:00:00.000Z');
const timeMs = time.valueOf();
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: time,
mtimeMs: timeMs,
birthtimeMs: timeMs,
} as Stats);
});
it('should handle an asset that could not be found', async () => { it('should handle an asset that could not be found', async () => {
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
@@ -151,13 +145,10 @@ describe(MetadataService.name, () => {
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mocks.storage.stat.mockResolvedValue({ mockReadTags({
size: 123_456, FileCreateDate: fileCreatedAt.toISOString(),
mtime: fileModifiedAt, FileModifyDate: fileModifiedAt.toISOString(),
mtimeMs: fileModifiedAt.valueOf(), });
birthtimeMs: fileCreatedAt.valueOf(),
} as Stats);
mockReadTags();
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
@@ -177,13 +168,10 @@ describe(MetadataService.name, () => {
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mocks.storage.stat.mockResolvedValue({ mockReadTags({
size: 123_456, FileCreateDate: fileCreatedAt.toISOString(),
mtime: fileModifiedAt, FileModifyDate: fileModifiedAt.toISOString(),
mtimeMs: fileModifiedAt.valueOf(), });
birthtimeMs: fileCreatedAt.valueOf(),
} as Stats);
mockReadTags();
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(mocks.asset.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } });
@@ -218,14 +206,10 @@ describe(MetadataService.name, () => {
it('should handle lists of numbers', async () => { it('should handle lists of numbers', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]); mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.image.fileModifiedAt,
mtimeMs: assetStub.image.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.image.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({ mockReadTags({
ISO: [160], ISO: [160],
FileCreateDate: assetStub.image.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.image.fileModifiedAt.toISOString(),
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -244,15 +228,11 @@ describe(MetadataService.name, () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]); mocks.asset.getByIds.mockResolvedValue([assetStub.withLocation]);
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.withLocation.fileModifiedAt,
mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({ mockReadTags({
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
FileCreateDate: assetStub.withLocation.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.withLocation.fileModifiedAt.toISOString(),
}); });
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -495,12 +475,6 @@ describe(MetadataService.name, () => {
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@@ -509,6 +483,8 @@ describe(MetadataService.name, () => {
// instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both // instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both
EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data', EmbeddedVideoType: 'MotionPhoto_Data',
FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(),
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
@@ -549,18 +525,14 @@ describe(MetadataService.name, () => {
}); });
it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => {
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(),
} as Stats);
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data', EmbeddedVideoType: 'MotionPhoto_Data',
MotionPhoto: 1, MotionPhoto: 1,
FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(),
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
@@ -602,17 +574,13 @@ describe(MetadataService.name, () => {
it('should extract the motion photo video from the XMP directory entry ', async () => { it('should extract the motion photo video from the XMP directory entry ', async () => {
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]); mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(),
} as Stats);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
MicroVideo: 1, MicroVideo: 1,
MicroVideoOffset: 1, MicroVideoOffset: 1,
FileCreateDate: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.toISOString(),
FileModifyDate: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.toISOString(),
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
+39 -29
View File
@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, Maybe, Tags } from 'exiftool-vendored'; import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { Insertable } from 'kysely'; import { Insertable } from 'kysely';
import _ from 'lodash'; import _ from 'lodash';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises'; import { constants } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
@@ -78,11 +77,6 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> }; type ImmichTagsWithFaces = ImmichTags & { RegionInfo: NonNullable<ImmichTags['RegionInfo']> };
type Dates = {
dateTimeOriginal: Date;
localDateTime: Date;
};
@Injectable() @Injectable()
export class MetadataService extends BaseService { export class MetadataService extends BaseService {
@OnEvent({ name: 'app.bootstrap', workers: [ImmichWorker.MICROSERVICES] }) @OnEvent({ name: 'app.bootstrap', workers: [ImmichWorker.MICROSERVICES] })
@@ -177,13 +171,18 @@ export class MetadataService extends BaseService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const [exifTags, stats] = await Promise.all([ const exifTags = await this.getExifTags(asset);
this.getExifTags(asset), if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) {
this.storageRepository.stat(asset.originalPath), this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`);
]); const stat = await this.storageRepository.stat(asset.originalPath);
exifTags.FileCreateDate = stat.ctime.toISOString();
exifTags.FileModifyDate = stat.mtime.toISOString();
exifTags.FileSize = stat.size.toString();
}
this.logger.verbose('Exif Tags', exifTags); this.logger.verbose('Exif Tags', exifTags);
const dates = this.getDates(asset, exifTags, stats); const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { width, height } = this.getImageDimensions(exifTags); const { width, height } = this.getImageDimensions(exifTags);
let geo: ReverseGeocodeResult, latitude: number | null, longitude: number | null; let geo: ReverseGeocodeResult, latitude: number | null, longitude: number | null;
@@ -201,9 +200,9 @@ export class MetadataService extends BaseService {
assetId: asset.id, assetId: asset.id,
// dates // dates
dateTimeOriginal: dates.dateTimeOriginal, dateTimeOriginal,
modifyDate: stats.mtime, modifyDate,
timeZone: dates.timeZone, timeZone,
// gps // gps
latitude, latitude,
@@ -213,7 +212,7 @@ export class MetadataService extends BaseService {
city: geo.city, city: geo.city,
// image/file // image/file
fileSizeInByte: stats.size, fileSizeInByte: Number.parseInt(exifTags.FileSize!),
exifImageHeight: validate(height), exifImageHeight: validate(height),
exifImageWidth: validate(width), exifImageWidth: validate(width),
orientation: validate(exifTags.Orientation)?.toString() ?? null, orientation: validate(exifTags.Orientation)?.toString() ?? null,
@@ -246,15 +245,15 @@ export class MetadataService extends BaseService {
this.assetRepository.update({ this.assetRepository.update({
id: asset.id, id: asset.id,
duration: exifTags.Duration?.toString() ?? null, duration: exifTags.Duration?.toString() ?? null,
localDateTime: dates.localDateTime, localDateTime,
fileCreatedAt: dates.dateTimeOriginal ?? undefined, fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
fileModifiedAt: stats.mtime, fileModifiedAt: exifData.modifyDate ?? undefined,
}), }),
this.applyTagList(asset, exifTags), this.applyTagList(asset, exifTags),
]; ];
if (this.isMotionPhoto(asset, exifTags)) { if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats)); promises.push(this.applyMotionPhotos(asset, exifTags, exifData.fileSizeInByte!));
} }
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) { if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
@@ -433,7 +432,7 @@ export class MetadataService extends BaseService {
return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo); return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo);
} }
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, dates: Dates, stats: Stats) { private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, fileSize: number) {
const isMotionPhoto = tags.MotionPhoto; const isMotionPhoto = tags.MotionPhoto;
const isMicroVideo = tags.MicroVideo; const isMicroVideo = tags.MicroVideo;
const videoOffset = tags.MicroVideoOffset; const videoOffset = tags.MicroVideoOffset;
@@ -467,7 +466,7 @@ export class MetadataService extends BaseService {
this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`Starting motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`);
try { try {
const position = stats.size - length - padding; const position = fileSize - length - padding;
let video: Buffer; let video: Buffer;
// Samsung MotionPhoto video extraction // Samsung MotionPhoto video extraction
// HEIC-encoded // HEIC-encoded
@@ -506,12 +505,13 @@ export class MetadataService extends BaseService {
} }
} else { } else {
const motionAssetId = this.cryptoRepository.randomUUID(); const motionAssetId = this.cryptoRepository.randomUUID();
const dates = this.getDates(asset, tags);
motionAsset = await this.assetRepository.create({ motionAsset = await this.assetRepository.create({
id: motionAssetId, id: motionAssetId,
libraryId: asset.libraryId, libraryId: asset.libraryId,
type: AssetType.VIDEO, type: AssetType.VIDEO,
fileCreatedAt: dates.dateTimeOriginal, fileCreatedAt: dates.dateTimeOriginal,
fileModifiedAt: stats.mtime, fileModifiedAt: dates.modifyDate,
localDateTime: dates.localDateTime, localDateTime: dates.localDateTime,
checksum, checksum,
ownerId: asset.ownerId, ownerId: asset.ownerId,
@@ -634,7 +634,7 @@ export class MetadataService extends BaseService {
} }
} }
private getDates(asset: AssetEntity, exifTags: ImmichTags, stats: Stats) { private getDates(asset: AssetEntity, exifTags: ImmichTags) {
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS); const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`); this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`);
@@ -654,16 +654,17 @@ export class MetadataService extends BaseService {
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);
} }
const modifyDate = this.toDate(exifTags.FileModifyDate!);
let dateTimeOriginal = dateTime?.toDate(); let dateTimeOriginal = dateTime?.toDate();
let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate();
if (!localDateTime || !dateTimeOriginal) { if (!localDateTime || !dateTimeOriginal) {
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet const fileCreatedAt = this.toDate(exifTags.FileCreateDate!);
// birthtime is not available in Docker on macOS, so it appears as 0 const earliestDate = this.earliestDate(fileCreatedAt, modifyDate);
const earliestDate = stats.birthtimeMs ? new Date(Math.min(stats.mtimeMs, stats.birthtimeMs)) : stats.mtime;
this.logger.debug( this.logger.debug(
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for assset ${asset.id}: ${asset.originalPath}`,
); );
dateTimeOriginal = localDateTime = earliestDate; dateTimeOriginal = earliestDate;
localDateTime = earliestDate;
} }
this.logger.verbose( this.logger.verbose(
@@ -674,9 +675,18 @@ export class MetadataService extends BaseService {
dateTimeOriginal, dateTimeOriginal,
timeZone, timeZone,
localDateTime, localDateTime,
modifyDate,
}; };
} }
private toDate(date: string | ExifDateTime): Date {
return typeof date === 'string' ? new Date(date) : date.toDate();
}
private earliestDate(a: Date, b: Date) {
return new Date(Math.min(a.valueOf(), b.valueOf()));
}
private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } { private hasGeo(tags: ImmichTags): tags is ImmichTags & { GPSLatitude: number; GPSLongitude: number } {
return ( return (
tags.GPSLatitude !== undefined && tags.GPSLatitude !== undefined &&
@@ -39,12 +39,7 @@ describe(MetadataService.name, () => {
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository })); ({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository }));
mocks.storage.stat.mockResolvedValue({ mocks.storage.stat.mockResolvedValue({ size: 123_456, ctime: new Date(), mtime: new Date() } as Stats);
size: 123_456,
mtime: new Date(654_321),
mtimeMs: 654_321,
birthtimeMs: 654_322,
} as Stats);
delete process.env.TZ; delete process.env.TZ;
}); });
@@ -59,6 +54,8 @@ describe(MetadataService.name, () => {
description: 'should handle no time zone information', description: 'should handle no time zone information',
exifData: { exifData: {
DateTimeOriginal: '2022:01:01 00:00:00', DateTimeOriginal: '2022:01:01 00:00:00',
FileCreateDate: '2022:01:01 00:00:00',
FileModifyDate: '2022:01:01 00:00:00',
}, },
expected: { expected: {
localDateTime: '2022-01-01T00:00:00.000Z', localDateTime: '2022-01-01T00:00:00.000Z',
@@ -71,6 +68,8 @@ describe(MetadataService.name, () => {
serverTimeZone: 'America/Los_Angeles', serverTimeZone: 'America/Los_Angeles',
exifData: { exifData: {
DateTimeOriginal: '2022:01:01 00:00:00', DateTimeOriginal: '2022:01:01 00:00:00',
FileCreateDate: '2022:01:01 00:00:00',
FileModifyDate: '2022:01:01 00:00:00',
}, },
expected: { expected: {
localDateTime: '2022-01-01T00:00:00.000Z', localDateTime: '2022-01-01T00:00:00.000Z',
@@ -83,6 +82,8 @@ describe(MetadataService.name, () => {
serverTimeZone: 'Europe/Brussels', serverTimeZone: 'Europe/Brussels',
exifData: { exifData: {
DateTimeOriginal: '2022:01:01 00:00:00', DateTimeOriginal: '2022:01:01 00:00:00',
FileCreateDate: '2022:01:01 00:00:00',
FileModifyDate: '2022:01:01 00:00:00',
}, },
expected: { expected: {
localDateTime: '2022-01-01T00:00:00.000Z', localDateTime: '2022-01-01T00:00:00.000Z',
@@ -95,6 +96,8 @@ describe(MetadataService.name, () => {
serverTimeZone: 'Europe/Brussels', serverTimeZone: 'Europe/Brussels',
exifData: { exifData: {
DateTimeOriginal: '2022:06:01 00:00:00', DateTimeOriginal: '2022:06:01 00:00:00',
FileCreateDate: '2022:06:01 00:00:00',
FileModifyDate: '2022:06:01 00:00:00',
}, },
expected: { expected: {
localDateTime: '2022-06-01T00:00:00.000Z', localDateTime: '2022-06-01T00:00:00.000Z',
@@ -106,6 +109,8 @@ describe(MetadataService.name, () => {
description: 'should handle a +13:00 time zone', description: 'should handle a +13:00 time zone',
exifData: { exifData: {
DateTimeOriginal: '2022:01:01 00:00:00+13:00', DateTimeOriginal: '2022:01:01 00:00:00+13:00',
FileCreateDate: '2022:01:01 00:00:00+13:00',
FileModifyDate: '2022:01:01 00:00:00+13:00',
}, },
expected: { expected: {
localDateTime: '2022-01-01T00:00:00.000Z', localDateTime: '2022-01-01T00:00:00.000Z',
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.131.3", "version": "1.131.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-web", "name": "immich-web",
"version": "1.131.3", "version": "1.131.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8", "@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -81,7 +81,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.131.3", "version": "1.131.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.131.3", "version": "1.131.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {