mirror of
https://github.com/immich-app/immich.git
synced 2025-07-07 18:26:50 -04:00
Merge branch 'main' of github.com:immich-app/immich into mobile/collections
This commit is contained in:
commit
c2e4d91b69
@ -1,16 +1,20 @@
|
||||
import {
|
||||
mdiBug,
|
||||
mdiCalendarToday,
|
||||
mdiCrosshairsOff,
|
||||
mdiDatabase,
|
||||
mdiLeadPencil,
|
||||
mdiLockOff,
|
||||
mdiLockOutline,
|
||||
mdiSecurity,
|
||||
mdiSpeedometerSlow,
|
||||
mdiTrashCan,
|
||||
mdiWeb,
|
||||
mdiWrap,
|
||||
} from '@mdi/js';
|
||||
import Layout from '@theme/Layout';
|
||||
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);
|
||||
|
||||
@ -96,6 +100,51 @@ const items: Item[] = [
|
||||
link: { url: 'https://github.com/immich-app/immich/pull/6787', text: '#6787' },
|
||||
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 {
|
||||
|
@ -545,6 +545,38 @@ describe('/asset', () => {
|
||||
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 () => {
|
||||
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
||||
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>
|
||||
|
@ -1,6 +1,6 @@
|
||||
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
|
||||
|
||||
@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
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
|
||||
|
||||
|
@ -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 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
52
machine-learning/poetry.lock
generated
52
machine-learning/poetry.lock
generated
@ -1963,36 +1963,36 @@ reference = ["Pillow", "google-re2"]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime"
|
||||
version = "1.19.0"
|
||||
version = "1.19.2"
|
||||
description = "ONNX Runtime is a runtime accelerator for Machine Learning models"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "onnxruntime-1.19.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6ce22a98dfec7b646ae305f52d0ce14a189a758b02ea501860ca719f4b0ae04b"},
|
||||
{file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19019c72873f26927aa322c54cf2bf7312b23451b27451f39b88f57016c94f8b"},
|
||||
{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.0-cp310-cp310-win32.whl", hash = "sha256:0eb0f8dbe596fd0f4737fe511fdbb17603853a7d204c5b2ca38d3c7808fc556b"},
|
||||
{file = "onnxruntime-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:616092d54ba8023b7bc0a5f6d900a07a37cc1cfcc631873c15f8c1d6e9e184d4"},
|
||||
{file = "onnxruntime-1.19.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a2b53b3c287cd933e5eb597273926e899082d8c84ab96e1b34035764a1627e17"},
|
||||
{file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e94984663963e74fbb468bde9ec6f19dcf890b594b35e249c4dc8789d08993c5"},
|
||||
{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.0-cp311-cp311-win32.whl", hash = "sha256:4ccb48faea02503275ae7e79e351434fc43c294c4cb5c4d8bcb7479061396614"},
|
||||
{file = "onnxruntime-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:9cdc8d311289a84e77722de68bd22b8adfb94eea26f4be6f9e017350faac8b18"},
|
||||
{file = "onnxruntime-1.19.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1b59eaec1be9a8613c5fdeaafe67f73a062edce3ac03bbbdc9e2d98b58a30617"},
|
||||
{file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4144d014a4b25184e63ce7a463a2e7796e2f3df931fccc6a6aefa6f1365dc5"},
|
||||
{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.0-cp312-cp312-win32.whl", hash = "sha256:87f2c58b577a1fb31dc5d92b647ecc588fd5f1ea0c3ad4526f5f80a113357c8d"},
|
||||
{file = "onnxruntime-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f50d49676d7b69566536ff039d9e4e95fc482a55673719f46528218ecbb94"},
|
||||
{file = "onnxruntime-1.19.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:71423c8c4b2d7a58956271534302ec72721c62a41efd0c4896343249b8399ab0"},
|
||||
{file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d63630d45e9498f96e75bbeb7fd4a56acb10155de0de4d0e18d1b6cbb0b358a"},
|
||||
{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.0-cp38-cp38-win32.whl", hash = "sha256:3b098003b6b4cb37cc84942e5f1fe27f945dd857cbd2829c824c26b0ba4a247e"},
|
||||
{file = "onnxruntime-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:cea067a6541d6787d903ee6843401c5b1332a266585160d9700f9f0939443886"},
|
||||
{file = "onnxruntime-1.19.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c4fcff12dc5ca963c5f76b9822bb404578fa4a98c281e8c666b429192799a099"},
|
||||
{file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6dcad8a4db908fbe70b98c79cea1c8b6ac3316adf4ce93453136e33a524ac59"},
|
||||
{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.0-cp39-cp39-win32.whl", hash = "sha256:947febd48405afcf526e45ccff97ff23b15e530434705f734870d22ae7fcf236"},
|
||||
{file = "onnxruntime-1.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:f60be47eff5ee77fd28a466b0fd41d7debc42a32179d1ddb21e05d6067d7b48b"},
|
||||
{file = "onnxruntime-1.19.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:84fa57369c06cadd3c2a538ae2a26d76d583e7c34bdecd5769d71ca5c0fc750e"},
|
||||
{file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc471a66df0c1cdef774accef69e9f2ca168c851ab5e4f2f3341512c7ef4666"},
|
||||
{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.2-cp310-cp310-win32.whl", hash = "sha256:4b3d723cc154c8ddeb9f6d0a8c0d6243774c6b5930847cc83170bfe4678fafb3"},
|
||||
{file = "onnxruntime-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:17ed7382d2c58d4b7354fb2b301ff30b9bf308a1c7eac9546449cd122d21cae5"},
|
||||
{file = "onnxruntime-1.19.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d863e8acdc7232d705d49e41087e10b274c42f09e259016a46f32c34e06dc4fd"},
|
||||
{file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dfe4f660a71b31caa81fc298a25f9612815215a47b286236e61d540350d7b6"},
|
||||
{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.2-cp311-cp311-win32.whl", hash = "sha256:50cbb8dc69d6befad4746a69760e5b00cc3ff0a59c6c3fb27f8afa20e2cab7e7"},
|
||||
{file = "onnxruntime-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:1c3e5d415b78337fa0b1b75291e9ea9fb2a4c1f148eb5811e7212fed02cfffa8"},
|
||||
{file = "onnxruntime-1.19.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:68e7051bef9cfefcbb858d2d2646536829894d72a4130c24019219442b1dd2ed"},
|
||||
{file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d366fbcc205ce68a8a3bde2185fd15c604d9645888703785b61ef174265168"},
|
||||
{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.2-cp312-cp312-win32.whl", hash = "sha256:9a174073dc5608fad05f7cf7f320b52e8035e73d80b0a23c80f840e5a97c0147"},
|
||||
{file = "onnxruntime-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:190103273ea4507638ffc31d66a980594b237874b65379e273125150eb044857"},
|
||||
{file = "onnxruntime-1.19.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636bc1d4cc051d40bc52e1f9da87fbb9c57d9d47164695dfb1c41646ea51ea66"},
|
||||
{file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bd8b875757ea941cbcfe01582970cc299893d1b65bd56731e326a8333f638a3"},
|
||||
{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.2-cp38-cp38-win32.whl", hash = "sha256:31c12840b1cde4ac1f7d27d540c44e13e34f2345cf3642762d2a3333621abb6a"},
|
||||
{file = "onnxruntime-1.19.2-cp38-cp38-win_amd64.whl", hash = "sha256:016229660adea180e9a32ce218b95f8f84860a200f0f13b50070d7d90e92956c"},
|
||||
{file = "onnxruntime-1.19.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:006c8d326835c017a9e9f74c9c77ebb570a71174a1e89fe078b29a557d9c3848"},
|
||||
{file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df2a94179a42d530b936f154615b54748239c2908ee44f0d722cb4df10670f68"},
|
||||
{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.2-cp39-cp39-win32.whl", hash = "sha256:dc5430f473e8706fff837ae01323be9dcfddd3ea471c900a91fa7c9b807ec5d3"},
|
||||
{file = "onnxruntime-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:38475e29a95c5f6c62c2c603d69fc7d4c6ccbf4df602bd567b86ae1138881c49"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
@ -414,7 +414,7 @@
|
||||
"search_filter_people_title": "Select people",
|
||||
"search_page_categories": "Categorías",
|
||||
"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_places": "No hay información de lugares disponible",
|
||||
"search_page_people": "Personas",
|
||||
@ -589,4 +589,4 @@
|
||||
"viewer_remove_from_stack": "Eliminar de la pila",
|
||||
"viewer_stack_use_as_main_asset": "Utilizar como recurso principal",
|
||||
"viewer_unstack": "Desapilar"
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ class AssetBulkUploadCheckResult {
|
||||
required this.action,
|
||||
this.assetId,
|
||||
required this.id,
|
||||
this.isTrashed,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
@ -31,6 +32,14 @@ class AssetBulkUploadCheckResult {
|
||||
|
||||
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;
|
||||
|
||||
@override
|
||||
@ -38,6 +47,7 @@ class AssetBulkUploadCheckResult {
|
||||
other.action == action &&
|
||||
other.assetId == assetId &&
|
||||
other.id == id &&
|
||||
other.isTrashed == isTrashed &&
|
||||
other.reason == reason;
|
||||
|
||||
@override
|
||||
@ -46,10 +56,11 @@ class AssetBulkUploadCheckResult {
|
||||
(action.hashCode) +
|
||||
(assetId == null ? 0 : assetId!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isTrashed == null ? 0 : isTrashed!.hashCode) +
|
||||
(reason == null ? 0 : reason!.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -60,6 +71,11 @@ class AssetBulkUploadCheckResult {
|
||||
// json[r'assetId'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
if (this.isTrashed != null) {
|
||||
json[r'isTrashed'] = this.isTrashed;
|
||||
} else {
|
||||
// json[r'isTrashed'] = null;
|
||||
}
|
||||
if (this.reason != null) {
|
||||
json[r'reason'] = this.reason;
|
||||
} else {
|
||||
@ -79,6 +95,7 @@ class AssetBulkUploadCheckResult {
|
||||
action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!,
|
||||
assetId: mapValueOfType<String>(json, r'assetId'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isTrashed: mapValueOfType<bool>(json, r'isTrashed'),
|
||||
reason: AssetBulkUploadCheckResultReasonEnum.fromJson(json[r'reason']),
|
||||
);
|
||||
}
|
||||
|
19
mobile/openapi/lib/model/update_asset_dto.dart
generated
19
mobile/openapi/lib/model/update_asset_dto.dart
generated
@ -18,6 +18,7 @@ class UpdateAssetDto {
|
||||
this.isArchived,
|
||||
this.isFavorite,
|
||||
this.latitude,
|
||||
this.livePhotoVideoId,
|
||||
this.longitude,
|
||||
this.rating,
|
||||
});
|
||||
@ -62,6 +63,14 @@ class UpdateAssetDto {
|
||||
///
|
||||
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
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@ -87,6 +96,7 @@ class UpdateAssetDto {
|
||||
other.isArchived == isArchived &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.latitude == latitude &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.longitude == longitude &&
|
||||
other.rating == rating;
|
||||
|
||||
@ -98,11 +108,12 @@ class UpdateAssetDto {
|
||||
(isArchived == null ? 0 : isArchived!.hashCode) +
|
||||
(isFavorite == null ? 0 : isFavorite!.hashCode) +
|
||||
(latitude == null ? 0 : latitude!.hashCode) +
|
||||
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
|
||||
(longitude == null ? 0 : longitude!.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -131,6 +142,11 @@ class UpdateAssetDto {
|
||||
} else {
|
||||
// json[r'latitude'] = null;
|
||||
}
|
||||
if (this.livePhotoVideoId != null) {
|
||||
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||
} else {
|
||||
// json[r'livePhotoVideoId'] = null;
|
||||
}
|
||||
if (this.longitude != null) {
|
||||
json[r'longitude'] = this.longitude;
|
||||
} else {
|
||||
@ -157,6 +173,7 @@ class UpdateAssetDto {
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
latitude: num.parse('${json[r'latitude']}'),
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
longitude: num.parse('${json[r'longitude']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
);
|
||||
|
@ -7928,6 +7928,9 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isTrashed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"duplicate",
|
||||
@ -12238,6 +12241,10 @@
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"livePhotoVideoId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
},
|
||||
|
@ -395,6 +395,7 @@ export type AssetBulkUploadCheckResult = {
|
||||
action: Action;
|
||||
assetId?: string;
|
||||
id: string;
|
||||
isTrashed?: boolean;
|
||||
reason?: Reason;
|
||||
};
|
||||
export type AssetBulkUploadCheckResponseDto = {
|
||||
@ -426,6 +427,7 @@ export type UpdateAssetDto = {
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
latitude?: number;
|
||||
livePhotoVideoId?: string;
|
||||
longitude?: number;
|
||||
rating?: number;
|
||||
};
|
||||
|
@ -301,7 +301,7 @@ export class StorageCore {
|
||||
return this.assetRepository.update({ id, sidecarPath: newPath });
|
||||
}
|
||||
case PersonPathType.FACE: {
|
||||
return this.personRepository.update([{ id, thumbnailPath: newPath }]);
|
||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ export class AssetBulkUploadCheckResult {
|
||||
action!: AssetUploadAction;
|
||||
reason?: AssetRejectReason;
|
||||
assetId?: string;
|
||||
isTrashed?: boolean;
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckResponseDto {
|
||||
|
@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase {
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
livePhotoVideoId?: string;
|
||||
}
|
||||
|
||||
export class RandomAssetsDto {
|
||||
|
@ -17,9 +17,10 @@ type EmitEventMap = {
|
||||
'album.update': [{ id: string; updatedBy: string }];
|
||||
'album.invite': [{ id: string; userId: string }];
|
||||
|
||||
// tag events
|
||||
// asset events
|
||||
'asset.tag': [{ assetId: string }];
|
||||
'asset.untag': [{ assetId: string }];
|
||||
'asset.hide': [{ assetId: string; userId: string }];
|
||||
|
||||
// session events
|
||||
'session.delete': [{ sessionId: string }];
|
||||
|
@ -54,7 +54,8 @@ export interface IPersonRepository {
|
||||
|
||||
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[]>;
|
||||
delete(entities: PersonEntity[]): Promise<void>;
|
||||
deleteAll(): Promise<void>;
|
||||
@ -74,6 +75,7 @@ export interface IPersonRepository {
|
||||
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
||||
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
|
||||
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>;
|
||||
}
|
||||
|
@ -493,6 +493,7 @@ LIMIT
|
||||
-- AssetRepository.getByChecksums
|
||||
SELECT
|
||||
"AssetEntity"."id" AS "AssetEntity_id",
|
||||
"AssetEntity"."deletedAt" AS "AssetEntity_deletedAt",
|
||||
"AssetEntity"."checksum" AS "AssetEntity_checksum"
|
||||
FROM
|
||||
"assets" "AssetEntity"
|
||||
|
@ -8,7 +8,7 @@ FROM
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $1
|
||||
"asset"."ownerId" IN ($1)
|
||||
|
||||
-- MetadataRepository.getStates
|
||||
SELECT DISTINCT
|
||||
@ -18,7 +18,7 @@ FROM
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $1
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."country" = $2
|
||||
|
||||
-- MetadataRepository.getCities
|
||||
@ -29,7 +29,7 @@ FROM
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $1
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."country" = $2
|
||||
AND "exif"."state" = $3
|
||||
|
||||
@ -41,7 +41,7 @@ FROM
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $1
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."model" = $2
|
||||
|
||||
-- MetadataRepository.getCameraModels
|
||||
@ -52,5 +52,5 @@ FROM
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $1
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."make" = $2
|
||||
|
@ -338,6 +338,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
select: {
|
||||
id: true,
|
||||
checksum: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
where: {
|
||||
ownerId,
|
||||
|
@ -55,7 +55,7 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
async getCountries(userIds: string[]): Promise<string[]> {
|
||||
const results = await this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
@ -68,7 +68,7 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
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[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
@ -86,7 +86,7 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
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[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
@ -108,7 +108,7 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
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[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
@ -125,7 +125,7 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
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[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
|
@ -280,8 +280,13 @@ export class PersonRepository implements IPersonRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
create(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
|
||||
return this.personRepository.save(entities);
|
||||
create(person: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||
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[]> {
|
||||
@ -297,8 +302,12 @@ export class PersonRepository implements IPersonRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async update(entities: Partial<PersonEntity>[]): Promise<PersonEntity[]> {
|
||||
return await this.personRepository.save(entities);
|
||||
async update(person: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||
return this.save(person);
|
||||
}
|
||||
|
||||
async updateAll(people: Partial<PersonEntity>[]): Promise<void> {
|
||||
await this.personRepository.save(people);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
||||
@ -320,4 +329,9 @@ export class PersonRepository implements IPersonRepository {
|
||||
.getRawOne();
|
||||
return result?.latestDate;
|
||||
}
|
||||
|
||||
private async save(person: Partial<PersonEntity>): Promise<PersonEntity> {
|
||||
const { id } = await this.personRepository.save(person);
|
||||
return this.personRepository.findOneByOrFail({ id });
|
||||
}
|
||||
}
|
||||
|
@ -589,8 +589,20 @@ describe(AssetMediaService.name, () => {
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -36,7 +36,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
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 { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
@ -158,20 +158,10 @@ export class AssetMediaService {
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetRepository.getById(dto.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 !== 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);
|
||||
}
|
||||
await onBeforeLink(
|
||||
{ asset: this.assetRepository, event: this.eventRepository },
|
||||
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
|
||||
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) {
|
||||
checksumMap[checksum.toString('hex')] = id;
|
||||
for (const { id, deletedAt, checksum } of results) {
|
||||
checksumMap[checksum.toString('hex')] = { id, isTrashed: !!deletedAt };
|
||||
}
|
||||
|
||||
return {
|
||||
@ -301,14 +291,13 @@ export class AssetMediaService {
|
||||
if (duplicate) {
|
||||
return {
|
||||
id,
|
||||
assetId: duplicate,
|
||||
action: AssetUploadAction.REJECT,
|
||||
reason: AssetRejectReason.DUPLICATE,
|
||||
assetId: duplicate.id,
|
||||
isTrashed: duplicate.isTrashed,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO mime-check
|
||||
|
||||
return {
|
||||
id,
|
||||
action: AssetUploadAction.ACCEPT,
|
||||
|
@ -39,7 +39,7 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
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';
|
||||
|
||||
export class AssetService {
|
||||
@ -159,6 +159,14 @@ export class AssetService {
|
||||
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
|
||||
|
||||
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.assetRepository.update({ id, ...rest });
|
||||
|
@ -115,7 +115,7 @@ export class AuditService {
|
||||
}
|
||||
|
||||
case PersonPathType.FACE: {
|
||||
await this.personRepository.update([{ id, thumbnailPath: pathValue }]);
|
||||
await this.personRepository.update({ id, thumbnailPath: pathValue });
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -117,7 +117,7 @@ export class MediaService {
|
||||
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 } });
|
||||
|
@ -8,7 +8,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.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 { ILoggerRepository } from 'src/interfaces/logger.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(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith(
|
||||
ClientEvent.ASSET_HIDDEN,
|
||||
assetStub.livePhotoMotionAsset.ownerId,
|
||||
assetStub.livePhotoMotionAsset.id,
|
||||
);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', {
|
||||
userId: assetStub.livePhotoMotionAsset.ownerId,
|
||||
assetId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
@ -993,13 +1002,12 @@ describe(MetadataService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName);
|
||||
personMock.getDistinctNames.mockResolvedValue([]);
|
||||
personMock.create.mockResolvedValue([]);
|
||||
personMock.createAll.mockResolvedValue([]);
|
||||
personMock.replaceFaces.mockResolvedValue([]);
|
||||
personMock.update.mockResolvedValue([]);
|
||||
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.update).toHaveBeenCalledWith([]);
|
||||
expect(personMock.updateAll).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should skip importing faces with empty name', async () => {
|
||||
@ -1007,13 +1015,12 @@ describe(MetadataService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName);
|
||||
personMock.getDistinctNames.mockResolvedValue([]);
|
||||
personMock.create.mockResolvedValue([]);
|
||||
personMock.createAll.mockResolvedValue([]);
|
||||
personMock.replaceFaces.mockResolvedValue([]);
|
||||
personMock.update.mockResolvedValue([]);
|
||||
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.update).toHaveBeenCalledWith([]);
|
||||
expect(personMock.updateAll).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should apply metadata face tags creating new persons', async () => {
|
||||
@ -1021,13 +1028,13 @@ describe(MetadataService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
|
||||
personMock.getDistinctNames.mockResolvedValue([]);
|
||||
personMock.create.mockResolvedValue([personStub.withName]);
|
||||
personMock.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
|
||||
personMock.update.mockResolvedValue([personStub.withName]);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
|
||||
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(
|
||||
assetStub.primaryImage.id,
|
||||
[
|
||||
@ -1046,7 +1053,7 @@ describe(MetadataService.name, () => {
|
||||
],
|
||||
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([
|
||||
{
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
@ -1060,13 +1067,13 @@ describe(MetadataService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
metadataMock.readTags.mockResolvedValue(metadataStub.withFace);
|
||||
personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
|
||||
personMock.create.mockResolvedValue([]);
|
||||
personMock.createAll.mockResolvedValue([]);
|
||||
personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']);
|
||||
personMock.update.mockResolvedValue([personStub.withName]);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]);
|
||||
expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
|
||||
expect(personMock.create).toHaveBeenCalledWith([]);
|
||||
expect(personMock.createAll).toHaveBeenCalledWith([]);
|
||||
expect(personMock.replaceFaces).toHaveBeenCalledWith(
|
||||
assetStub.primaryImage.id,
|
||||
[
|
||||
@ -1085,7 +1092,7 @@ describe(MetadataService.name, () => {
|
||||
],
|
||||
SourceType.EXIF,
|
||||
);
|
||||
expect(personMock.update).toHaveBeenCalledWith([]);
|
||||
expect(personMock.updateAll).toHaveBeenCalledWith([]);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
@ -17,7 +17,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.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 {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
@ -186,8 +186,7 @@ export class MetadataService {
|
||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||
await this.albumRepository.removeAsset(motionAsset.id);
|
||||
|
||||
// Notify clients to hide the linked live photo asset
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
|
||||
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@ -428,7 +427,7 @@ export class MetadataService {
|
||||
|
||||
if (isMotionPhoto && directory) {
|
||||
for (const entry of directory) {
|
||||
if (entry.Item.Semantic == 'MotionPhoto') {
|
||||
if (entry?.Item?.Semantic == 'MotionPhoto') {
|
||||
length = entry.Item.Length ?? 0;
|
||||
padding = entry.Item.Padding ?? 0;
|
||||
break;
|
||||
@ -585,18 +584,15 @@ export class MetadataService {
|
||||
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);
|
||||
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(
|
||||
newPersons.map((person) => ({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: person.id },
|
||||
})),
|
||||
newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } })),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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' })
|
||||
async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) {
|
||||
if (notify) {
|
||||
|
@ -241,18 +241,18 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it("should update a person's name", async () => {
|
||||
personMock.update.mockResolvedValue([personStub.withName]);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
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']));
|
||||
});
|
||||
|
||||
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]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
@ -264,25 +264,25 @@ describe(PersonService.name, () => {
|
||||
isHidden: false,
|
||||
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.queueAll).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should update a person visibility', async () => {
|
||||
personMock.update.mockResolvedValue([personStub.withName]);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
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']));
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
personMock.update.mockResolvedValue([personStub.withName]);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
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 }),
|
||||
).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([
|
||||
{
|
||||
assetId: faceStub.face1.assetId,
|
||||
@ -441,11 +441,11 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('createPerson', () => {
|
||||
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);
|
||||
|
||||
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 } } });
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue([faceStub.primaryFace1.person]);
|
||||
personMock.create.mockResolvedValue(faceStub.primaryFace1.person);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
|
||||
@ -844,16 +844,14 @@ describe(PersonService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } });
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue([personStub.withName]);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
|
||||
expect(personMock.create).toHaveBeenCalledWith([
|
||||
{
|
||||
ownerId: faceStub.noPerson1.asset.ownerId,
|
||||
faceAssetId: faceStub.noPerson1.id,
|
||||
},
|
||||
]);
|
||||
expect(personMock.create).toHaveBeenCalledWith({
|
||||
ownerId: faceStub.noPerson1.asset.ownerId,
|
||||
faceAssetId: faceStub.noPerson1.id,
|
||||
});
|
||||
expect(personMock.reassignFaces).toHaveBeenCalledWith({
|
||||
faceIds: [faceStub.noPerson1.id],
|
||||
newPersonId: personStub.withName.id,
|
||||
@ -865,7 +863,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue([personStub.withName]);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
|
||||
@ -884,7 +882,7 @@ describe(PersonService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
searchMock.searchFaces.mockResolvedValue(faces);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue([personStub.withName]);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id });
|
||||
|
||||
@ -906,7 +904,7 @@ describe(PersonService.name, () => {
|
||||
systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } });
|
||||
searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1);
|
||||
personMock.create.mockResolvedValue([personStub.withName]);
|
||||
personMock.create.mockResolvedValue(personStub.withName);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true });
|
||||
|
||||
@ -979,12 +977,10 @@ describe(PersonService.name, () => {
|
||||
processInvalidImages: false,
|
||||
},
|
||||
);
|
||||
expect(personMock.update).toHaveBeenCalledWith([
|
||||
{
|
||||
id: 'person-1',
|
||||
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
|
||||
},
|
||||
]);
|
||||
expect(personMock.update).toHaveBeenCalledWith({
|
||||
id: 'person-1',
|
||||
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
personMock.getById.mockResolvedValueOnce(personStub.randomPerson);
|
||||
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-1']));
|
||||
|
||||
@ -1116,12 +1112,10 @@ describe(PersonService.name, () => {
|
||||
oldPersonId: personStub.primaryPerson.id,
|
||||
});
|
||||
|
||||
expect(personMock.update).toHaveBeenCalledWith([
|
||||
{
|
||||
id: personStub.randomPerson.id,
|
||||
name: personStub.primaryPerson.name,
|
||||
},
|
||||
]);
|
||||
expect(personMock.update).toHaveBeenCalledWith({
|
||||
id: personStub.randomPerson.id,
|
||||
name: personStub.primaryPerson.name,
|
||||
});
|
||||
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
@ -173,7 +173,7 @@ export class PersonService {
|
||||
const assetFace = await this.repository.getRandomFace(personId);
|
||||
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
@ -211,16 +211,13 @@ export class PersonService {
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
const [created] = await this.repository.create([
|
||||
{
|
||||
ownerId: auth.user.id,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
},
|
||||
]);
|
||||
return created;
|
||||
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
|
||||
return this.repository.create({
|
||||
ownerId: auth.user.id,
|
||||
name: dto.name,
|
||||
birthDate: dto.birthDate,
|
||||
isHidden: dto.isHidden,
|
||||
});
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||
@ -239,7 +236,7 @@ export class PersonService {
|
||||
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) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||
@ -501,7 +498,7 @@ export class PersonService {
|
||||
|
||||
if (isCore && !personId) {
|
||||
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 } });
|
||||
personId = newPerson.id;
|
||||
}
|
||||
@ -577,7 +574,7 @@ export class PersonService {
|
||||
} as const;
|
||||
|
||||
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;
|
||||
}
|
||||
@ -624,7 +621,7 @@ export class PersonService {
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
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 { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
@ -130,3 +133,24 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
@ -13,9 +13,11 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
|
||||
getDistinctNames: vitest.fn(),
|
||||
|
||||
create: vitest.fn(),
|
||||
createAll: vitest.fn(),
|
||||
update: vitest.fn(),
|
||||
deleteAll: vitest.fn(),
|
||||
updateAll: vitest.fn(),
|
||||
delete: vitest.fn(),
|
||||
deleteAll: vitest.fn(),
|
||||
deleteAllFaces: vitest.fn(),
|
||||
|
||||
getStatistics: vitest.fn(),
|
||||
|
13
web/package-lock.json
generated
13
web/package-lock.json
generated
@ -33,7 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.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",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.3.0",
|
||||
@ -746,9 +746,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@faker-js/faker": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz",
|
||||
"integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==",
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.0.tgz",
|
||||
"integrity": "sha512-dTDHJSmz6c1OJ6HO7jiUiIb4sB20Dlkb3pxYsKm0qTXm2Bmj97rlXIhlvaFsW2rvCi+OLlwKLVSS6ZxFUVZvjQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -756,9 +756,10 @@
|
||||
"url": "https://opencollective.com/fakerjs"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0",
|
||||
"npm": ">=6.14.13"
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
|
@ -25,7 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.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",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.3.0",
|
||||
|
@ -331,8 +331,10 @@
|
||||
locale: $locale,
|
||||
})
|
||||
: DateTime.now()}
|
||||
{@const assetTimeZoneOriginal = asset.exifInfo?.timeZone ?? ''}
|
||||
<ChangeDate
|
||||
initialDate={assetDateTimeOriginal}
|
||||
initialTimeZone={assetTimeZoneOriginal}
|
||||
on:confirm={({ detail: date }) => handleConfirmChangeDate(date)}
|
||||
on:cancel={() => (isShowChangeDate = false)}
|
||||
/>
|
||||
|
@ -11,7 +11,7 @@
|
||||
|
||||
let showAlbumPicker = false;
|
||||
|
||||
const { getAssets, clearSelect } = getAssetControlContext();
|
||||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const handleHideAlbumPicker = () => {
|
||||
showAlbumPicker = false;
|
||||
@ -28,7 +28,6 @@
|
||||
showAlbumPicker = false;
|
||||
const assetIds = [...getAssets()].map((asset) => asset.id);
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
clearSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -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}
|
@ -7,6 +7,7 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let initialDate: DateTime = DateTime.now();
|
||||
export let initialTimeZone: string = '';
|
||||
|
||||
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)"
|
||||
* instead of just the raw offset or something like "UTC+02:00".
|
||||
@ -97,6 +98,7 @@
|
||||
) {
|
||||
const offset = date.offset;
|
||||
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 firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
|
||||
const utcFallback = {
|
||||
@ -105,7 +107,7 @@
|
||||
value: 'UTC',
|
||||
valid: true,
|
||||
};
|
||||
return previousSelection ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
|
||||
return previousSelection ?? fromInitialTimeZone ?? sameAsUserTimeZone ?? firstWithSameOffset ?? utcFallback;
|
||||
}
|
||||
|
||||
function sortTwoZones(zoneA: ZoneOption, zoneB: ZoneOption) {
|
||||
|
@ -22,7 +22,7 @@
|
||||
* - `narrow`: 28rem
|
||||
* - `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.
|
||||
@ -34,12 +34,25 @@
|
||||
|
||||
let modalWidth: string;
|
||||
$: {
|
||||
if (width === 'wide') {
|
||||
modalWidth = 'w-[48rem]';
|
||||
} else if (width === 'narrow') {
|
||||
modalWidth = 'w-[28rem]';
|
||||
} else {
|
||||
modalWidth = 'sm:max-w-4xl';
|
||||
switch (width) {
|
||||
case 'extra-wide': {
|
||||
modalWidth = 'w-[56rem]';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'wide': {
|
||||
modalWidth = 'w-[48rem]';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'narrow': {
|
||||
modalWidth = 'w-[28rem]';
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
modalWidth = 'sm:max-w-4xl';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -55,24 +68,28 @@
|
||||
use:focusTrap
|
||||
>
|
||||
<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 }}
|
||||
tabindex="-1"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
class:scroll-pb-40={isStickyBottom}
|
||||
class:sm:scroll-p-24={isStickyBottom}
|
||||
>
|
||||
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
||||
<div class="p-5 pt-0">
|
||||
<slot />
|
||||
</div>
|
||||
{#if isStickyBottom}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<slot name="sticky-bottom" />
|
||||
<div
|
||||
class="immich-scrollbar overflow-y-auto max-h-[min(92dvh,64rem)]"
|
||||
class:scroll-pb-40={isStickyBottom}
|
||||
class:sm:scroll-p-24={isStickyBottom}
|
||||
>
|
||||
<ModalHeader id={titleId} {title} {showLogo} {icon} {onClose} />
|
||||
<div class="px-5 pt-0" class:pb-5={isStickyBottom}>
|
||||
<slot />
|
||||
</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>
|
||||
</section>
|
||||
|
@ -21,12 +21,12 @@
|
||||
export let icon: string | undefined = undefined;
|
||||
</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">
|
||||
{#if showLogo}
|
||||
<ImmichLogo noText={true} width={32} />
|
||||
{: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}
|
||||
<h1 {id}>
|
||||
{title}
|
||||
|
@ -4,7 +4,7 @@
|
||||
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
|
||||
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
|
||||
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 { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
@ -160,8 +160,8 @@
|
||||
id="main-search-bar"
|
||||
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'}
|
||||
{(showSuggestions && isSearchSuggestions) || showFilter ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
|
||||
{$isSearchEnabled ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
|
||||
{showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'}
|
||||
{$isSearchEnabled && !showFilter ? 'border-gray-200 dark:border-gray-700 bg-white' : 'border-transparent'}"
|
||||
placeholder={$t('search_your_photos')}
|
||||
required
|
||||
pattern="^(?!m:$).*$"
|
||||
@ -215,6 +215,6 @@
|
||||
</form>
|
||||
|
||||
{#if showFilter}
|
||||
<SearchFilterBox {searchQuery} on:search={({ detail }) => onSearch(detail)} />
|
||||
<SearchFilterModal {searchQuery} onSearch={(payload) => onSearch(payload)} onClose={() => (showFilter = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -10,8 +10,8 @@
|
||||
}
|
||||
|
||||
export type SearchFilter = {
|
||||
context?: string;
|
||||
filename?: string;
|
||||
query: string;
|
||||
queryType: 'smart' | 'metadata';
|
||||
personIds: Set<string>;
|
||||
location: SearchLocationFilter;
|
||||
camera: SearchCameraFilter;
|
||||
@ -24,8 +24,6 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
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 SearchLocationSection from './search-location-section.svelte';
|
||||
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
|
||||
@ -35,12 +33,17 @@
|
||||
import SearchDisplaySection from './search-display-section.svelte';
|
||||
import SearchTextSection from './search-text-section.svelte';
|
||||
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 onClose: () => void;
|
||||
export let onSearch: (search: SmartSearchDto | MetadataSearchDto) => void;
|
||||
|
||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : 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.
|
||||
function withNullAsUndefined<T>(value: T | null) {
|
||||
@ -48,8 +51,8 @@
|
||||
}
|
||||
|
||||
let filter: SearchFilter = {
|
||||
context: 'query' in searchQuery ? searchQuery.query : '',
|
||||
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined,
|
||||
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
|
||||
queryType: 'query' in searchQuery ? 'smart' : 'metadata',
|
||||
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||
location: {
|
||||
country: withNullAsUndefined(searchQuery.country),
|
||||
@ -79,6 +82,8 @@
|
||||
|
||||
const resetForm = () => {
|
||||
filter = {
|
||||
query: '',
|
||||
queryType: 'smart',
|
||||
personIds: new Set(),
|
||||
location: {},
|
||||
camera: {},
|
||||
@ -96,9 +101,11 @@
|
||||
type = AssetTypeEnum.Video;
|
||||
}
|
||||
|
||||
const query = filter.query || undefined;
|
||||
|
||||
let payload: SmartSearchDto | MetadataSearchDto = {
|
||||
query: filter.context || undefined,
|
||||
originalFileName: filter.filename,
|
||||
query: filter.queryType === 'smart' ? query : undefined,
|
||||
originalFileName: filter.queryType === 'metadata' ? query : undefined,
|
||||
country: filter.location.country,
|
||||
state: filter.location.state,
|
||||
city: filter.location.city,
|
||||
@ -113,26 +120,18 @@
|
||||
type,
|
||||
};
|
||||
|
||||
dispatch('search', payload);
|
||||
onSearch(payload);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
transition:fly={{ y: 25, duration: 250 }}
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>
|
||||
<form id={formId} autocomplete="off" on:submit|preventDefault={search} on:reset|preventDefault={resetForm}>
|
||||
<div class="space-y-10 pb-10" tabindex="-1">
|
||||
<!-- PEOPLE -->
|
||||
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
|
||||
|
||||
<!-- TEXT -->
|
||||
<SearchTextSection bind:filename={filter.filename} bind:context={filter.context} />
|
||||
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
|
||||
|
||||
<!-- LOCATION -->
|
||||
<SearchLocationSection bind:filters={filter.location} />
|
||||
@ -143,7 +142,7 @@
|
||||
<!-- DATE RANGE -->
|
||||
<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 -->
|
||||
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
|
||||
|
||||
@ -151,13 +150,10 @@
|
||||
<SearchDisplaySection bind:filters={filter.display} />
|
||||
</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>
|
||||
</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>
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
export let filteredMedia: MediaType;
|
||||
|
@ -2,46 +2,25 @@
|
||||
import RadioButton from '$lib/components/elements/radio-button.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export let filename: string | undefined;
|
||||
export let context: string | undefined;
|
||||
|
||||
enum TextSearchOptions {
|
||||
Context = 'context',
|
||||
Filename = 'filename',
|
||||
}
|
||||
|
||||
let selectedOption = filename ? TextSearchOptions.Filename : TextSearchOptions.Context;
|
||||
|
||||
$: {
|
||||
if (selectedOption === TextSearchOptions.Context) {
|
||||
filename = undefined;
|
||||
} else {
|
||||
context = undefined;
|
||||
}
|
||||
}
|
||||
export let query: string | undefined;
|
||||
export let queryType: 'smart' | 'metadata' = 'smart';
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<legend class="immich-form-label">{$t('search_type')}</legend>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2">
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="context-radio"
|
||||
bind:group={selectedOption}
|
||||
label={$t('context')}
|
||||
value={TextSearchOptions.Context}
|
||||
/>
|
||||
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
|
||||
<RadioButton
|
||||
name="query-type"
|
||||
id="file-name-radio"
|
||||
bind:group={selectedOption}
|
||||
label={$t('file_name_or_extension')}
|
||||
value={TextSearchOptions.Filename}
|
||||
bind:group={queryType}
|
||||
value="metadata"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if selectedOption === TextSearchOptions.Context}
|
||||
{#if queryType === 'smart'}
|
||||
<label for="context-input" class="immich-form-label">{$t('search_by_context')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full !mt-1"
|
||||
@ -49,7 +28,7 @@
|
||||
id="context-input"
|
||||
name="context"
|
||||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={context}
|
||||
bind:value={query}
|
||||
/>
|
||||
{:else}
|
||||
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
|
||||
@ -59,7 +38,7 @@
|
||||
id="file-name-input"
|
||||
name="file-name"
|
||||
placeholder={$t('search_by_filename_example')}
|
||||
bind:value={filename}
|
||||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
{/if}
|
||||
|
@ -15,6 +15,7 @@
|
||||
mdiLoading,
|
||||
mdiOpenInNew,
|
||||
mdiRestart,
|
||||
mdiTrashCan,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@ -29,6 +30,10 @@
|
||||
uploadAssetsStore.removeItem(uploadAsset.id);
|
||||
await fileUploadHandler([uploadAsset.file], uploadAsset.albumId);
|
||||
};
|
||||
|
||||
const asLink = (asset: UploadAsset) => {
|
||||
return asset.isTrashed ? `${AppRoute.TRASH}/${asset.assetId}` : `${AppRoute.PHOTOS}/${uploadAsset.assetId}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
@ -45,7 +50,11 @@
|
||||
{:else if uploadAsset.state === UploadState.ERROR}
|
||||
<Icon path={mdiAlertCircle} size="24" class="text-immich-error" title={$t('error')} />
|
||||
{: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}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-immich-success" title={$t('asset_uploaded')} />
|
||||
{/if}
|
||||
@ -56,7 +65,7 @@
|
||||
{#if uploadAsset.state === UploadState.DUPLICATED && uploadAsset.assetId}
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<a
|
||||
href="{AppRoute.PHOTOS}/{uploadAsset.assetId}"
|
||||
href={asLink(uploadAsset)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class=""
|
||||
|
@ -387,6 +387,7 @@
|
||||
"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_skipped": "Skipped",
|
||||
"asset_skipped_in_trash": "In trash",
|
||||
"asset_uploaded": "Uploaded",
|
||||
"asset_uploading": "Uploading...",
|
||||
"assets": "Assets",
|
||||
@ -784,6 +785,7 @@
|
||||
"library_options": "Library options",
|
||||
"light": "Light",
|
||||
"like_deleted": "Like deleted",
|
||||
"link_motion_video": "Link motion video",
|
||||
"link_options": "Link options",
|
||||
"link_to_oauth": "Link to OAuth",
|
||||
"linked_oauth_account": "Linked OAuth account",
|
||||
@ -1067,6 +1069,7 @@
|
||||
"search_for_existing_person": "Search for existing person",
|
||||
"search_no_people": "No people",
|
||||
"search_no_people_named": "No people named \"{name}\"",
|
||||
"search_options": "Search options",
|
||||
"search_people": "Search people",
|
||||
"search_places": "Search places",
|
||||
"search_state": "Search state...",
|
||||
|
@ -10,6 +10,7 @@ export type UploadAsset = {
|
||||
id: string;
|
||||
file: File;
|
||||
assetId?: string;
|
||||
isTrashed?: boolean;
|
||||
albumId?: string;
|
||||
progress?: number;
|
||||
state?: UploadState;
|
||||
|
@ -6,6 +6,7 @@ import { handleError } from './handle-error';
|
||||
|
||||
export type OnDelete = (assetIds: string[]) => void;
|
||||
export type OnRestore = (ids: string[]) => void;
|
||||
export type OnLink = (asset: AssetResponseDto) => void;
|
||||
export type OnArchive = (ids: string[], isArchived: boolean) => void;
|
||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||
export type OnStack = (ids: string[]) => void;
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { getServerErrorMessage, handleError } from './handle-error';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export const addDummyItems = () => {
|
||||
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.updateItem('asset-3', { state: UploadState.DUPLICATED, assetId: 'asset-2' });
|
||||
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('success');
|
||||
uploadAssetsStore.track('duplicate');
|
||||
@ -122,7 +124,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
|
||||
formData.append(key, value);
|
||||
}
|
||||
|
||||
let responseData: AssetMediaResponseDto | undefined;
|
||||
let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined;
|
||||
const key = getKey();
|
||||
if (crypto?.subtle?.digest && !key) {
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') });
|
||||
@ -138,7 +140,11 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
|
||||
results: [checkUploadResult],
|
||||
} = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: [{ id: assetFile.name, checksum }] } });
|
||||
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) {
|
||||
console.error(`Error calculating sha1 file=${assetFile.name})`, error);
|
||||
@ -185,6 +191,7 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
|
||||
uploadAssetsStore.updateItem(deviceAssetId, {
|
||||
state: responseData.status === AssetMediaStatus.Duplicate ? UploadState.DUPLICATED : UploadState.DONE,
|
||||
assetId: responseData.id,
|
||||
isTrashed: responseData.isTrashed,
|
||||
});
|
||||
|
||||
if (responseData.status !== AssetMediaStatus.Duplicate) {
|
||||
@ -195,10 +202,9 @@ async function fileUploader(assetFile: File, albumId?: string, replaceAssetId?:
|
||||
|
||||
return responseData.id;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_upload_file'));
|
||||
const reason = getServerErrorMessage(error) || error;
|
||||
const errorMessage = handleError(error, $t('errors.unable_to_upload_file'));
|
||||
uploadAssetsStore.track('error');
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: reason });
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { state: UploadState.ERROR, error: errorMessage });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -20,11 +20,13 @@ export function handleError(error: unknown, message: string) {
|
||||
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: serverMessage || message,
|
||||
type: NotificationType.Error,
|
||||
});
|
||||
const errorMessage = serverMessage || message;
|
||||
|
||||
notificationController.show({ message: errorMessage, type: NotificationType.Error });
|
||||
|
||||
return errorMessage;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
@ -128,7 +128,7 @@
|
||||
const assetInteractionStore = createAssetInteractionStore();
|
||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||
|
||||
$: timelineStore = new AssetStore({ isArchived: false }, albumId);
|
||||
$: timelineStore = new AssetStore({ isArchived: false, withPartners: true }, albumId);
|
||||
const timelineInteractionStore = createAssetInteractionStore();
|
||||
const { selectedAssets: timelineSelected } = timelineInteractionStore;
|
||||
|
||||
|
@ -2,30 +2,32 @@
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.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 AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.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 AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.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 DownloadAction from '$lib/components/photos-page/actions/download-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 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 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 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 { AssetAction } from '$lib/constants';
|
||||
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 { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import { AssetStore } from '$lib/stores/assets.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 TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
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(() => {
|
||||
assetStore.destroy();
|
||||
});
|
||||
@ -78,6 +87,9 @@
|
||||
onUnstack={(assets) => assetStore.addAssets(assets)}
|
||||
/>
|
||||
{/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 />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
|
Loading…
x
Reference in New Issue
Block a user