Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00d3b8d83a | ||
|
|
d911b76c08 | ||
|
|
502854cee1 | ||
|
|
59e5c82569 | ||
|
|
e4b0c00885 | ||
|
|
946507231d | ||
|
|
20ba800a50 | ||
|
|
f434e858ed | ||
|
|
3e03c47fbf |
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.60",
|
"version": "2.2.61",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.60",
|
"version": "2.2.61",
|
||||||
"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.2",
|
"version": "1.131.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.60",
|
"version": "2.2.61",
|
||||||
"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
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
|||||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"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.60",
|
"version": "2.2.61",
|
||||||
"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.2",
|
"version": "1.131.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 192,
|
"android.injected.version.code" => 193,
|
||||||
"android.injected.version.name" => "1.131.2",
|
"android.injected.version.name" => "1.131.3",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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')
|
||||||
|
|||||||
@@ -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.2"
|
version_number: "1.131.3"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -71,8 +71,13 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
|
|||||||
Future<List<Asset>> getAllByRemoteId(
|
Future<List<Asset>> getAllByRemoteId(
|
||||||
Iterable<String> ids, {
|
Iterable<String> ids, {
|
||||||
AssetState? state,
|
AssetState? state,
|
||||||
}) =>
|
}) async {
|
||||||
_getAllByRemoteIdImpl(ids, state).findAll();
|
if (ids.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return _getAllByRemoteIdImpl(ids, state).findAll();
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> _getAllByRemoteIdImpl(
|
QueryBuilder<Asset, Asset, QAfterFilterCondition> _getAllByRemoteIdImpl(
|
||||||
Iterable<String> ids,
|
Iterable<String> ids,
|
||||||
|
|||||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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.2
|
- API version: 1.131.3
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
|||||||
@@ -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.2+192
|
version: 1.131.3+193
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|||||||
@@ -7656,7 +7656,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|||||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"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,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.131.2
|
* 1.131.3
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
|
|||||||
import { PersonRepository } from 'src/repositories/person.repository';
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFile } from 'src/utils/asset.util';
|
||||||
import { getConfig } from 'src/utils/config';
|
import { getConfig } from 'src/utils/config';
|
||||||
|
|
||||||
export interface MoveRequest {
|
export interface MoveRequest {
|
||||||
@@ -117,8 +117,7 @@ export class StorageCore {
|
|||||||
|
|
||||||
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) {
|
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) {
|
||||||
const { id: entityId, files } = asset;
|
const { id: entityId, files } = asset;
|
||||||
const { thumbnailFile, previewFile } = getAssetFiles(files);
|
const oldFile = getAssetFile(files, pathType);
|
||||||
const oldFile = pathType === AssetPathType.PREVIEW ? previewFile : thumbnailFile;
|
|
||||||
return this.moveFile({
|
return this.moveFile({
|
||||||
entityId,
|
entityId,
|
||||||
pathType,
|
pathType,
|
||||||
|
|||||||
@@ -32,47 +32,7 @@ where
|
|||||||
"asset_stack"."ownerId" = $1
|
"asset_stack"."ownerId" = $1
|
||||||
|
|
||||||
-- StackRepository.delete
|
-- StackRepository.delete
|
||||||
select
|
delete from "asset_stack"
|
||||||
*,
|
|
||||||
(
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -122,38 +122,11 @@ 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> {
|
||||||
const assetIds = [];
|
await this.db.deleteFrom('asset_stack').where('id', 'in', ids).execute();
|
||||||
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> {
|
||||||
|
|||||||
@@ -584,7 +584,7 @@ describe(AssetMediaService.name, () => {
|
|||||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
path: assetStub.image.files[0].path,
|
path: '/uploads/user-id/thumbs/path.jpg',
|
||||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
fileName: 'asset-id_preview.jpg',
|
fileName: 'asset-id_preview.jpg',
|
||||||
@@ -599,7 +599,7 @@ describe(AssetMediaService.name, () => {
|
|||||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
path: assetStub.image.files[1].path,
|
path: '/uploads/user-id/webp/path.ext',
|
||||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||||
contentType: 'application/octet-stream',
|
contentType: 'application/octet-stream',
|
||||||
fileName: 'asset-id_thumbnail.ext',
|
fileName: 'asset-id_thumbnail.ext',
|
||||||
|
|||||||
@@ -578,6 +578,7 @@ describe(AssetService.name, () => {
|
|||||||
files: [
|
files: [
|
||||||
'/uploads/user-id/webp/path.ext',
|
'/uploads/user-id/webp/path.ext',
|
||||||
'/uploads/user-id/thumbs/path.jpg',
|
'/uploads/user-id/thumbs/path.jpg',
|
||||||
|
'/uploads/user-id/fullsize/path.webp',
|
||||||
assetWithFace.encodedVideoPath,
|
assetWithFace.encodedVideoPath,
|
||||||
assetWithFace.sidecarPath,
|
assetWithFace.sidecarPath,
|
||||||
assetWithFace.originalPath,
|
assetWithFace.originalPath,
|
||||||
@@ -637,7 +638,14 @@ describe(AssetService.name, () => {
|
|||||||
{
|
{
|
||||||
name: JobName.DELETE_FILES,
|
name: JobName.DELETE_FILES,
|
||||||
data: {
|
data: {
|
||||||
files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'],
|
files: [
|
||||||
|
'/uploads/user-id/webp/path.ext',
|
||||||
|
'/uploads/user-id/thumbs/path.jpg',
|
||||||
|
'/uploads/user-id/fullsize/path.webp',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'fake_path/asset_1.jpeg',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -658,7 +666,14 @@ describe(AssetService.name, () => {
|
|||||||
{
|
{
|
||||||
name: JobName.DELETE_FILES,
|
name: JobName.DELETE_FILES,
|
||||||
data: {
|
data: {
|
||||||
files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'],
|
files: [
|
||||||
|
'/uploads/user-id/webp/path.ext',
|
||||||
|
'/uploads/user-id/thumbs/path.jpg',
|
||||||
|
'/uploads/user-id/fullsize/path.webp',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'fake_path/asset_1.jpeg',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ export class AssetService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { thumbnailFile, previewFile } = getAssetFiles(asset.files);
|
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
||||||
const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath];
|
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
|
||||||
|
|
||||||
if (deleteOnDisk) {
|
if (deleteOnDisk) {
|
||||||
files.push(asset.sidecarPath, asset.originalPath);
|
files.push(asset.sidecarPath, asset.originalPath);
|
||||||
|
|||||||
@@ -136,8 +136,14 @@ export class AuditService extends BaseService {
|
|||||||
for await (const assets of pagination) {
|
for await (const assets of pagination) {
|
||||||
assetCount += assets.length;
|
assetCount += assets.length;
|
||||||
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
|
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
|
||||||
const { previewFile, thumbnailFile } = getAssetFiles(files);
|
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(files);
|
||||||
for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) {
|
for (const file of [
|
||||||
|
originalPath,
|
||||||
|
fullsizeFile?.path,
|
||||||
|
previewFile?.path,
|
||||||
|
encodedVideoPath,
|
||||||
|
thumbnailFile?.path,
|
||||||
|
]) {
|
||||||
track(file);
|
track(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { JobName, JobStatus, QueueName } from 'src/enum';
|
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||||
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf } from 'src/types';
|
import { JobOf } from 'src/types';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFile } from 'src/utils/asset.util';
|
||||||
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
import { usePagination } from 'src/utils/pagination';
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export class DuplicateService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { previewFile } = getAssetFiles(asset.files);
|
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||||
if (!previewFile) {
|
if (!previewFile) {
|
||||||
this.logger.warn(`Asset ${id} is missing preview image`);
|
this.logger.warn(`Asset ${id} is missing preview image`);
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
|
|||||||
@@ -234,6 +234,24 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
||||||
|
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.FULLSIZE,
|
||||||
|
oldPath: '/uploads/user-id/fullsize/path.webp',
|
||||||
|
newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
|
||||||
|
});
|
||||||
|
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.PREVIEW,
|
||||||
|
oldPath: '/uploads/user-id/thumbs/path.jpg',
|
||||||
|
newPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||||
|
});
|
||||||
|
expect(mocks.move.create).toHaveBeenCalledWith({
|
||||||
|
entityId: assetStub.image.id,
|
||||||
|
pathType: AssetPathType.THUMBNAIL,
|
||||||
|
oldPath: '/uploads/user-id/webp/path.ext',
|
||||||
|
newPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||||
|
});
|
||||||
expect(mocks.move.create).toHaveBeenCalledTimes(3);
|
expect(mocks.move.create).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export class MediaService extends BaseService {
|
|||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG);
|
await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, image.fullsize.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
|
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||||
await this.storageCore.moveAssetVideo(asset);
|
await this.storageCore.moveAssetVideo(asset);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@@ -21,14 +22,8 @@ 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 ?? {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,6 +109,17 @@ 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);
|
||||||
|
|
||||||
@@ -145,10 +151,13 @@ 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]);
|
||||||
mockReadTags({
|
mocks.storage.stat.mockResolvedValue({
|
||||||
FileCreateDate: fileCreatedAt.toISOString(),
|
size: 123_456,
|
||||||
FileModifyDate: fileModifiedAt.toISOString(),
|
mtime: fileModifiedAt,
|
||||||
});
|
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 } });
|
||||||
@@ -168,10 +177,13 @@ 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]);
|
||||||
mockReadTags({
|
mocks.storage.stat.mockResolvedValue({
|
||||||
FileCreateDate: fileCreatedAt.toISOString(),
|
size: 123_456,
|
||||||
FileModifyDate: fileModifiedAt.toISOString(),
|
mtime: fileModifiedAt,
|
||||||
});
|
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 } });
|
||||||
@@ -206,10 +218,14 @@ 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 });
|
||||||
@@ -228,11 +244,15 @@ 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 });
|
||||||
@@ -475,6 +495,12 @@ 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,
|
||||||
@@ -483,8 +509,6 @@ 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);
|
||||||
@@ -525,14 +549,18 @@ 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);
|
||||||
@@ -574,13 +602,17 @@ 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);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
|
import { ContainerDirectoryItem, 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';
|
||||||
@@ -77,6 +78,11 @@ 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] })
|
||||||
@@ -171,18 +177,13 @@ export class MetadataService extends BaseService {
|
|||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exifTags = await this.getExifTags(asset);
|
const [exifTags, stats] = await Promise.all([
|
||||||
if (!exifTags.FileCreateDate || !exifTags.FileModifyDate || exifTags.FileSize === undefined) {
|
this.getExifTags(asset),
|
||||||
this.logger.warn(`Missing file creation or modification date for asset ${asset.id}: ${asset.originalPath}`);
|
this.storageRepository.stat(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 { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
const dates = this.getDates(asset, exifTags, stats);
|
||||||
|
|
||||||
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;
|
||||||
@@ -200,9 +201,9 @@ export class MetadataService extends BaseService {
|
|||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
|
||||||
// dates
|
// dates
|
||||||
dateTimeOriginal,
|
dateTimeOriginal: dates.dateTimeOriginal,
|
||||||
modifyDate,
|
modifyDate: stats.mtime,
|
||||||
timeZone,
|
timeZone: dates.timeZone,
|
||||||
|
|
||||||
// gps
|
// gps
|
||||||
latitude,
|
latitude,
|
||||||
@@ -212,7 +213,7 @@ export class MetadataService extends BaseService {
|
|||||||
city: geo.city,
|
city: geo.city,
|
||||||
|
|
||||||
// image/file
|
// image/file
|
||||||
fileSizeInByte: Number.parseInt(exifTags.FileSize!),
|
fileSizeInByte: stats.size,
|
||||||
exifImageHeight: validate(height),
|
exifImageHeight: validate(height),
|
||||||
exifImageWidth: validate(width),
|
exifImageWidth: validate(width),
|
||||||
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
||||||
@@ -245,15 +246,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,
|
localDateTime: dates.localDateTime,
|
||||||
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
|
||||||
fileModifiedAt: exifData.modifyDate ?? undefined,
|
fileModifiedAt: stats.mtime,
|
||||||
}),
|
}),
|
||||||
this.applyTagList(asset, exifTags),
|
this.applyTagList(asset, exifTags),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.isMotionPhoto(asset, exifTags)) {
|
if (this.isMotionPhoto(asset, exifTags)) {
|
||||||
promises.push(this.applyMotionPhotos(asset, exifTags, exifData.fileSizeInByte!));
|
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
|
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
|
||||||
@@ -432,7 +433,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, fileSize: number) {
|
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags, dates: Dates, stats: Stats) {
|
||||||
const isMotionPhoto = tags.MotionPhoto;
|
const isMotionPhoto = tags.MotionPhoto;
|
||||||
const isMicroVideo = tags.MicroVideo;
|
const isMicroVideo = tags.MicroVideo;
|
||||||
const videoOffset = tags.MicroVideoOffset;
|
const videoOffset = tags.MicroVideoOffset;
|
||||||
@@ -466,7 +467,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 = fileSize - length - padding;
|
const position = stats.size - length - padding;
|
||||||
let video: Buffer;
|
let video: Buffer;
|
||||||
// Samsung MotionPhoto video extraction
|
// Samsung MotionPhoto video extraction
|
||||||
// HEIC-encoded
|
// HEIC-encoded
|
||||||
@@ -505,13 +506,12 @@ 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: dates.modifyDate,
|
fileModifiedAt: stats.mtime,
|
||||||
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) {
|
private getDates(asset: AssetEntity, exifTags: ImmichTags, stats: Stats) {
|
||||||
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,17 +654,16 @@ 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) {
|
||||||
const fileCreatedAt = this.toDate(exifTags.FileCreateDate!);
|
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
|
||||||
const earliestDate = this.earliestDate(fileCreatedAt, modifyDate);
|
// birthtime is not available in Docker on macOS, so it appears as 0
|
||||||
|
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 assset ${asset.id}: ${asset.originalPath}`,
|
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
|
||||||
);
|
);
|
||||||
dateTimeOriginal = earliestDate;
|
dateTimeOriginal = localDateTime = earliestDate;
|
||||||
localDateTime = earliestDate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(
|
this.logger.verbose(
|
||||||
@@ -675,18 +674,9 @@ 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 &&
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { JobName, JobStatus, QueueName } from 'src/enum';
|
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { EmailTemplate } from 'src/repositories/notification.repository';
|
import { EmailTemplate } from 'src/repositories/notification.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types';
|
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFile } from 'src/utils/asset.util';
|
||||||
import { getFilenameExtension } from 'src/utils/file';
|
import { getFilenameExtension } from 'src/utils/file';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
import { isEqualObject } from 'src/utils/object';
|
import { isEqualObject } from 'src/utils/object';
|
||||||
@@ -398,7 +398,11 @@ export class NotificationService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true });
|
const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true });
|
||||||
const { thumbnailFile } = getAssetFiles(albumThumbnail?.files);
|
if (!albumThumbnail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailFile = getAssetFile(albumThumbnail.files, AssetFileType.THUMBNAIL);
|
||||||
if (!thumbnailFile) {
|
if (!thumbnailFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
|||||||
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
import { FaceSearchEntity } from 'src/entities/face-search.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import {
|
import {
|
||||||
|
AssetFileType,
|
||||||
AssetType,
|
AssetType,
|
||||||
CacheControl,
|
CacheControl,
|
||||||
ImageFormat,
|
ImageFormat,
|
||||||
@@ -42,7 +43,7 @@ import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
|||||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
import { UpdateFacesData } from 'src/repositories/person.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types';
|
import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFile } from 'src/utils/asset.util';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||||
@@ -300,7 +301,7 @@ export class PersonService extends BaseService {
|
|||||||
|
|
||||||
const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true };
|
const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true };
|
||||||
const [asset] = await this.assetRepository.getByIds([id], relations);
|
const [asset] = await this.assetRepository.getByIds([id], relations);
|
||||||
const { previewFile } = getAssetFiles(asset.files);
|
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||||
if (!asset || !previewFile) {
|
if (!asset || !previewFile) {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
@@ -674,7 +675,7 @@ export class PersonService extends BaseService {
|
|||||||
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
throw new Error(`Asset ${asset.id} dimensions are unknown`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { previewFile } = getAssetFiles(asset.files);
|
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||||
if (!previewFile) {
|
if (!previewFile) {
|
||||||
throw new Error(`Asset ${asset.id} has no preview path`);
|
throw new Error(`Asset ${asset.id} has no preview path`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
import { AssetFileType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf } from 'src/types';
|
import { JobOf } from 'src/types';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFile } from 'src/utils/asset.util';
|
||||||
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
|
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
import { usePagination } from 'src/utils/pagination';
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export class SmartInfoService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { previewFile } = getAssetFiles(asset.files);
|
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
|
||||||
if (!previewFile) {
|
if (!previewFile) {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
|
||||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
@@ -13,14 +13,14 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
|||||||
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
|
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
|
||||||
import { checkAccess } from 'src/utils/access';
|
import { checkAccess } from 'src/utils/access';
|
||||||
|
|
||||||
const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => {
|
export const getAssetFile = (files: AssetFileEntity[], type: AssetFileType | GeneratedImageType) => {
|
||||||
return (files || []).find((file) => file.type === type);
|
return (files || []).find((file) => file.type === type);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAssetFiles = (files?: AssetFileEntity[]) => ({
|
export const getAssetFiles = (files: AssetFileEntity[]) => ({
|
||||||
fullsizeFile: getFileByType(files, AssetFileType.FULLSIZE),
|
fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE),
|
||||||
previewFile: getFileByType(files, AssetFileType.PREVIEW),
|
previewFile: getAssetFile(files, AssetFileType.PREVIEW),
|
||||||
thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL),
|
thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAssets = async (
|
export const addAssets = async (
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export class BaseConfig implements VideoCodecSWConfig {
|
|||||||
'-fps_mode passthrough',
|
'-fps_mode passthrough',
|
||||||
// explicitly selects the video stream instead of leaving it up to FFmpeg
|
// explicitly selects the video stream instead of leaving it up to FFmpeg
|
||||||
`-map 0:${videoStream.index}`,
|
`-map 0:${videoStream.index}`,
|
||||||
|
// Strip metadata like capture date, camera, and GPS
|
||||||
|
'-map_metadata -1',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (audioStream) {
|
if (audioStream) {
|
||||||
|
|||||||
12
server/test/fixtures/asset.stub.ts
vendored
12
server/test/fixtures/asset.stub.ts
vendored
@@ -26,7 +26,16 @@ const thumbnailFile: AssetFileEntity = {
|
|||||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const files: AssetFileEntity[] = [previewFile, thumbnailFile];
|
const fullsizeFile: AssetFileEntity = {
|
||||||
|
id: 'file-3',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
type: AssetFileType.FULLSIZE,
|
||||||
|
path: '/uploads/user-id/fullsize/path.webp',
|
||||||
|
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const files: AssetFileEntity[] = [fullsizeFile, previewFile, thumbnailFile];
|
||||||
|
|
||||||
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => {
|
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => {
|
||||||
return {
|
return {
|
||||||
@@ -553,6 +562,7 @@ export const assetStub = {
|
|||||||
fileSizeInByte: 25_000,
|
fileSizeInByte: 25_000,
|
||||||
timeZone: `America/New_York`,
|
timeZone: `America/New_York`,
|
||||||
},
|
},
|
||||||
|
files,
|
||||||
} as AssetEntity),
|
} as AssetEntity),
|
||||||
|
|
||||||
livePhotoWithOriginalFileName: Object.freeze({
|
livePhotoWithOriginalFileName: Object.freeze({
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ describe(MetadataService.name, () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository }));
|
({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository }));
|
||||||
|
|
||||||
mocks.storage.stat.mockResolvedValue({ size: 123_456, ctime: new Date(), mtime: new Date() } as Stats);
|
mocks.storage.stat.mockResolvedValue({
|
||||||
|
size: 123_456,
|
||||||
|
mtime: new Date(654_321),
|
||||||
|
mtimeMs: 654_321,
|
||||||
|
birthtimeMs: 654_322,
|
||||||
|
} as Stats);
|
||||||
|
|
||||||
delete process.env.TZ;
|
delete process.env.TZ;
|
||||||
});
|
});
|
||||||
@@ -54,8 +59,6 @@ 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',
|
||||||
@@ -68,8 +71,6 @@ 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',
|
||||||
@@ -82,8 +83,6 @@ 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',
|
||||||
@@ -96,8 +95,6 @@ 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',
|
||||||
@@ -109,8 +106,6 @@ 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',
|
||||||
|
|||||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"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.2",
|
"version": "1.131.3",
|
||||||
"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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.131.2",
|
"version": "1.131.3",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
{type}
|
{type}
|
||||||
{value}
|
bind:value
|
||||||
max={max || fallbackMax}
|
max={max || fallbackMax}
|
||||||
oninput={(e) => (updatedValue = e.currentTarget.value)}
|
oninput={(e) => (updatedValue = e.currentTarget.value)}
|
||||||
onblur={() => (value = updatedValue)}
|
onblur={() => (value = updatedValue)}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { AssetBucket, assetSnapshot, assetsSnapshot } from '$lib/stores/assets-store.svelte';
|
import {
|
||||||
|
type AssetStore,
|
||||||
|
type AssetBucket,
|
||||||
|
assetSnapshot,
|
||||||
|
assetsSnapshot,
|
||||||
|
isSelectingAllAssets,
|
||||||
|
} from '$lib/stores/assets-store.svelte';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
@@ -22,6 +28,7 @@
|
|||||||
withStacked: boolean;
|
withStacked: boolean;
|
||||||
showArchiveIcon: boolean;
|
showArchiveIcon: boolean;
|
||||||
bucket: AssetBucket;
|
bucket: AssetBucket;
|
||||||
|
assetStore: AssetStore;
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
|
|
||||||
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
||||||
@@ -36,6 +43,7 @@
|
|||||||
showArchiveIcon,
|
showArchiveIcon,
|
||||||
bucket = $bindable(),
|
bucket = $bindable(),
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
|
assetStore,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSelectAssets,
|
onSelectAssets,
|
||||||
onSelectAssetCandidates,
|
onSelectAssetCandidates,
|
||||||
@@ -46,9 +54,9 @@
|
|||||||
|
|
||||||
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
assetSelectHandler(asset, assets, groupTitle);
|
assetSelectHandler(assetStore, asset, assets, groupTitle);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
@@ -56,7 +64,12 @@
|
|||||||
|
|
||||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
|
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
|
||||||
|
|
||||||
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
const assetSelectHandler = (
|
||||||
|
assetStore: AssetStore,
|
||||||
|
asset: AssetResponseDto,
|
||||||
|
assetsInDateGroup: AssetResponseDto[],
|
||||||
|
groupTitle: string,
|
||||||
|
) => {
|
||||||
onSelectAssets(asset);
|
onSelectAssets(asset);
|
||||||
|
|
||||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||||
@@ -70,6 +83,12 @@
|
|||||||
} else {
|
} else {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
|
||||||
|
isSelectingAllAssets.set(true);
|
||||||
|
} else {
|
||||||
|
isSelectingAllAssets.set(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
||||||
@@ -164,8 +183,8 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{groupIndex}
|
{groupIndex}
|
||||||
focussed={assetInteraction.isFocussedAsset(asset.id)}
|
focussed={assetInteraction.isFocussedAsset(asset.id)}
|
||||||
onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
|
onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)}
|
||||||
onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
|
onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)}
|
||||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
|
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
|
||||||
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
|
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, assetsSnapshot, AssetStore } from '$lib/stores/assets-store.svelte';
|
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
lastAssetMouseEvent = asset;
|
lastAssetMouseEvent = asset;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => {
|
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => {
|
||||||
if (assetInteraction.selectedGroup.has(group)) {
|
if (assetInteraction.selectedGroup.has(group)) {
|
||||||
assetInteraction.removeGroupFromMultiselectGroup(group);
|
assetInteraction.removeGroupFromMultiselectGroup(group);
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
@@ -468,6 +468,12 @@
|
|||||||
handleSelectAsset(asset);
|
handleSelectAsset(asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
|
||||||
|
isSelectingAllAssets.set(true);
|
||||||
|
} else {
|
||||||
|
isSelectingAllAssets.set(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAssets = async (asset: AssetResponseDto) => {
|
const handleSelectAssets = async (asset: AssetResponseDto) => {
|
||||||
@@ -774,10 +780,11 @@
|
|||||||
{withStacked}
|
{withStacked}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
|
{assetStore}
|
||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{bucket}
|
{bucket}
|
||||||
onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
|
onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
|
||||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||||
onSelectAssets={handleSelectAssets}
|
onSelectAssets={handleSelectAssets}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const colorTheme = persisted<ThemeSetting>('color-theme', initialTheme, {
|
|||||||
// Locale to use for formatting dates, numbers, etc.
|
// Locale to use for formatting dates, numbers, etc.
|
||||||
export const locale = persisted<string | undefined>('locale', undefined, {
|
export const locale = persisted<string | undefined>('locale', undefined, {
|
||||||
serializer: {
|
serializer: {
|
||||||
parse: (text) => text,
|
parse: (text) => (text == '' ? 'en-US' : text),
|
||||||
stringify: (object) => object ?? '',
|
stringify: (object) => object ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -486,6 +486,10 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
|
|||||||
break; // Cancelled
|
break; // Cancelled
|
||||||
}
|
}
|
||||||
assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets()));
|
assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets()));
|
||||||
|
|
||||||
|
for (const dateGroup of bucket.dateGroups) {
|
||||||
|
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const $t = get(t);
|
const $t = get(t);
|
||||||
|
|||||||
Reference in New Issue
Block a user