Merge branch 'main' of github.com:immich-app/immich into mobile/collections

This commit is contained in:
Alex 2024-09-10 09:11:09 -05:00
commit c2e4d91b69
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
52 changed files with 538 additions and 282 deletions

View File

@ -1,16 +1,20 @@
import { import {
mdiBug,
mdiCalendarToday, mdiCalendarToday,
mdiCrosshairsOff, mdiCrosshairsOff,
mdiDatabase,
mdiLeadPencil, mdiLeadPencil,
mdiLockOff, mdiLockOff,
mdiLockOutline, mdiLockOutline,
mdiSecurity,
mdiSpeedometerSlow, mdiSpeedometerSlow,
mdiTrashCan,
mdiWeb, mdiWeb,
mdiWrap, mdiWrap,
} from '@mdi/js'; } from '@mdi/js';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import React from 'react'; import React from 'react';
import { Item as TimelineItem, Timeline } from '../components/timeline'; import { Timeline, Item as TimelineItem } from '../components/timeline';
const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language); const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language);
@ -96,6 +100,51 @@ const items: Item[] = [
link: { url: 'https://github.com/immich-app/immich/pull/6787', text: '#6787' }, link: { url: 'https://github.com/immich-app/immich/pull/6787', text: '#6787' },
date: new Date(2024, 0, 31), date: new Date(2024, 0, 31),
}, },
{
icon: mdiBug,
iconColor: 'green',
title: 'ESM imports are cursed',
description:
'Prior to Node.js v20.8 using --experimental-vm-modules in a CommonJS project that imported an ES module that imported a CommonJS modules would create a segfault and crash Node.js',
link: {
url: 'https://github.com/immich-app/immich/pull/6719',
text: '#6179',
},
date: new Date(2024, 0, 9),
},
{
icon: mdiDatabase,
iconColor: 'gray',
title: 'PostgreSQL parameters are cursed',
description: `PostgresSQL has a limit of ${Number(65535).toLocaleString()} parameters, so bulk inserts can fail with large datasets.`,
link: {
url: 'https://github.com/immich-app/immich/pull/6034',
text: '#6034',
},
date: new Date(2023, 11, 28),
},
{
icon: mdiSecurity,
iconColor: 'gold',
title: 'Secure contexts are cursed',
description: `Some web features like the clipboard API only work in "secure contexts" (ie. https or localhost)`,
link: {
url: 'https://github.com/immich-app/immich/issues/2981',
text: '#2981',
},
date: new Date(2023, 5, 26),
},
{
icon: mdiTrashCan,
iconColor: 'gray',
title: 'TypeORM deletes are cursed',
description: `The remove implementation in TypeORM mutates the input, deleting the id property from the original object.`,
link: {
url: 'https://github.com/typeorm/typeorm/issues/7024#issuecomment-948519328',
text: 'typeorm#6034',
},
date: new Date(2023, 1, 23),
},
]; ];
export default function CursedKnowledgePage(): JSX.Element { export default function CursedKnowledgePage(): JSX.Element {

View File

@ -545,6 +545,38 @@ describe('/asset', () => {
expect(status).toEqual(200); expect(status).toEqual(200);
}); });
it('should not allow linking two photos', async () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: user1Assets[1].id });
expect(body).toEqual(errorDto.badRequest('Live photo video must be a video'));
expect(status).toEqual(400);
});
it('should not allow linking a video owned by another user', async () => {
const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } });
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: asset.id });
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user'));
expect(status).toEqual(400);
});
it('should link a motion photo', async () => {
const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } });
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: asset.id });
expect(status).toEqual(200);
expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id });
});
it('should update date time original when sidecar file contains DateTimeOriginal', async () => { it('should update date time original when sidecar file contains DateTimeOriginal', async () => {
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?> const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'> <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>

View File

@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:20c1819af5af3acba0b2b66074a2615e398ceee6842adf03cd7ad5f8d0ee3daf AS builder-cpu FROM python:3.11-bookworm@sha256:3cd9b520be95c671135ea1318f32be6912876024ee16d0f472669d3878801651 AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino
@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
FROM python:3.11-slim-bookworm@sha256:ed4e985674f478c90ce879e9aa224fbb772c84e39b4aed5155b9e2280f131039 AS prod-cpu FROM python:3.11-slim-bookworm@sha256:50ec89bdac0a845ec1751f91cb6187a3d8adb2b919d6e82d17acf48d1a9743fc AS prod-cpu
FROM prod-cpu AS prod-openvino FROM prod-cpu AS prod-openvino

View File

@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:29174348bd09352e5f1b1f6756cf1d00021487b8340fae040e91e4f98e954ce5 AS builder FROM mambaorg/micromamba:bookworm-slim@sha256:b10f75974a30a6889b03519ac48d3e1510fd13d0689468c2c443033a15d84f1b AS builder
ENV TRANSFORMERS_CACHE=/cache \ ENV TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \

View File

@ -1963,36 +1963,36 @@ reference = ["Pillow", "google-re2"]
[[package]] [[package]]
name = "onnxruntime" name = "onnxruntime"
version = "1.19.0" version = "1.19.2"
description = "ONNX Runtime is a runtime accelerator for Machine Learning models" description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
{file = "onnxruntime-1.19.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6ce22a98dfec7b646ae305f52d0ce14a189a758b02ea501860ca719f4b0ae04b"}, {file = "onnxruntime-1.19.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:84fa57369c06cadd3c2a538ae2a26d76d583e7c34bdecd5769d71ca5c0fc750e"},
{file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19019c72873f26927aa322c54cf2bf7312b23451b27451f39b88f57016c94f8b"}, {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc471a66df0c1cdef774accef69e9f2ca168c851ab5e4f2f3341512c7ef4666"},
{file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8eaa16df99171dc636e30108d15597aed8c4c2dd9dbfdd07cc464d57d73fb275"}, {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3a4ce906105d99ebbe817f536d50a91ed8a4d1592553f49b3c23c4be2560ae6"},
{file = "onnxruntime-1.19.0-cp310-cp310-win32.whl", hash = "sha256:0eb0f8dbe596fd0f4737fe511fdbb17603853a7d204c5b2ca38d3c7808fc556b"}, {file = "onnxruntime-1.19.2-cp310-cp310-win32.whl", hash = "sha256:4b3d723cc154c8ddeb9f6d0a8c0d6243774c6b5930847cc83170bfe4678fafb3"},
{file = "onnxruntime-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:616092d54ba8023b7bc0a5f6d900a07a37cc1cfcc631873c15f8c1d6e9e184d4"}, {file = "onnxruntime-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:17ed7382d2c58d4b7354fb2b301ff30b9bf308a1c7eac9546449cd122d21cae5"},
{file = "onnxruntime-1.19.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a2b53b3c287cd933e5eb597273926e899082d8c84ab96e1b34035764a1627e17"}, {file = "onnxruntime-1.19.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d863e8acdc7232d705d49e41087e10b274c42f09e259016a46f32c34e06dc4fd"},
{file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e94984663963e74fbb468bde9ec6f19dcf890b594b35e249c4dc8789d08993c5"}, {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dfe4f660a71b31caa81fc298a25f9612815215a47b286236e61d540350d7b6"},
{file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f379d1f050cfb55ce015d53727b78ee362febc065c38eed81512b22b757da73"}, {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a36511dc07c5c964b916697e42e366fa43c48cdb3d3503578d78cef30417cb84"},
{file = "onnxruntime-1.19.0-cp311-cp311-win32.whl", hash = "sha256:4ccb48faea02503275ae7e79e351434fc43c294c4cb5c4d8bcb7479061396614"}, {file = "onnxruntime-1.19.2-cp311-cp311-win32.whl", hash = "sha256:50cbb8dc69d6befad4746a69760e5b00cc3ff0a59c6c3fb27f8afa20e2cab7e7"},
{file = "onnxruntime-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:9cdc8d311289a84e77722de68bd22b8adfb94eea26f4be6f9e017350faac8b18"}, {file = "onnxruntime-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:1c3e5d415b78337fa0b1b75291e9ea9fb2a4c1f148eb5811e7212fed02cfffa8"},
{file = "onnxruntime-1.19.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1b59eaec1be9a8613c5fdeaafe67f73a062edce3ac03bbbdc9e2d98b58a30617"}, {file = "onnxruntime-1.19.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:68e7051bef9cfefcbb858d2d2646536829894d72a4130c24019219442b1dd2ed"},
{file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4144d014a4b25184e63ce7a463a2e7796e2f3df931fccc6a6aefa6f1365dc5"}, {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d366fbcc205ce68a8a3bde2185fd15c604d9645888703785b61ef174265168"},
{file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d7e7d4ca7021ce7f29a66dbc6071addf2de5839135339bd855c6d9c2bba371"}, {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:477b93df4db467e9cbf34051662a4b27c18e131fa1836e05974eae0d6e4cf29b"},
{file = "onnxruntime-1.19.0-cp312-cp312-win32.whl", hash = "sha256:87f2c58b577a1fb31dc5d92b647ecc588fd5f1ea0c3ad4526f5f80a113357c8d"}, {file = "onnxruntime-1.19.2-cp312-cp312-win32.whl", hash = "sha256:9a174073dc5608fad05f7cf7f320b52e8035e73d80b0a23c80f840e5a97c0147"},
{file = "onnxruntime-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f50d49676d7b69566536ff039d9e4e95fc482a55673719f46528218ecbb94"}, {file = "onnxruntime-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:190103273ea4507638ffc31d66a980594b237874b65379e273125150eb044857"},
{file = "onnxruntime-1.19.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:71423c8c4b2d7a58956271534302ec72721c62a41efd0c4896343249b8399ab0"}, {file = "onnxruntime-1.19.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636bc1d4cc051d40bc52e1f9da87fbb9c57d9d47164695dfb1c41646ea51ea66"},
{file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d63630d45e9498f96e75bbeb7fd4a56acb10155de0de4d0e18d1b6cbb0b358a"}, {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bd8b875757ea941cbcfe01582970cc299893d1b65bd56731e326a8333f638a3"},
{file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bfd15db1e8794d379a86c1a9116889f47f2cca40cc82208fc4f7e8c38e8522"}, {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2046fc9560f97947bbc1acbe4c6d48585ef0f12742744307d3364b131ac5778"},
{file = "onnxruntime-1.19.0-cp38-cp38-win32.whl", hash = "sha256:3b098003b6b4cb37cc84942e5f1fe27f945dd857cbd2829c824c26b0ba4a247e"}, {file = "onnxruntime-1.19.2-cp38-cp38-win32.whl", hash = "sha256:31c12840b1cde4ac1f7d27d540c44e13e34f2345cf3642762d2a3333621abb6a"},
{file = "onnxruntime-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:cea067a6541d6787d903ee6843401c5b1332a266585160d9700f9f0939443886"}, {file = "onnxruntime-1.19.2-cp38-cp38-win_amd64.whl", hash = "sha256:016229660adea180e9a32ce218b95f8f84860a200f0f13b50070d7d90e92956c"},
{file = "onnxruntime-1.19.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c4fcff12dc5ca963c5f76b9822bb404578fa4a98c281e8c666b429192799a099"}, {file = "onnxruntime-1.19.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:006c8d326835c017a9e9f74c9c77ebb570a71174a1e89fe078b29a557d9c3848"},
{file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6dcad8a4db908fbe70b98c79cea1c8b6ac3316adf4ce93453136e33a524ac59"}, {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df2a94179a42d530b936f154615b54748239c2908ee44f0d722cb4df10670f68"},
{file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bc449907c6e8d99eee5ae5cc9c8fdef273d801dcd195393d3f9ab8ad3f49522"}, {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fae4b4de45894b9ce7ae418c5484cbf0341db6813effec01bb2216091c52f7fb"},
{file = "onnxruntime-1.19.0-cp39-cp39-win32.whl", hash = "sha256:947febd48405afcf526e45ccff97ff23b15e530434705f734870d22ae7fcf236"}, {file = "onnxruntime-1.19.2-cp39-cp39-win32.whl", hash = "sha256:dc5430f473e8706fff837ae01323be9dcfddd3ea471c900a91fa7c9b807ec5d3"},
{file = "onnxruntime-1.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:f60be47eff5ee77fd28a466b0fd41d7debc42a32179d1ddb21e05d6067d7b48b"}, {file = "onnxruntime-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:38475e29a95c5f6c62c2c603d69fc7d4c6ccbf4df602bd567b86ae1138881c49"},
] ]
[package.dependencies] [package.dependencies]

View File

@ -414,7 +414,7 @@
"search_filter_people_title": "Select people", "search_filter_people_title": "Select people",
"search_page_categories": "Categorías", "search_page_categories": "Categorías",
"search_page_favorites": "Favoritos", "search_page_favorites": "Favoritos",
"search_page_motion_photos": "Fotos en .ovimiento", "search_page_motion_photos": "Fotos en movimiento",
"search_page_no_objects": "No hay información de objetos disponible", "search_page_no_objects": "No hay información de objetos disponible",
"search_page_no_places": "No hay información de lugares disponible", "search_page_no_places": "No hay información de lugares disponible",
"search_page_people": "Personas", "search_page_people": "Personas",

View File

@ -16,6 +16,7 @@ class AssetBulkUploadCheckResult {
required this.action, required this.action,
this.assetId, this.assetId,
required this.id, required this.id,
this.isTrashed,
this.reason, this.reason,
}); });
@ -31,6 +32,14 @@ class AssetBulkUploadCheckResult {
String id; String id;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isTrashed;
AssetBulkUploadCheckResultReasonEnum? reason; AssetBulkUploadCheckResultReasonEnum? reason;
@override @override
@ -38,6 +47,7 @@ class AssetBulkUploadCheckResult {
other.action == action && other.action == action &&
other.assetId == assetId && other.assetId == assetId &&
other.id == id && other.id == id &&
other.isTrashed == isTrashed &&
other.reason == reason; other.reason == reason;
@override @override
@ -46,10 +56,11 @@ class AssetBulkUploadCheckResult {
(action.hashCode) + (action.hashCode) +
(assetId == null ? 0 : assetId!.hashCode) + (assetId == null ? 0 : assetId!.hashCode) +
(id.hashCode) + (id.hashCode) +
(isTrashed == null ? 0 : isTrashed!.hashCode) +
(reason == null ? 0 : reason!.hashCode); (reason == null ? 0 : reason!.hashCode);
@override @override
String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, reason=$reason]'; String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, isTrashed=$isTrashed, reason=$reason]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -60,6 +71,11 @@ class AssetBulkUploadCheckResult {
// json[r'assetId'] = null; // json[r'assetId'] = null;
} }
json[r'id'] = this.id; json[r'id'] = this.id;
if (this.isTrashed != null) {
json[r'isTrashed'] = this.isTrashed;
} else {
// json[r'isTrashed'] = null;
}
if (this.reason != null) { if (this.reason != null) {
json[r'reason'] = this.reason; json[r'reason'] = this.reason;
} else { } else {
@ -79,6 +95,7 @@ class AssetBulkUploadCheckResult {
action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!, action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!,
assetId: mapValueOfType<String>(json, r'assetId'), assetId: mapValueOfType<String>(json, r'assetId'),
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
isTrashed: mapValueOfType<bool>(json, r'isTrashed'),
reason: AssetBulkUploadCheckResultReasonEnum.fromJson(json[r'reason']), reason: AssetBulkUploadCheckResultReasonEnum.fromJson(json[r'reason']),
); );
} }

View File

@ -18,6 +18,7 @@ class UpdateAssetDto {
this.isArchived, this.isArchived,
this.isFavorite, this.isFavorite,
this.latitude, this.latitude,
this.livePhotoVideoId,
this.longitude, this.longitude,
this.rating, this.rating,
}); });
@ -62,6 +63,14 @@ class UpdateAssetDto {
/// ///
num? latitude; num? latitude;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? livePhotoVideoId;
/// ///
/// Please note: This property should have been non-nullable! Since the specification file /// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated /// does not include a default value (using the "default:" property), however, the generated
@ -87,6 +96,7 @@ class UpdateAssetDto {
other.isArchived == isArchived && other.isArchived == isArchived &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
other.latitude == latitude && other.latitude == latitude &&
other.livePhotoVideoId == livePhotoVideoId &&
other.longitude == longitude && other.longitude == longitude &&
other.rating == rating; other.rating == rating;
@ -98,11 +108,12 @@ class UpdateAssetDto {
(isArchived == null ? 0 : isArchived!.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) +
(latitude == null ? 0 : latitude!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(longitude == null ? 0 : longitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) +
(rating == null ? 0 : rating!.hashCode); (rating == null ? 0 : rating!.hashCode);
@override @override
String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -131,6 +142,11 @@ class UpdateAssetDto {
} else { } else {
// json[r'latitude'] = null; // json[r'latitude'] = null;
} }
if (this.livePhotoVideoId != null) {
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
} else {
// json[r'livePhotoVideoId'] = null;
}
if (this.longitude != null) { if (this.longitude != null) {
json[r'longitude'] = this.longitude; json[r'longitude'] = this.longitude;
} else { } else {
@ -157,6 +173,7 @@ class UpdateAssetDto {
isArchived: mapValueOfType<bool>(json, r'isArchived'), isArchived: mapValueOfType<bool>(json, r'isArchived'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
rating: num.parse('${json[r'rating']}'), rating: num.parse('${json[r'rating']}'),
); );

View File

@ -7928,6 +7928,9 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"isTrashed": {
"type": "boolean"
},
"reason": { "reason": {
"enum": [ "enum": [
"duplicate", "duplicate",
@ -12238,6 +12241,10 @@
"latitude": { "latitude": {
"type": "number" "type": "number"
}, },
"livePhotoVideoId": {
"format": "uuid",
"type": "string"
},
"longitude": { "longitude": {
"type": "number" "type": "number"
}, },

View File

@ -395,6 +395,7 @@ export type AssetBulkUploadCheckResult = {
action: Action; action: Action;
assetId?: string; assetId?: string;
id: string; id: string;
isTrashed?: boolean;
reason?: Reason; reason?: Reason;
}; };
export type AssetBulkUploadCheckResponseDto = { export type AssetBulkUploadCheckResponseDto = {
@ -426,6 +427,7 @@ export type UpdateAssetDto = {
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
latitude?: number; latitude?: number;
livePhotoVideoId?: string;
longitude?: number; longitude?: number;
rating?: number; rating?: number;
}; };

View File

@ -301,7 +301,7 @@ export class StorageCore {
return this.assetRepository.update({ id, sidecarPath: newPath }); return this.assetRepository.update({ id, sidecarPath: newPath });
} }
case PersonPathType.FACE: { case PersonPathType.FACE: {
return this.personRepository.update([{ id, thumbnailPath: newPath }]); return this.personRepository.update({ id, thumbnailPath: newPath });
} }
} }
} }

View File

@ -26,6 +26,7 @@ export class AssetBulkUploadCheckResult {
action!: AssetUploadAction; action!: AssetUploadAction;
reason?: AssetRejectReason; reason?: AssetRejectReason;
assetId?: string; assetId?: string;
isTrashed?: boolean;
} }
export class AssetBulkUploadCheckResponseDto { export class AssetBulkUploadCheckResponseDto {

View File

@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
@Optional() @Optional()
@IsString() @IsString()
description?: string; description?: string;
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;
} }
export class RandomAssetsDto { export class RandomAssetsDto {

View File

@ -17,9 +17,10 @@ type EmitEventMap = {
'album.update': [{ id: string; updatedBy: string }]; 'album.update': [{ id: string; updatedBy: string }];
'album.invite': [{ id: string; userId: string }]; 'album.invite': [{ id: string; userId: string }];
// tag events // asset events
'asset.tag': [{ assetId: string }]; 'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }];
'asset.hide': [{ assetId: string; userId: string }];
// session events // session events
'session.delete': [{ sessionId: string }]; 'session.delete': [{ sessionId: string }];

View File

@ -54,7 +54,8 @@ export interface IPersonRepository {
getAssets(personId: string): Promise<AssetEntity[]>; getAssets(personId: string): Promise<AssetEntity[]>;
create(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]>; create(person: Partial<PersonEntity>): Promise<PersonEntity>;
createAll(people: Partial<PersonEntity>[]): Promise<string[]>;
createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>; createFaces(entities: Partial<AssetFaceEntity>[]): Promise<string[]>;
delete(entities: PersonEntity[]): Promise<void>; delete(entities: PersonEntity[]): Promise<void>;
deleteAll(): Promise<void>; deleteAll(): Promise<void>;
@ -74,6 +75,7 @@ export interface IPersonRepository {
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>; reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>; getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>; reassignFaces(data: UpdateFacesData): Promise<number>;
update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]>; update(person: Partial<PersonEntity>): Promise<PersonEntity>;
updateAll(people: Partial<PersonEntity>[]): Promise<void>;
getLatestFaceDate(): Promise<string | undefined>; getLatestFaceDate(): Promise<string | undefined>;
} }

View File

@ -493,6 +493,7 @@ LIMIT
-- AssetRepository.getByChecksums -- AssetRepository.getByChecksums
SELECT SELECT
"AssetEntity"."id" AS "AssetEntity_id", "AssetEntity"."id" AS "AssetEntity_id",
"AssetEntity"."deletedAt" AS "AssetEntity_deletedAt",
"AssetEntity"."checksum" AS "AssetEntity_checksum" "AssetEntity"."checksum" AS "AssetEntity_checksum"
FROM FROM
"assets" "AssetEntity" "assets" "AssetEntity"

View File

@ -8,7 +8,7 @@ FROM
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" IN ($1)
-- MetadataRepository.getStates -- MetadataRepository.getStates
SELECT DISTINCT SELECT DISTINCT
@ -18,7 +18,7 @@ FROM
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" IN ($1)
AND "exif"."country" = $2 AND "exif"."country" = $2
-- MetadataRepository.getCities -- MetadataRepository.getCities
@ -29,7 +29,7 @@ FROM
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" IN ($1)
AND "exif"."country" = $2 AND "exif"."country" = $2
AND "exif"."state" = $3 AND "exif"."state" = $3
@ -41,7 +41,7 @@ FROM
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" IN ($1)
AND "exif"."model" = $2 AND "exif"."model" = $2
-- MetadataRepository.getCameraModels -- MetadataRepository.getCameraModels
@ -52,5 +52,5 @@ FROM
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" IN ($1)
AND "exif"."make" = $2 AND "exif"."make" = $2

View File

@ -338,6 +338,7 @@ export class AssetRepository implements IAssetRepository {
select: { select: {
id: true, id: true,
checksum: true, checksum: true,
deletedAt: true,
}, },
where: { where: {
ownerId, ownerId,

View File

@ -55,7 +55,7 @@ export class MetadataRepository implements IMetadataRepository {
} }
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [[DummyValue.UUID]] })
async getCountries(userIds: string[]): Promise<string[]> { async getCountries(userIds: string[]): Promise<string[]> {
const results = await this.exifRepository const results = await this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
@ -68,7 +68,7 @@ export class MetadataRepository implements IMetadataRepository {
return results.map(({ country }) => country).filter((item) => item !== ''); return results.map(({ country }) => country).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getStates(userIds: string[], country: string | undefined): Promise<string[]> { async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
@ -86,7 +86,7 @@ export class MetadataRepository implements IMetadataRepository {
return result.map(({ state }) => state).filter((item) => item !== ''); return result.map(({ state }) => state).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> { async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
@ -108,7 +108,7 @@ export class MetadataRepository implements IMetadataRepository {
return results.map(({ city }) => city).filter((item) => item !== ''); return results.map(({ city }) => city).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> { async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
@ -125,7 +125,7 @@ export class MetadataRepository implements IMetadataRepository {
return results.map(({ make }) => make).filter((item) => item !== ''); return results.map(({ make }) => make).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> { async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')

View File

@ -280,8 +280,13 @@ export class PersonRepository implements IPersonRepository {
return result; return result;
} }
create(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> { create(person: Partial<PersonEntity>): Promise<PersonEntity> {
return this.personRepository.save(entities); return this.save(person);
}
async createAll(people: Partial<PersonEntity>[]): Promise<string[]> {
const results = await this.personRepository.save(people);
return results.map((person) => person.id);
} }
async createFaces(entities: AssetFaceEntity[]): Promise<string[]> { async createFaces(entities: AssetFaceEntity[]): Promise<string[]> {
@ -297,8 +302,12 @@ export class PersonRepository implements IPersonRepository {
}); });
} }
async update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> { async update(person: Partial<PersonEntity>): Promise<PersonEntity> {
return await this.personRepository.save(entities); return this.save(person);
}
async updateAll(people: Partial<PersonEntity>[]): Promise<void> {
await this.personRepository.save(people);
} }
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
@ -320,4 +329,9 @@ export class PersonRepository implements IPersonRepository {
.getRawOne(); .getRawOne();
return result?.latestDate; return result?.latestDate;
} }
private async save(person: Partial<PersonEntity>): Promise<PersonEntity> {
const { id } = await this.personRepository.save(person);
return this.personRepository.findOneByOrFail({ id });
}
} }

View File

@ -589,8 +589,20 @@ describe(AssetMediaService.name, () => {
}), }),
).resolves.toEqual({ ).resolves.toEqual({
results: [ results: [
{ id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, {
{ id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, id: '1',
assetId: 'asset-1',
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
isTrashed: false,
},
{
id: '2',
assetId: 'asset-2',
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
isTrashed: false,
},
], ],
}); });

View File

@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess, requireUploadAccess } from 'src/utils/access'; import { requireAccess, requireUploadAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request'; import { fromChecksum } from 'src/utils/request';
@ -158,20 +158,10 @@ export class AssetMediaService {
this.requireQuota(auth, file.size); this.requireQuota(auth, file.size);
if (dto.livePhotoVideoId) { if (dto.livePhotoVideoId) {
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId); await onBeforeLink(
if (!motionAsset) { { asset: this.assetRepository, event: this.eventRepository },
throw new BadRequestException('Live photo video not found'); { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
} );
if (motionAsset.type !== AssetType.VIDEO) {
throw new BadRequestException('Live photo video must be a video');
}
if (motionAsset.ownerId !== auth.user.id) {
throw new BadRequestException('Live photo video does not belong to the user');
}
if (motionAsset.isVisible) {
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id);
}
} }
const asset = await this.create(auth.user.id, dto, file, sidecarFile); const asset = await this.create(auth.user.id, dto, file, sidecarFile);
@ -289,10 +279,10 @@ export class AssetMediaService {
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> { async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
const checksumMap: Record<string, string> = {}; const checksumMap: Record<string, { id: string; isTrashed: boolean }> = {};
for (const { id, checksum } of results) { for (const { id, deletedAt, checksum } of results) {
checksumMap[checksum.toString('hex')] = id; checksumMap[checksum.toString('hex')] = { id, isTrashed: !!deletedAt };
} }
return { return {
@ -301,14 +291,13 @@ export class AssetMediaService {
if (duplicate) { if (duplicate) {
return { return {
id, id,
assetId: duplicate,
action: AssetUploadAction.REJECT, action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE, reason: AssetRejectReason.DUPLICATE,
assetId: duplicate.id,
isTrashed: duplicate.isTrashed,
}; };
} }
// TODO mime-check
return { return {
id, id,
action: AssetUploadAction.ACCEPT, action: AssetUploadAction.ACCEPT,

View File

@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess } from 'src/utils/access'; import { requireAccess } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onBeforeLink } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
export class AssetService { export class AssetService {
@ -159,6 +159,14 @@ export class AssetService {
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
if (rest.livePhotoVideoId) {
await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId },
);
}
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
await this.assetRepository.update({ id, ...rest }); await this.assetRepository.update({ id, ...rest });

View File

@ -115,7 +115,7 @@ export class AuditService {
} }
case PersonPathType.FACE: { case PersonPathType.FACE: {
await this.personRepository.update([{ id, thumbnailPath: pathValue }]); await this.personRepository.update({ id, thumbnailPath: pathValue });
break; break;
} }

View File

@ -117,7 +117,7 @@ export class MediaService {
continue; continue;
} }
await this.personRepository.update([{ id: person.id, faceAssetId: face.id }]); await this.personRepository.update({ id: person.id, faceAssetId: face.id });
} }
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });

View File

@ -8,7 +8,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository } from 'src/interfaces/map.interface'; import { IMapRepository } from 'src/interfaces/map.interface';
@ -220,11 +220,10 @@ describe(MetadataService.name, () => {
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
JobStatus.SUCCESS, JobStatus.SUCCESS,
); );
expect(eventMock.clientSend).toHaveBeenCalledWith( expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
ClientEvent.ASSET_HIDDEN, userId: assetStub.livePhotoMotionAsset.ownerId,
assetStub.livePhotoMotionAsset.ownerId, assetId: assetStub.livePhotoMotionAsset.id,
assetStub.livePhotoMotionAsset.id, });
);
}); });
it('should search by libraryId', async () => { it('should search by libraryId', async () => {
@ -520,6 +519,16 @@ describe(MetadataService.name, () => {
); );
}); });
it('should handle an invalid Directory Item', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({
MotionPhoto: 1,
ContainerDirectory: [{ Foo: 100 }],
});
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
});
it('should extract the correct video orientation', async () => { it('should extract the correct video orientation', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
@ -993,13 +1002,12 @@ describe(MetadataService.name, () => {
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName); metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName);
personMock.getDistinctNames.mockResolvedValue([]); personMock.getDistinctNames.mockResolvedValue([]);
personMock.create.mockResolvedValue([]); personMock.createAll.mockResolvedValue([]);
personMock.replaceFaces.mockResolvedValue([]); personMock.replaceFaces.mockResolvedValue([]);
personMock.update.mockResolvedValue([]);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(personMock.create).toHaveBeenCalledWith([]); expect(personMock.createAll).toHaveBeenCalledWith([]);
expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
expect(personMock.update).toHaveBeenCalledWith([]); expect(personMock.updateAll).toHaveBeenCalledWith([]);
}); });
it('should skip importing faces with empty name', async () => { it('should skip importing faces with empty name', async () => {
@ -1007,13 +1015,12 @@ describe(MetadataService.name, () => {
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName); metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName);
personMock.getDistinctNames.mockResolvedValue([]); personMock.getDistinctNames.mockResolvedValue([]);
personMock.create.mockResolvedValue([]); personMock.createAll.mockResolvedValue([]);
personMock.replaceFaces.mockResolvedValue([]); personMock.replaceFaces.mockResolvedValue([]);
personMock.update.mockResolvedValue([]);
await sut.handleMetadataExtraction({ id: assetStub.image.id }); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(personMock.create).toHaveBeenCalledWith([]); expect(personMock.createAll).toHaveBeenCalledWith([]);
expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF);
expect(personMock.update).toHaveBeenCalledWith([]); expect(personMock.updateAll).toHaveBeenCalledWith([]);
}); });
it('should apply metadata face tags creating new persons', async () => { it('should apply metadata face tags creating new persons', async () => {
@ -1021,13 +1028,13 @@ describe(MetadataService.name, () => {
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
metadataMock.readTags.mockResolvedValue(metadataStub.withFace); metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
personMock.getDistinctNames.mockResolvedValue([]); personMock.getDistinctNames.mockResolvedValue([]);
personMock.create.mockResolvedValue([personStub.withName]); personMock.createAll.mockResolvedValue([personStub.withName.id]);
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
personMock.update.mockResolvedValue([personStub.withName]); personMock.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
expect(personMock.create).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); expect(personMock.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]);
expect(personMock.replaceFaces).toHaveBeenCalledWith( expect(personMock.replaceFaces).toHaveBeenCalledWith(
assetStub.primaryImage.id, assetStub.primaryImage.id,
[ [
@ -1046,7 +1053,7 @@ describe(MetadataService.name, () => {
], ],
SourceType.EXIF, SourceType.EXIF,
); );
expect(personMock.update).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.GENERATE_PERSON_THUMBNAIL, name: JobName.GENERATE_PERSON_THUMBNAIL,
@ -1060,13 +1067,13 @@ describe(MetadataService.name, () => {
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
metadataMock.readTags.mockResolvedValue(metadataStub.withFace); metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
personMock.create.mockResolvedValue([]); personMock.createAll.mockResolvedValue([]);
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
personMock.update.mockResolvedValue([personStub.withName]); personMock.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
expect(personMock.create).toHaveBeenCalledWith([]); expect(personMock.createAll).toHaveBeenCalledWith([]);
expect(personMock.replaceFaces).toHaveBeenCalledWith( expect(personMock.replaceFaces).toHaveBeenCalledWith(
assetStub.primaryImage.id, assetStub.primaryImage.id,
[ [
@ -1085,7 +1092,7 @@ describe(MetadataService.name, () => {
], ],
SourceType.EXIF, SourceType.EXIF,
); );
expect(personMock.update).toHaveBeenCalledWith([]); expect(personMock.updateAll).toHaveBeenCalledWith([]);
expect(jobMock.queueAll).toHaveBeenCalledWith([]); expect(jobMock.queueAll).toHaveBeenCalledWith([]);
}); });
}); });

View File

@ -17,7 +17,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { import {
IBaseJob, IBaseJob,
IEntityJob, IEntityJob,
@ -186,8 +186,7 @@ export class MetadataService {
await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
await this.albumRepository.removeAsset(motionAsset.id); await this.albumRepository.removeAsset(motionAsset.id);
// Notify clients to hide the linked live photo asset await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@ -428,7 +427,7 @@ export class MetadataService {
if (isMotionPhoto && directory) { if (isMotionPhoto && directory) {
for (const entry of directory) { for (const entry of directory) {
if (entry.Item.Semantic == 'MotionPhoto') { if (entry?.Item?.Semantic == 'MotionPhoto') {
length = entry.Item.Length ?? 0; length = entry.Item.Length ?? 0;
padding = entry.Item.Padding ?? 0; padding = entry.Item.Padding ?? 0;
break; break;
@ -585,18 +584,15 @@ export class MetadataService {
this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`);
} }
const newPersons = await this.personRepository.create(missing); const newPersonIds = await this.personRepository.createAll(missing);
const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF); const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF);
this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`); this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`);
await this.personRepository.update(missingWithFaceAsset); await this.personRepository.updateAll(missingWithFaceAsset);
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
newPersons.map((person) => ({ newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } })),
name: JobName.GENERATE_PERSON_THUMBNAIL,
data: { id: person.id },
})),
); );
} }

View File

@ -58,6 +58,12 @@ export class NotificationService {
} }
} }
@OnEmit({ event: 'asset.hide' })
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
// Notify clients to hide the linked live photo asset
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, userId, assetId);
}
@OnEmit({ event: 'user.signup' }) @OnEmit({ event: 'user.signup' })
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
if (notify) { if (notify) {

View File

@ -241,18 +241,18 @@ describe(PersonService.name, () => {
}); });
it("should update a person's name", async () => { it("should update a person's name", async () => {
personMock.update.mockResolvedValue([personStub.withName]); personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]); personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', name: 'Person 1' }]); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });
it("should update a person's date of birth", async () => { it("should update a person's date of birth", async () => {
personMock.update.mockResolvedValue([personStub.withBirthDate]); personMock.update.mockResolvedValue(personStub.withBirthDate);
personMock.getAssets.mockResolvedValue([assetStub.image]); personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@ -264,25 +264,25 @@ describe(PersonService.name, () => {
isHidden: false, isHidden: false,
updatedAt: expect.any(Date), updatedAt: expect.any(Date),
}); });
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', birthDate: '1976-06-30' }]); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });
it('should update a person visibility', async () => { it('should update a person visibility', async () => {
personMock.update.mockResolvedValue([personStub.withName]); personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]); personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', isHidden: false }]); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });
it("should update a person's thumbnailPath", async () => { it("should update a person's thumbnailPath", async () => {
personMock.update.mockResolvedValue([personStub.withName]); personMock.update.mockResolvedValue(personStub.withName);
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
@ -291,7 +291,7 @@ describe(PersonService.name, () => {
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
).resolves.toEqual(responseDto); ).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', faceAssetId: faceStub.face1.id }]); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id });
expect(personMock.getFacesByIds).toHaveBeenCalledWith([ expect(personMock.getFacesByIds).toHaveBeenCalledWith([
{ {
assetId: faceStub.face1.assetId, assetId: faceStub.face1.assetId,
@ -441,11 +441,11 @@ describe(PersonService.name, () => {
describe('createPerson', () => { describe('createPerson', () => {
it('should create a new person', async () => { it('should create a new person', async () => {
personMock.create.mockResolvedValue([personStub.primaryPerson]); personMock.create.mockResolvedValue(personStub.primaryPerson);
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
expect(personMock.create).toHaveBeenCalledWith([{ ownerId: authStub.admin.user.id }]); expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
}); });
}); });
@ -819,7 +819,7 @@ describe(PersonService.name, () => {
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue([faceStub.primaryFace1.person]); personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -844,16 +844,14 @@ describe(PersonService.name, () => {
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue([personStub.withName]); personMock.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
expect(personMock.create).toHaveBeenCalledWith([ expect(personMock.create).toHaveBeenCalledWith({
{ ownerId: faceStub.noPerson1.asset.ownerId,
ownerId: faceStub.noPerson1.asset.ownerId, faceAssetId: faceStub.noPerson1.id,
faceAssetId: faceStub.noPerson1.id, });
},
]);
expect(personMock.reassignFaces).toHaveBeenCalledWith({ expect(personMock.reassignFaces).toHaveBeenCalledWith({
faceIds: [faceStub.noPerson1.id], faceIds: [faceStub.noPerson1.id],
newPersonId: personStub.withName.id, newPersonId: personStub.withName.id,
@ -865,7 +863,7 @@ describe(PersonService.name, () => {
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue([personStub.withName]); personMock.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -884,7 +882,7 @@ describe(PersonService.name, () => {
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
searchMock.searchFaces.mockResolvedValue(faces); searchMock.searchFaces.mockResolvedValue(faces);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue([personStub.withName]); personMock.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
@ -906,7 +904,7 @@ describe(PersonService.name, () => {
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
personMock.create.mockResolvedValue([personStub.withName]); personMock.create.mockResolvedValue(personStub.withName);
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
@ -979,12 +977,10 @@ describe(PersonService.name, () => {
processInvalidImages: false, processInvalidImages: false,
}, },
); );
expect(personMock.update).toHaveBeenCalledWith([ expect(personMock.update).toHaveBeenCalledWith({
{ id: 'person-1',
id: 'person-1', thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', });
},
]);
}); });
it('should generate a thumbnail without going negative', async () => { it('should generate a thumbnail without going negative', async () => {
@ -1103,7 +1099,7 @@ describe(PersonService.name, () => {
it('should merge two people with smart merge', async () => { it('should merge two people with smart merge', async () => {
personMock.getById.mockResolvedValueOnce(personStub.randomPerson); personMock.getById.mockResolvedValueOnce(personStub.randomPerson);
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.update.mockResolvedValue([{ ...personStub.randomPerson, name: personStub.primaryPerson.name }]); personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name });
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
@ -1116,12 +1112,10 @@ describe(PersonService.name, () => {
oldPersonId: personStub.primaryPerson.id, oldPersonId: personStub.primaryPerson.id,
}); });
expect(personMock.update).toHaveBeenCalledWith([ expect(personMock.update).toHaveBeenCalledWith({
{ id: personStub.randomPerson.id,
id: personStub.randomPerson.id, name: personStub.primaryPerson.name,
name: personStub.primaryPerson.name, });
},
]);
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
}); });

View File

@ -173,7 +173,7 @@ export class PersonService {
const assetFace = await this.repository.getRandomFace(personId); const assetFace = await this.repository.getRandomFace(personId);
if (assetFace !== null) { if (assetFace !== null) {
await this.repository.update([{ id: personId, faceAssetId: assetFace.id }]); await this.repository.update({ id: personId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
} }
} }
@ -211,16 +211,13 @@ export class PersonService {
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} }
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> { create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
const [created] = await this.repository.create([ return this.repository.create({
{ ownerId: auth.user.id,
ownerId: auth.user.id, name: dto.name,
name: dto.name, birthDate: dto.birthDate,
birthDate: dto.birthDate, isHidden: dto.isHidden,
isHidden: dto.isHidden, });
},
]);
return created;
} }
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
@ -239,7 +236,7 @@ export class PersonService {
faceId = face.id; faceId = face.id;
} }
const [person] = await this.repository.update([{ id, faceAssetId: faceId, name, birthDate, isHidden }]); const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
if (assetId) { if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
@ -501,7 +498,7 @@ export class PersonService {
if (isCore && !personId) { if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`); this.logger.log(`Creating new person for face ${id}`);
const [newPerson] = await this.repository.create([{ ownerId: face.asset.ownerId, faceAssetId: face.id }]); const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
personId = newPerson.id; personId = newPerson.id;
} }
@ -577,7 +574,7 @@ export class PersonService {
} as const; } as const;
await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions);
await this.repository.update([{ id: person.id, thumbnailPath }]); await this.repository.update({ id: person.id, thumbnailPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@ -624,7 +621,7 @@ export class PersonService {
} }
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
[primaryPerson] = await this.repository.update([{ id: primaryPerson.id, ...update }]); primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update });
} }
const mergeName = mergePerson.name || mergePerson.id; const mergeName = mergePerson.name || mergePerson.id;

View File

@ -1,8 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, Permission } from 'src/enum'; import { AssetFileType, AssetType, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
return [...partnerIds]; return [...partnerIds];
}; };
export const onBeforeLink = async (
{ asset: assetRepository, event: eventRepository }: { asset: IAssetRepository; event: IEventRepository },
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
) => {
const motionAsset = await assetRepository.getById(livePhotoVideoId);
if (!motionAsset) {
throw new BadRequestException('Live photo video not found');
}
if (motionAsset.type !== AssetType.VIDEO) {
throw new BadRequestException('Live photo video must be a video');
}
if (motionAsset.ownerId !== userId) {
throw new BadRequestException('Live photo video does not belong to the user');
}
if (motionAsset?.isVisible) {
await assetRepository.update({ id: livePhotoVideoId, isVisible: false });
await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId });
}
};

View File

@ -13,9 +13,11 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
getDistinctNames: vitest.fn(), getDistinctNames: vitest.fn(),
create: vitest.fn(), create: vitest.fn(),
createAll: vitest.fn(),
update: vitest.fn(), update: vitest.fn(),
deleteAll: vitest.fn(), updateAll: vitest.fn(),
delete: vitest.fn(), delete: vitest.fn(),
deleteAll: vitest.fn(),
deleteAllFaces: vitest.fn(), deleteAllFaces: vitest.fn(),
getStatistics: vitest.fn(), getStatistics: vitest.fn(),

13
web/package-lock.json generated
View File

@ -33,7 +33,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.0",
@ -746,9 +746,9 @@
} }
}, },
"node_modules/@faker-js/faker": { "node_modules/@faker-js/faker": {
"version": "8.4.1", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0.tgz",
"integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", "integrity": "sha512-dTDHJSmz6c1OJ6HO7jiUiIb4sB20Dlkb3pxYsKm0qTXm2Bmj97rlXIhlvaFsW2rvCi+OLlwKLVSS6ZxFUVZvjQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -756,9 +756,10 @@
"url": "https://opencollective.com/fakerjs" "url": "https://opencollective.com/fakerjs"
} }
], ],
"license": "MIT",
"engines": { "engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0", "node": ">=18.0.0",
"npm": ">=6.14.13" "npm": ">=9.0.0"
} }
}, },
"node_modules/@formatjs/ecma402-abstract": { "node_modules/@formatjs/ecma402-abstract": {

View File

@ -25,7 +25,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.0",

View File

@ -331,8 +331,10 @@
locale: $locale, locale: $locale,
}) })
: DateTime.now()} : DateTime.now()}
{@const assetTimeZoneOriginal = asset.exifInfo?.timeZone ?? ''}
<ChangeDate <ChangeDate
initialDate={assetDateTimeOriginal} initialDate={assetDateTimeOriginal}
initialTimeZone={assetTimeZoneOriginal}
on:confirm={({ detail: date }) => handleConfirmChangeDate(date)} on:confirm={({ detail: date }) => handleConfirmChangeDate(date)}
on:cancel={() => (isShowChangeDate = false)} on:cancel={() => (isShowChangeDate = false)}
/> />

View File

@ -11,7 +11,7 @@
let showAlbumPicker = false; let showAlbumPicker = false;
const { getAssets, clearSelect } = getAssetControlContext(); const { getAssets } = getAssetControlContext();
const handleHideAlbumPicker = () => { const handleHideAlbumPicker = () => {
showAlbumPicker = false; showAlbumPicker = false;
@ -28,7 +28,6 @@
showAlbumPicker = false; showAlbumPicker = false;
const assetIds = [...getAssets()].map((asset) => asset.id); const assetIds = [...getAssets()].map((asset) => asset.id);
await addAssetsToAlbum(album.id, assetIds); await addAssetsToAlbum(album.id, assetIds);
clearSelect();
}; };
</script> </script>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnLink } from '$lib/utils/actions';
import { AssetTypeEnum, updateAsset } from '@immich/sdk';
import { mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onLink: OnLink;
export let menuItem = false;
let loading = false;
const text = $t('link_motion_video');
const icon = mdiMotionPlayOutline;
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) {
[still, motion] = [motion, still];
}
loading = true;
const response = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink(response);
clearSelect();
loading = false;
};
</script>
{#if menuItem}
<MenuOption {text} {icon} onClick={handleLink} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
{:else}
<CircleIconButton title={text} {icon} on:click={handleLink} />
{/if}
{/if}

View File

@ -7,6 +7,7 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let initialDate: DateTime = DateTime.now(); export let initialDate: DateTime = DateTime.now();
export let initialTimeZone: string = '';
type ZoneOption = { type ZoneOption = {
/** /**
@ -76,7 +77,7 @@
} }
/* /*
* Find the time zone to select for a given time, date, and offset (e.g. +02:00). * If the time zone is not given, find the timezone to select for a given time, date, and offset (e.g. +02:00).
* *
* This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)" * This is done so that the list shown to the user includes more helpful names like "Europe/Berlin (+02:00)"
* instead of just the raw offset or something like "UTC+02:00". * instead of just the raw offset or something like "UTC+02:00".
@ -97,6 +98,7 @@
) { ) {
const offset = date.offset; const offset = date.offset;
const previousSelection = timezones.find((item) => item.value === selectedOption?.value); const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone); const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset); const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
const utcFallback = { const utcFallback = {
@ -105,7 +107,7 @@
value: 'UTC', value: 'UTC',
valid: true, valid: true,
}; };
return previousSelection ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback; return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
} }
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) { function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {

View File

@ -22,7 +22,7 @@
* - `narrow`: 28rem * - `narrow`: 28rem
* - `auto`: fits the width of the modal content, up to a maximum of 32rem * - `auto`: fits the width of the modal content, up to a maximum of 32rem
*/ */
export let width: 'wide' | 'narrow' | 'auto' = 'narrow'; export let width: 'extra-wide' | 'wide' | 'narrow' | 'auto' = 'narrow';
/** /**
* Unique identifier for the modal. * Unique identifier for the modal.
@ -34,12 +34,25 @@
let modalWidth: string; let modalWidth: string;
$: { $: {
if (width === 'wide') { switch (width) {
modalWidth = 'w-[48rem]'; case 'extra-wide': {
} else if (width === 'narrow') { modalWidth = 'w-[56rem]';
modalWidth = 'w-[28rem]'; break;
} else { }
modalWidth = 'sm:max-w-4xl';
case 'wide': {
modalWidth = 'w-[48rem]';
break;
}
case 'narrow': {
modalWidth = 'w-[28rem]';
break;
}
default: {
modalWidth = 'sm:max-w-4xl';
}
} }
} }
</script> </script>
@ -55,24 +68,28 @@
use:focusTrap use:focusTrap
> >
<div <div
class="z-[9999] max-w-[95vw] max-h-[min(95dvh,56rem)] {modalWidth} overflow-y-auto rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar" class="z-[9999] max-w-[95vw] {modalWidth} overflow-hidden rounded-3xl bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg pt-3 pb-4"
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }} use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
tabindex="-1" tabindex="-1"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
class:scroll-pb-40={isStickyBottom}
class:sm:scroll-p-24={isStickyBottom}
> >
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} /> <div
<div class="p-5 pt-0"> class="immich-scrollbar overflow-y-auto max-h-[min(92dvh,64rem)]"
<slot /> class:scroll-pb-40={isStickyBottom}
</div> class:sm:scroll-p-24={isStickyBottom}
{#if isStickyBottom} >
<div <ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky bottom-0 py-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500 shadow" <div class="px-5 pt-0" class:pb-5={isStickyBottom}>
> <slot />
<slot name="sticky-bottom" />
</div> </div>
{/if} {#if isStickyBottom}
<div
class="flex flex-col sm:flex-row justify-end w-full gap-2 sm:gap-4 sticky bottom-0 pt-4 px-5 bg-immich-bg dark:bg-immich-dark-gray border-t border-gray-200 dark:border-gray-500 shadow z-[9999]"
>
<slot name="sticky-bottom" />
</div>
{/if}
</div>
</div> </div>
</section> </section>

View File

@ -21,12 +21,12 @@
export let icon: string | undefined = undefined; export let icon: string | undefined = undefined;
</script> </script>
<div class="flex place-items-center justify-between px-5 py-3"> <div class="flex place-items-center justify-between px-5 pb-3">
<div class="flex gap-2 place-items-center"> <div class="flex gap-2 place-items-center">
{#if showLogo} {#if showLogo}
<ImmichLogo noText={true} width={32} /> <ImmichLogo noText={true} width={32} />
{:else if icon} {:else if icon}
<Icon path={icon} size={32} ariaHidden={true} class="text-immich-primary dark:text-immich-dark-primary" /> <Icon path={icon} size={24} ariaHidden={true} class="text-immich-primary dark:text-immich-dark-primary" />
{/if} {/if}
<h1 {id}> <h1 {id}>
{title} {title}

View File

@ -4,7 +4,7 @@
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import SearchHistoryBox from './search-history-box.svelte'; import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterBox from './search-filter-box.svelte'; import SearchFilterModal from './search-filter-modal.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
@ -160,8 +160,8 @@
id="main-search-bar" id="main-search-bar"
class="w-full transition-all border-2 px-14 py-4 text-immich-fg/75 dark:text-immich-dark-fg class="w-full transition-all border-2 px-14 py-4 text-immich-fg/75 dark:text-immich-dark-fg
{grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} {grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'}
{(showSuggestions && isSearchSuggestions) || showFilter ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} {showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
{$isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}" {$isSearchEnabled && !showFilter ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
placeholder={$t('search_your_photos')} placeholder={$t('search_your_photos')}
required required
pattern="^(?!m:$).*$" pattern="^(?!m:$).*$"
@ -215,6 +215,6 @@
</form> </form>
{#if showFilter} {#if showFilter}
<SearchFilterBox {searchQuery} on:search={({ detail }) => onSearch(detail)} /> <SearchFilterModal {searchQuery} onSearch={(payload) => onSearch(payload)} onClose={() => (showFilter = false)} />
{/if} {/if}
</div> </div>

View File

@ -10,8 +10,8 @@
} }
export type SearchFilter = { export type SearchFilter = {
context?: string; query: string;
filename?: string; queryType: 'smart' | 'metadata';
personIds: Set<string>; personIds: Set<string>;
location: SearchLocationFilter; location: SearchLocationFilter;
camera: SearchCameraFilter; camera: SearchCameraFilter;
@ -24,8 +24,6 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk'; import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { fly } from 'svelte/transition';
import SearchPeopleSection from './search-people-section.svelte'; import SearchPeopleSection from './search-people-section.svelte';
import SearchLocationSection from './search-location-section.svelte'; import SearchLocationSection from './search-location-section.svelte';
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
@ -35,12 +33,17 @@
import SearchDisplaySection from './search-display-section.svelte'; import SearchDisplaySection from './search-display-section.svelte';
import SearchTextSection from './search-text-section.svelte'; import SearchTextSection from './search-text-section.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { mdiTune } from '@mdi/js';
import { generateId } from '$lib/utils/generate-id';
export let searchQuery: MetadataSearchDto | SmartSearchDto; export let searchQuery: MetadataSearchDto | SmartSearchDto;
export let onClose: () => void;
export let onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined); const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined; const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>(); const formId = generateId();
// combobox and all the search components have terrible support for value | null so we use empty string instead. // combobox and all the search components have terrible support for value | null so we use empty string instead.
function withNullAsUndefined<T>(value: T | null) { function withNullAsUndefined<T>(value: T | null) {
@ -48,8 +51,8 @@
} }
let filter: SearchFilter = { let filter: SearchFilter = {
context: 'query' in searchQuery ? searchQuery.query : '', query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined, queryType: 'query' in searchQuery ? 'smart' : 'metadata',
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
location: { location: {
country: withNullAsUndefined(searchQuery.country), country: withNullAsUndefined(searchQuery.country),
@ -79,6 +82,8 @@
const resetForm = () => { const resetForm = () => {
filter = { filter = {
query: '',
queryType: 'smart',
personIds: new Set(), personIds: new Set(),
location: {}, location: {},
camera: {}, camera: {},
@ -96,9 +101,11 @@
type = AssetTypeEnum.Video; type = AssetTypeEnum.Video;
} }
const query = filter.query || undefined;
let payload: SmartSearchDto | MetadataSearchDto = { let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.context || undefined, query: filter.queryType === 'smart' ? query : undefined,
originalFileName: filter.filename, originalFileName: filter.queryType === 'metadata' ? query : undefined,
country: filter.location.country, country: filter.location.country,
state: filter.location.state, state: filter.location.state,
city: filter.location.city, city: filter.location.city,
@ -113,26 +120,18 @@
type, type,
}; };
dispatch('search', payload); onSearch(payload);
}; };
</script> </script>
<div <FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>
transition:fly={{ y: 25, duration: 250 }} <form id={formId} autocomplete="off" on:submit|preventDefault={search} on:reset|preventDefault={resetForm}>
class="absolute w-full rounded-b-3xl border-2 border-t-0 border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-immich-dark-gray dark:text-gray-300" <div class="space-y-10 pb-10" tabindex="-1">
>
<form
id="search-filter-form"
autocomplete="off"
on:submit|preventDefault={search}
on:reset|preventDefault={resetForm}
>
<div class="px-4 sm:px-6 py-4 space-y-10 max-h-[calc(100dvh-12rem)] overflow-y-auto immich-scrollbar" tabindex="-1">
<!-- PEOPLE --> <!-- PEOPLE -->
<SearchPeopleSection bind:selectedPeople={filter.personIds} /> <SearchPeopleSection bind:selectedPeople={filter.personIds} />
<!-- TEXT --> <!-- TEXT -->
<SearchTextSection bind:filename={filter.filename} bind:context={filter.context} /> <SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- LOCATION --> <!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} /> <SearchLocationSection bind:filters={filter.location} />
@ -143,7 +142,7 @@
<!-- DATE RANGE --> <!-- DATE RANGE -->
<SearchDateSection bind:filters={filter.date} /> <SearchDateSection bind:filters={filter.date} />
<div class="grid md:grid-cols-2 gap-x-5 gap-y-8"> <div class="grid md:grid-cols-2 gap-x-5 gap-y-10">
<!-- MEDIA TYPE --> <!-- MEDIA TYPE -->
<SearchMediaSection bind:filteredMedia={filter.mediaType} /> <SearchMediaSection bind:filteredMedia={filter.mediaType} />
@ -151,13 +150,10 @@
<SearchDisplaySection bind:filters={filter.display} /> <SearchDisplaySection bind:filters={filter.display} />
</div> </div>
</div> </div>
<div
id="button-row"
class="flex justify-end gap-4 border-t dark:border-gray-800 dark:bg-immich-dark-gray px-4 sm:py-6 py-4 mt-2 rounded-b-3xl"
>
<Button type="reset" color="gray">{$t('clear_all')}</Button>
<Button type="submit">{$t('search')}</Button>
</div>
</form> </form>
</div>
<svelte:fragment slot="sticky-bottom">
<Button type="reset" color="gray" fullwidth form={formId}>{$t('clear_all')}</Button>
<Button type="submit" fullwidth form={formId}>{$t('search')}</Button>
</svelte:fragment>
</FullScreenModal>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import RadioButton from '$lib/components/elements/radio-button.svelte'; import RadioButton from '$lib/components/elements/radio-button.svelte';
import { MediaType } from './search-filter-box.svelte'; import { MediaType } from './search-filter-modal.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let filteredMedia: MediaType; export let filteredMedia: MediaType;

View File

@ -2,46 +2,25 @@
import RadioButton from '$lib/components/elements/radio-button.svelte'; import RadioButton from '$lib/components/elements/radio-button.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let filename: string | undefined; export let query: string | undefined;
export let context: string | undefined; export let queryType: 'smart' | 'metadata' = 'smart';
enum TextSearchOptions {
Context = 'context',
Filename = 'filename',
}
let selectedOption = filename ? TextSearchOptions.Filename : TextSearchOptions.Context;
$: {
if (selectedOption === TextSearchOptions.Context) {
filename = undefined;
} else {
context = undefined;
}
}
</script> </script>
<fieldset> <fieldset>
<legend class="immich-form-label">{$t('search_type')}</legend> <legend class="immich-form-label">{$t('search_type')}</legend>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2"> <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2">
<RadioButton <RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
name="query-type"
id="context-radio"
bind:group={selectedOption}
label={$t('context')}
value={TextSearchOptions.Context}
/>
<RadioButton <RadioButton
name="query-type" name="query-type"
id="file-name-radio" id="file-name-radio"
bind:group={selectedOption}
label={$t('file_name_or_extension')} label={$t('file_name_or_extension')}
value={TextSearchOptions.Filename} bind:group={queryType}
value="metadata"
/> />
</div> </div>
</fieldset> </fieldset>
{#if selectedOption === TextSearchOptions.Context} {#if queryType === 'smart'}
<label for="context-input" class="immich-form-label">{$t('search_by_context')}</label> <label for="context-input" class="immich-form-label">{$t('search_by_context')}</label>
<input <input
class="immich-form-input hover:cursor-text w-full !mt-1" class="immich-form-input hover:cursor-text w-full !mt-1"
@ -49,7 +28,7 @@
id="context-input" id="context-input"
name="context" name="context"
placeholder={$t('sunrise_on_the_beach')} placeholder={$t('sunrise_on_the_beach')}
bind:value={context} bind:value={query}
/> />
{:else} {:else}
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label> <label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
@ -59,7 +38,7 @@
id="file-name-input" id="file-name-input"
name="file-name" name="file-name"
placeholder={$t('search_by_filename_example')} placeholder={$t('search_by_filename_example')}
bind:value={filename} bind:value={query}
aria-labelledby="file-name-label" aria-labelledby="file-name-label"
/> />
{/if} {/if}

View File

@ -15,6 +15,7 @@
mdiLoading, mdiLoading,
mdiOpenInNew, mdiOpenInNew,
mdiRestart, mdiRestart,
mdiTrashCan,
} from '@mdi/js'; } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@ -29,6 +30,10 @@
uploadAssetsStore.removeItem(uploadAsset.id); uploadAssetsStore.removeItem(uploadAsset.id);
await fileUploadHandler([uploadAsset.file], uploadAsset.albumId); await fileUploadHandler([uploadAsset.file], uploadAsset.albumId);
}; };
const asLink = (asset: UploadAsset) => {
return asset.isTrashed ? `${AppRoute.TRASH}/${asset.assetId}` : `${AppRoute.PHOTOS}/${uploadAsset.assetId}`;
};
</script> </script>
<div <div
@ -45,7 +50,11 @@
{:else if uploadAsset.state === UploadState.ERROR} {:else if uploadAsset.state === UploadState.ERROR}
<Icon path={mdiAlertCircle} size="24" class="text-immich-error" title={$t('error')} /> <Icon path={mdiAlertCircle} size="24" class="text-immich-error" title={$t('error')} />
{:else if uploadAsset.state === UploadState.DUPLICATED} {:else if uploadAsset.state === UploadState.DUPLICATED}
<Icon path={mdiAlertCircle} size="24" class="text-immich-warning" title={$t('asset_skipped')} /> {#if uploadAsset.isTrashed}
<Icon path={mdiTrashCan} size="24" class="text-gray-500" title={$t('asset_skipped_in_trash')} />
{:else}
<Icon path={mdiAlertCircle} size="24" class="text-immich-warning" title={$t('asset_skipped')} />
{/if}
{:else if uploadAsset.state === UploadState.DONE} {:else if uploadAsset.state === UploadState.DONE}
<Icon path={mdiCheckCircle} size="24" class="text-immich-success" title={$t('asset_uploaded')} /> <Icon path={mdiCheckCircle} size="24" class="text-immich-success" title={$t('asset_uploaded')} />
{/if} {/if}
@ -56,7 +65,7 @@
{#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId} {#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId}
<div class="flex items-center justify-between gap-1"> <div class="flex items-center justify-between gap-1">
<a <a
href="{AppRoute.PHOTOS}/{uploadAsset.assetId}" href={asLink(uploadAsset)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="" class=""

View File

@ -387,6 +387,7 @@
"asset_offline": "Asset offline", "asset_offline": "Asset offline",
"asset_offline_description": "This asset is offline. Immich can not access its file location. Please ensure the asset is available and then rescan the library.", "asset_offline_description": "This asset is offline. Immich can not access its file location. Please ensure the asset is available and then rescan the library.",
"asset_skipped": "Skipped", "asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
"asset_uploaded": "Uploaded", "asset_uploaded": "Uploaded",
"asset_uploading": "Uploading...", "asset_uploading": "Uploading...",
"assets": "Assets", "assets": "Assets",
@ -784,6 +785,7 @@
"library_options": "Library options", "library_options": "Library options",
"light": "Light", "light": "Light",
"like_deleted": "Like deleted", "like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
"link_options": "Link options", "link_options": "Link options",
"link_to_oauth": "Link to OAuth", "link_to_oauth": "Link to OAuth",
"linked_oauth_account": "Linked OAuth account", "linked_oauth_account": "Linked OAuth account",
@ -1067,6 +1069,7 @@
"search_for_existing_person": "Search for existing person", "search_for_existing_person": "Search for existing person",
"search_no_people": "No people", "search_no_people": "No people",
"search_no_people_named": "No people named \"{name}\"", "search_no_people_named": "No people named \"{name}\"",
"search_options": "Search options",
"search_people": "Search people", "search_people": "Search people",
"search_places": "Search places", "search_places": "Search places",
"search_state": "Search state...", "search_state": "Search state...",

View File

@ -10,6 +10,7 @@ export type UploadAsset = {
id: string; id: string;
file: File; file: File;
assetId?: string; assetId?: string;
isTrashed?: boolean;
albumId?: string; albumId?: string;
progress?: number; progress?: number;
state?: UploadState; state?: UploadState;

View File

@ -6,6 +6,7 @@ import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void; export type OnDelete = (assetIds: string[]) => void;
export type OnRestore = (ids: string[]) => void; export type OnRestore = (ids: string[]) => void;
export type OnLink = (asset: AssetResponseDto) => void;
export type OnArchive = (ids: string[], isArchived: boolean) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (ids: string[]) => void; export type OnStack = (ids: string[]) => void;

View File

@ -15,7 +15,7 @@ import {
import { tick } from 'svelte'; import { tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { getServerErrorMessage, handleError } from './handle-error'; import { handleError } from './handle-error';
export const addDummyItems = () => { export const addDummyItems = () => {
uploadAssetsStore.addItem({ id: 'asset-0', file: { name: 'asset0.jpg', size: 123_456 } as File }); uploadAssetsStore.addItem({ id: 'asset-0', file: { name: 'asset0.jpg', size: 123_456 } as File });
@ -28,7 +28,9 @@ export const addDummyItems = () => {
uploadAssetsStore.addItem({ id: 'asset-3', file: { name: 'asset3.jpg', size: 123_456 } as File }); uploadAssetsStore.addItem({ id: 'asset-3', file: { name: 'asset3.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-3', { state: UploadState.DUPLICATED, assetId: 'asset-2' }); uploadAssetsStore.updateItem('asset-3', { state: UploadState.DUPLICATED, assetId: 'asset-2' });
uploadAssetsStore.addItem({ id: 'asset-4', file: { name: 'asset3.jpg', size: 123_456 } as File }); uploadAssetsStore.addItem({ id: 'asset-4', file: { name: 'asset3.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-4', { state: UploadState.DONE }); uploadAssetsStore.updateItem('asset-4', { state: UploadState.DUPLICATED, assetId: 'asset-2', isTrashed: true });
uploadAssetsStore.addItem({ id: 'asset-10', file: { name: 'asset3.jpg', size: 123_456 } as File });
uploadAssetsStore.updateItem('asset-10', { state: UploadState.DONE });
uploadAssetsStore.track('error'); uploadAssetsStore.track('error');
uploadAssetsStore.track('success'); uploadAssetsStore.track('success');
uploadAssetsStore.track('duplicate'); uploadAssetsStore.track('duplicate');
@ -122,7 +124,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
formData.append(key, value); formData.append(key, value);
} }
let responseData: AssetMediaResponseDto | undefined; let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined;
const key = getKey(); const key = getKey();
if (crypto?.subtle?.digest && !key) { if (crypto?.subtle?.digest && !key) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') }); uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') });
@ -138,7 +140,11 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
results: [checkUploadResult], results: [checkUploadResult],
} = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } }); } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } });
if (checkUploadResult.action === Action.Reject && checkUploadResult.assetId) { if (checkUploadResult.action === Action.Reject && checkUploadResult.assetId) {
responseData = { status: AssetMediaStatus.Duplicate, id: checkUploadResult.assetId }; responseData = {
status: AssetMediaStatus.Duplicate,
id: checkUploadResult.assetId,
isTrashed: checkUploadResult.isTrashed,
};
} }
} catch (error) { } catch (error) {
console.error(`Error calculating sha1 file=${assetFile.name})`, error); console.error(`Error calculating sha1 file=${assetFile.name})`, error);
@ -185,6 +191,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
uploadAssetsStore.updateItem(deviceAssetId, { uploadAssetsStore.updateItem(deviceAssetId, {
state: responseData.status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE, state: responseData.status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE,
assetId: responseData.id, assetId: responseData.id,
isTrashed: responseData.isTrashed,
}); });
if (responseData.status !== AssetMediaStatus.Duplicate) { if (responseData.status !== AssetMediaStatus.Duplicate) {
@ -195,10 +202,9 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
return responseData.id; return responseData.id;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_upload_file')); const errorMessage = handleError(error, $t('errors.unable_to_upload_file'));
const reason = getServerErrorMessage(error) || error;
uploadAssetsStore.track('error'); uploadAssetsStore.track('error');
uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: reason }); uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: errorMessage });
return; return;
} }
} }

View File

@ -20,11 +20,13 @@ export function handleError(error: unknown, message: string) {
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`; serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
} }
notificationController.show({ const errorMessage = serverMessage || message;
message: serverMessage || message,
type: NotificationType.Error, notificationController.show({ message: errorMessage, type: NotificationType.Error });
});
return errorMessage;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return message;
} }
} }

View File

@ -128,7 +128,7 @@
const assetInteractionStore = createAssetInteractionStore(); const assetInteractionStore = createAssetInteractionStore();
const { isMultiSelectState, selectedAssets } = assetInteractionStore; const { isMultiSelectState, selectedAssets } = assetInteractionStore;
$: timelineStore = new AssetStore({ isArchived: false }, albumId); $: timelineStore = new AssetStore({ isArchived: false, withPartners: true }, albumId);
const timelineInteractionStore = createAssetInteractionStore(); const timelineInteractionStore = createAssetInteractionStore();
const { selectedAssets: timelineSelected } = timelineInteractionStore; const { selectedAssets: timelineSelected } = timelineInteractionStore;

View File

@ -2,30 +2,32 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte'; import LinkLivePhotoAction from '$lib/components/photos-page/actions/link-live-photo-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte'; import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { AssetStore } from '$lib/stores/assets.store';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { AssetStore } from '$lib/stores/assets.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@ -51,6 +53,13 @@
} }
}; };
const handleLink = (asset: AssetResponseDto) => {
if (asset.livePhotoVideoId) {
assetStore.removeAssets([asset.livePhotoVideoId]);
}
assetStore.updateAssets([asset]);
};
onDestroy(() => { onDestroy(() => {
assetStore.destroy(); assetStore.destroy();
}); });
@ -78,6 +87,9 @@
onUnstack={(assets) => assetStore.addAssets(assets)} onUnstack={(assets) => assetStore.addAssets(assets)}
/> />
{/if} {/if}
{#if $selectedAssets.size === 2 && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Image && [...$selectedAssets].some((asset) => asset.type === AssetTypeEnum.Video))}
<LinkLivePhotoAction menuItem onLink={handleLink} />
{/if}
<ChangeDate menuItem /> <ChangeDate menuItem />
<ChangeLocation menuItem /> <ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} /> <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />