1
0
forked from Cutlery/immich

Merge branch 'main' into fix/edit-faces-notification

This commit is contained in:
martabal 2024-02-27 08:02:51 +01:00
commit 0f8fb8d38b
No known key found for this signature in database
GPG Key ID: C00196E3148A52BD
126 changed files with 2800 additions and 1533 deletions

View File

@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration.
``` ```
<VirtualHost *:80> <VirtualHost *:80>
ServerName <snip> ServerName <snip>
ProxyRequests Off
ProxyPass / http://127.0.0.1:2283/ timeout=600 upgrade=websocket
ProxyPassReverse / http://127.0.0.1:2283/
ProxyPreserveHost On
ProxyRequests off
ProxyVia on
RewriteEngine On
RewriteCond %{REQUEST_URI} ^/api/socket.io [NC]
RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /(.*) ws://localhost:2283/$1 [P,L]
ProxyPass /api/socket.io ws://localhost:2283/api/socket.io
ProxyPassReverse /api/socket.io ws://localhost:2283/api/socket.io
<Location />
ProxyPass http://localhost:2283/
ProxyPassReverse http://localhost:2283/
</Location>
</VirtualHost> </VirtualHost>
``` ```
**timeout:** is measured in seconds, and it is particularly useful when long operations are triggered (i.e. Repair), so the server doesn't return an error.

78
e2e/package-lock.json generated
View File

@ -904,9 +904,9 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
"integrity": "sha512-e5Y5uK5NNoQMQaNitGQQjo9FoA5ZNcu7Bn6pH+dxUf48u6po1cX38kFBYUHZ9GNVkF4JLbncE0WeWwTw+nLrxg==", "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.1", "@ampproject/remapping": "^2.2.1",
@ -927,17 +927,17 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"vitest": "1.3.0" "vitest": "1.3.1"
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
"integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/spy": "1.3.0", "@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"chai": "^4.3.10" "chai": "^4.3.10"
}, },
"funding": { "funding": {
@ -945,12 +945,12 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
"integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"p-limit": "^5.0.0", "p-limit": "^5.0.0",
"pathe": "^1.1.1" "pathe": "^1.1.1"
}, },
@ -959,9 +959,9 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
"integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"magic-string": "^0.30.5", "magic-string": "^0.30.5",
@ -973,9 +973,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
"integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"tinyspy": "^2.2.0" "tinyspy": "^2.2.0"
@ -985,9 +985,9 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"diff-sequences": "^29.6.3", "diff-sequences": "^29.6.3",
@ -2551,9 +2551,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.1.3", "version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
"integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"esbuild": "^0.19.3", "esbuild": "^0.19.3",
@ -2606,9 +2606,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
"integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"cac": "^6.7.14", "cac": "^6.7.14",
@ -2642,16 +2642,16 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
"integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.3.0", "@vitest/expect": "1.3.1",
"@vitest/runner": "1.3.0", "@vitest/runner": "1.3.1",
"@vitest/snapshot": "1.3.0", "@vitest/snapshot": "1.3.1",
"@vitest/spy": "1.3.0", "@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.0", "@vitest/utils": "1.3.1",
"acorn-walk": "^8.3.2", "acorn-walk": "^8.3.2",
"chai": "^4.3.10", "chai": "^4.3.10",
"debug": "^4.3.4", "debug": "^4.3.4",
@ -2665,7 +2665,7 @@
"tinybench": "^2.5.1", "tinybench": "^2.5.1",
"tinypool": "^0.8.2", "tinypool": "^0.8.2",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-node": "1.3.0", "vite-node": "1.3.1",
"why-is-node-running": "^2.2.2" "why-is-node-running": "^2.2.2"
}, },
"bin": { "bin": {
@ -2680,8 +2680,8 @@
"peerDependencies": { "peerDependencies": {
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0", "@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "1.3.0", "@vitest/browser": "1.3.1",
"@vitest/ui": "1.3.0", "@vitest/ui": "1.3.1",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },

View File

@ -1 +0,0 @@
from .ann import Ann, is_available

View File

@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True)
class Newable(Protocol[T]): class Newable(Protocol[T]):
def new(self) -> None: def new(self) -> None: ...
...
class _Singleton(type, Newable[T]): class _Singleton(type, Newable[T]):

View File

@ -1,18 +1,16 @@
from __future__ import annotations from __future__ import annotations
import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any from typing import Any
import onnx
import onnxruntime as ort import onnxruntime as ort
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from onnx.shape_inference import infer_shapes
from onnx.tools.update_model_dims import update_inputs_outputs_dims
import ann.ann import ann.ann
from app.models.constants import STATIC_INPUT_PROVIDERS, SUPPORTED_PROVIDERS from app.models.constants import SUPPORTED_PROVIDERS
from ..config import get_cache_dir, get_hf_model_name, log, settings from ..config import get_cache_dir, get_hf_model_name, log, settings
from ..schemas import ModelRuntime, ModelType from ..schemas import ModelRuntime, ModelType
@ -113,63 +111,25 @@ class InferenceModel(ABC):
) )
model_path = onnx_path model_path = onnx_path
if any(provider in STATIC_INPUT_PROVIDERS for provider in self.providers):
static_path = model_path.parent / "static_1" / "model.onnx"
static_path.parent.mkdir(parents=True, exist_ok=True)
if not static_path.is_file():
self._convert_to_static(model_path, static_path)
model_path = static_path
match model_path.suffix: match model_path.suffix:
case ".armnn": case ".armnn":
session = AnnSession(model_path) session = AnnSession(model_path)
case ".onnx": case ".onnx":
session = ort.InferenceSession( cwd = os.getcwd()
model_path.as_posix(), try:
sess_options=self.sess_options, os.chdir(model_path.parent)
providers=self.providers, session = ort.InferenceSession(
provider_options=self.provider_options, model_path.as_posix(),
) sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
finally:
os.chdir(cwd)
case _: case _:
raise ValueError(f"Unsupported model file type: {model_path.suffix}") raise ValueError(f"Unsupported model file type: {model_path.suffix}")
return session return session
def _convert_to_static(self, source_path: Path, target_path: Path) -> None:
inferred = infer_shapes(onnx.load(source_path))
inputs = self._get_static_dims(inferred.graph.input)
outputs = self._get_static_dims(inferred.graph.output)
# check_model gets called in update_inputs_outputs_dims and doesn't work for large models
check_model = onnx.checker.check_model
try:
def check_model_stub(*args: Any, **kwargs: Any) -> None:
pass
onnx.checker.check_model = check_model_stub
updated_model = update_inputs_outputs_dims(inferred, inputs, outputs)
finally:
onnx.checker.check_model = check_model
onnx.save(
updated_model,
target_path,
save_as_external_data=True,
all_tensors_to_one_file=False,
size_threshold=1048576,
)
def _get_static_dims(self, graph_io: Any, dim_size: int = 1) -> dict[str, list[int]]:
return {
field.name: [
d.dim_value if d.HasField("dim_value") else dim_size
for shape in field.type.ListFields()
if (dim := shape[1].shape.dim)
for d in dim
]
for field in graph_io
}
@property @property
def model_type(self) -> ModelType: def model_type(self) -> ModelType:
return self._model_type return self._model_type
@ -205,6 +165,14 @@ class InferenceModel(ABC):
def providers_default(self) -> list[str]: def providers_default(self) -> list[str]:
available_providers = set(ort.get_available_providers()) available_providers = set(ort.get_available_providers())
log.debug(f"Available ORT providers: {available_providers}") log.debug(f"Available ORT providers: {available_providers}")
if (openvino := "OpenVINOExecutionProvider") in available_providers:
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
if not gpu_devices:
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
available_providers.remove(openvino)
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers] return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
@property @property
@ -224,15 +192,7 @@ class InferenceModel(ABC):
case "CPUExecutionProvider" | "CUDAExecutionProvider": case "CPUExecutionProvider" | "CUDAExecutionProvider":
option = {"arena_extend_strategy": "kSameAsRequested"} option = {"arena_extend_strategy": "kSameAsRequested"}
case "OpenVINOExecutionProvider": case "OpenVINOExecutionProvider":
try: option = {"device_type": "GPU_FP32"}
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
option = {"device_id": gpu_devices[0]} if gpu_devices else {}
except AttributeError as e:
log.warning("Failed to get OpenVINO device IDs. Using default options.")
log.error(e)
option = {}
case _: case _:
option = {} option = {}
options.append(option) options.append(option)

View File

@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = {
SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"] SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"]
STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"]
def is_openclip(model_name: str) -> bool: def is_openclip(model_name: str) -> bool:
return clean_name(model_name) in _OPENCLIP_MODELS return clean_name(model_name) in _OPENCLIP_MODELS

View File

@ -1,4 +1,5 @@
import json import json
import os
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from random import randint from random import randint
@ -44,11 +45,23 @@ class TestBase:
assert encoder.providers == self.CUDA_EP assert encoder.providers == self.CUDA_EP
@pytest.mark.providers(OV_EP) @pytest.mark.providers(OV_EP)
def test_sets_openvino_provider_if_available(self, providers: list[str]) -> None: def test_sets_openvino_provider_if_available(self, providers: list[str], mocker: MockerFixture) -> None:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
assert encoder.providers == self.OV_EP assert encoder.providers == self.OV_EP
@pytest.mark.providers(OV_EP)
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], mocker: MockerFixture) -> None:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai")
assert encoder.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER) @pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None: def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
@ -67,22 +80,14 @@ class TestBase:
assert encoder.providers == providers assert encoder.providers == providers
def test_sets_default_provider_options(self) -> None: def test_sets_default_provider_options(self, mocker: MockerFixture) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
assert encoder.provider_options == [
{},
{"arena_extend_strategy": "kSameAsRequested"},
]
def test_sets_openvino_device_id_if_possible(self, mocker: MockerFixture) -> None:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state") mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"] mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
assert encoder.provider_options == [ assert encoder.provider_options == [
{"device_id": "GPU.0"}, {"device_type": "GPU_FP32"},
{"arena_extend_strategy": "kSameAsRequested"}, {"arena_extend_strategy": "kSameAsRequested"},
] ]
@ -237,12 +242,12 @@ class TestBase:
mock_model_path.is_file.return_value = True mock_model_path.is_file.return_value = True
mock_model_path.suffix = ".armnn" mock_model_path.suffix = ".armnn"
mock_model_path.with_suffix.return_value = mock_model_path mock_model_path.with_suffix.return_value = mock_model_path
mock_session = mocker.patch("app.models.base.AnnSession") mock_ann = mocker.patch("app.models.base.AnnSession")
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path) encoder._make_session(mock_model_path)
mock_session.assert_called_once() mock_ann.assert_called_once()
def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None: def test_make_session_return_ort_if_available_and_ann_is_not(self, mocker: MockerFixture) -> None:
mock_armnn_path = mocker.Mock() mock_armnn_path = mocker.Mock()
@ -256,6 +261,7 @@ class TestBase:
mock_ann = mocker.patch("app.models.base.AnnSession") mock_ann = mocker.patch("app.models.base.AnnSession")
mock_ort = mocker.patch("app.models.base.ort.InferenceSession") mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
mocker.patch("app.models.base.os.chdir")
encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_armnn_path) encoder._make_session(mock_armnn_path)
@ -278,6 +284,26 @@ class TestBase:
mock_ann.assert_not_called() mock_ann.assert_not_called()
mock_ort.assert_not_called() mock_ort.assert_not_called()
def test_make_session_changes_cwd(self, mocker: MockerFixture) -> None:
mock_model_path = mocker.Mock()
mock_model_path.is_file.return_value = True
mock_model_path.suffix = ".onnx"
mock_model_path.parent = "model_parent"
mock_model_path.with_suffix.return_value = mock_model_path
mock_ort = mocker.patch("app.models.base.ort.InferenceSession")
mock_chdir = mocker.patch("app.models.base.os.chdir")
encoder = OpenCLIPEncoder("ViT-B-32__openai")
encoder._make_session(mock_model_path)
mock_chdir.assert_has_calls(
[
mock.call(mock_model_path.parent),
mock.call(os.getcwd()),
]
)
mock_ort.assert_called_once()
def test_download(self, mocker: MockerFixture) -> None: def test_download(self, mocker: MockerFixture) -> None:
mock_snapshot_download = mocker.patch("app.models.base.snapshot_download") mock_snapshot_download = mocker.patch("app.models.base.snapshot_download")

View File

@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:926cac38640709f90f3fef2a3f730733b5c350be612f0d14706be8833b79ad8c as builder FROM mambaorg/micromamba:bookworm-slim@sha256:6038b89363c9181215f3d9e8ce2720c880e224537f4028a854482e43a9b4998a as builder
ENV NODE_ENV=production \ ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \ TRANSFORMERS_CACHE=/cache \

View File

@ -1250,13 +1250,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.26.0" version = "0.27.0"
description = "The next generation HTTP client." description = "The next generation HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
] ]
[package.dependencies] [package.dependencies]
@ -2465,13 +2465,13 @@ files = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.0.0" version = "8.0.2"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"},
{file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"},
] ]
[package.dependencies] [package.dependencies]
@ -2836,28 +2836,28 @@ files = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.2.1" version = "0.2.2"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"}, {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"},
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"}, {file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"}, {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"}, {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"}, {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"}, {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"}, {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"},
{file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"}, {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"},
{file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"}, {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"},
{file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"}, {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"},
{file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"}, {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"},
] ]
[[package]] [[package]]

View File

@ -82,10 +82,10 @@ warn_untyped_fields = true
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
target-version = "py311" target-version = "py311"
select = ["E", "F", "I"]
[tool.ruff.per-file-ignores] [tool.ruff.lint]
"test_main.py" = ["F403"] select = ["E", "F", "I"]
per-file-ignores = { "test_main.py" = ["F403"] }
[tool.black] [tool.black]
line-length = 120 line-length = 120

View File

@ -30,7 +30,7 @@ extension LogOnError<T> on AsyncValue<T> {
} }
if (hasError && !hasValue) { if (hasError && !hasValue) {
_asyncErrorLogger.severe("$error", error, stackTrace); _asyncErrorLogger.severe('Could not load value', error, stackTrace);
return onError?.call(error, stackTrace) ?? return onError?.call(error, stackTrace) ??
ScaffoldErrorBody(errorMsg: error?.toString()); ScaffoldErrorBody(errorMsg: error?.toString());
} }

View File

@ -0,0 +1,5 @@
import 'package:http/http.dart';
extension LoggerExtension on Response {
String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body";
}

View File

@ -73,15 +73,14 @@ Future<void> initApp() async {
FlutterError.onError = (details) { FlutterError.onError = (details) {
FlutterError.presentError(details); FlutterError.presentError(details);
log.severe( log.severe(
'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}', 'FlutterError - Catch all',
details, "${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}",
details.stack, details.stack,
); );
}; };
PlatformDispatcher.instance.onError = (error, stack) { PlatformDispatcher.instance.onError = (error, stack) {
log.severe('PlatformDispatcher - Catch all error: $error', error, stack); log.severe('PlatformDispatcher - Catch all', error, stack);
debugPrint("PlatformDispatcher - Catch all error: $error $stack");
return true; return true;
}; };

View File

@ -10,13 +10,14 @@ mixin ErrorLoggerMixin {
/// Else, logs the error to the overrided logger and returns an AsyncError<> /// Else, logs the error to the overrided logger and returns an AsyncError<>
AsyncFuture<T> guardError<T>( AsyncFuture<T> guardError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required String errorMessage,
Level logLevel = Level.SEVERE, Level logLevel = Level.SEVERE,
}) async { }) async {
try { try {
final result = await fn(); final result = await fn();
return AsyncData(result); return AsyncData(result);
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace); logger.log(logLevel, errorMessage, error, stackTrace);
return AsyncError(error, stackTrace); return AsyncError(error, stackTrace);
} }
} }
@ -26,12 +27,13 @@ mixin ErrorLoggerMixin {
Future<T> logError<T>( Future<T> logError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required T defaultValue, required T defaultValue,
required String errorMessage,
Level logLevel = Level.SEVERE, Level logLevel = Level.SEVERE,
}) async { }) async {
try { try {
return await fn(); return await fn();
} catch (error, stackTrace) { } catch (error, stackTrace) {
logger.log(logLevel, "$error", error, stackTrace); logger.log(logLevel, errorMessage, error, stackTrace);
} }
return defaultValue; return defaultValue;
} }

View File

@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin {
return list != null ? list.map(Activity.fromDto).toList() : []; return list != null ? list.map(Activity.fromDto).toList() : [];
}, },
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get all activities for album $albumId",
); );
} }
@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin {
return dto?.comments ?? 0; return dto?.comments ?? 0;
}, },
defaultValue: 0, defaultValue: 0,
errorMessage: "Failed to statistics for album $albumId",
); );
} }
@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin {
return true; return true;
}, },
defaultValue: false, defaultValue: false,
errorMessage: "Failed to delete activity",
); );
} }
@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin {
String? assetId, String? assetId,
String? comment, String? comment,
}) async { }) async {
return guardError(() async { return guardError(
final dto = await _apiService.activityApi.createActivity( () async {
ActivityCreateDto( final dto = await _apiService.activityApi.createActivity(
albumId: albumId, ActivityCreateDto(
type: type == ActivityType.comment albumId: albumId,
? ReactionType.comment type: type == ActivityType.comment
: ReactionType.like, ? ReactionType.comment
assetId: assetId, : ReactionType.like,
comment: comment, assetId: assetId,
), comment: comment,
); ),
if (dto != null) { );
return Activity.fromDto(dto); if (dto != null) {
} return Activity.fromDto(dto);
throw NoResponseDtoError(); }
}); throw NoResponseDtoError();
},
errorMessage: "Failed to create $type for album $albumId",
);
} }
} }

View File

@ -0,0 +1,179 @@
import 'dart:async';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:video_player/video_player.dart';
import 'package:immich_mobile/shared/models/store.dart' as store;
import 'package:wakelock_plus/wakelock_plus.dart';
/// Provides the initialized video player controller
/// If the asset is local, use the local file
/// Otherwise, use a video player with a URL
ChewieController? useChewieController(
Asset asset, {
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
bool showOptions = true,
bool showControlsOnInitialize = false,
bool autoPlay = true,
bool autoInitialize = true,
bool allowFullScreen = false,
bool allowedScreenSleep = false,
bool showControls = true,
Widget? customControls,
Widget? placeholder,
Duration hideControlsTimer = const Duration(seconds: 1),
VoidCallback? onPlaying,
VoidCallback? onPaused,
VoidCallback? onVideoEnded,
}) {
return use(
_ChewieControllerHook(
asset: asset,
placeholder: placeholder,
showOptions: showOptions,
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
autoPlay: autoPlay,
allowFullScreen: allowFullScreen,
customControls: customControls,
hideControlsTimer: hideControlsTimer,
showControlsOnInitialize: showControlsOnInitialize,
showControls: showControls,
autoInitialize: autoInitialize,
allowedScreenSleep: allowedScreenSleep,
onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
),
);
}
class _ChewieControllerHook extends Hook<ChewieController?> {
final Asset asset;
final EdgeInsets controlsSafeAreaMinimum;
final bool showOptions;
final bool showControlsOnInitialize;
final bool autoPlay;
final bool autoInitialize;
final bool allowFullScreen;
final bool allowedScreenSleep;
final bool showControls;
final Widget? customControls;
final Widget? placeholder;
final Duration hideControlsTimer;
final VoidCallback? onPlaying;
final VoidCallback? onPaused;
final VoidCallback? onVideoEnded;
const _ChewieControllerHook({
required this.asset,
this.controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
this.showOptions = true,
this.showControlsOnInitialize = false,
this.autoPlay = true,
this.autoInitialize = true,
this.allowFullScreen = false,
this.allowedScreenSleep = false,
this.showControls = true,
this.customControls,
this.placeholder,
this.hideControlsTimer = const Duration(seconds: 3),
this.onPlaying,
this.onPaused,
this.onVideoEnded,
});
@override
createState() => _ChewieControllerHookState();
}
class _ChewieControllerHookState
extends HookState<ChewieController?, _ChewieControllerHook> {
ChewieController? chewieController;
VideoPlayerController? videoPlayerController;
@override
void initHook() async {
super.initHook();
unawaited(_initialize());
}
@override
void dispose() {
chewieController?.dispose();
videoPlayerController?.dispose();
super.dispose();
}
@override
ChewieController? build(BuildContext context) {
return chewieController;
}
/// Initializes the chewie controller and video player controller
Future<void> _initialize() async {
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
// Use a local file for the video player controller
final file = await hook.asset.local!.file;
if (file == null) {
throw Exception('No file found for the video');
}
videoPlayerController = VideoPlayerController.file(file);
} else {
// Use a network URL for the video player controller
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
final String videoUrl = hook.asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
final url = Uri.parse(videoUrl);
final accessToken = store.Store.get(StoreKey.accessToken);
videoPlayerController = VideoPlayerController.networkUrl(
url,
httpHeaders: {"x-immich-user-token": accessToken},
);
}
videoPlayerController!.addListener(() {
final value = videoPlayerController!.value;
if (value.isPlaying) {
WakelockPlus.enable();
hook.onPlaying?.call();
} else if (!value.isPlaying) {
WakelockPlus.disable();
hook.onPaused?.call();
}
if (value.position == value.duration) {
WakelockPlus.disable();
hook.onVideoEnded?.call();
}
});
await videoPlayerController!.initialize();
setState(() {
chewieController = ChewieController(
videoPlayerController: videoPlayerController!,
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
showOptions: hook.showOptions,
showControlsOnInitialize: hook.showControlsOnInitialize,
autoPlay: hook.autoPlay,
autoInitialize: hook.autoInitialize,
allowFullScreen: hook.allowFullScreen,
allowedScreenSleep: hook.allowedScreenSleep,
showControls: hook.showControls,
customControls: hook.customControls,
placeholder: hook.placeholder,
hideControlsTimer: hook.hideControlsTimer,
);
});
}
}

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
@ -39,7 +40,8 @@ class ImageViewerService {
final failedResponse = final failedResponse =
imageResponse.statusCode != 200 ? imageResponse : motionReponse; imageResponse.statusCode != 200 ? imageResponse : motionReponse;
_log.severe( _log.severe(
"Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}", "Motion asset download failed",
failedResponse.toLoggerString(),
); );
return false; return false;
} }
@ -75,9 +77,7 @@ class ImageViewerService {
.downloadFileWithHttpInfo(asset.remoteId!); .downloadFileWithHttpInfo(asset.remoteId!);
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe("Asset download failed", res.toLoggerString());
"Asset download failed with status - ${res.statusCode} and response - ${res.body}",
);
return false; return false;
} }
@ -98,7 +98,7 @@ class ImageViewerService {
return entity != null; return entity != null;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Error saving file ${error.toString()}", error, stack); _log.severe("Error saving downloaded asset", error, stack);
return false; return false;
} finally { } finally {
// Clear temp files // Clear temp files

View File

@ -48,7 +48,7 @@ class DescriptionInput extends HookConsumerWidget {
); );
} catch (error, stack) { } catch (error, stack) {
hasError.value = true; hasError.value = true;
_log.severe("Error updating description $error", error, stack); _log.severe("Error updating description", error, stack);
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "description_input_submit_error".tr(), msg: "description_input_submit_error".tr(),

View File

@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget { class VideoPlayerControls extends ConsumerStatefulWidget {
@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
children: [ children: [
if (_displayBufferingIndicator) if (_displayBufferingIndicator)
const Center( const Center(
child: ImmichLoadingIndicator(), child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
) )
else else
_buildHitArea(), _buildHitArea(),
@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
@override @override
void dispose() { void dispose() {
_dispose(); _dispose();
super.dispose(); super.dispose();
} }
@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
final oldController = _chewieController; final oldController = _chewieController;
_chewieController = ChewieController.of(context); _chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController; controller = chewieController.videoPlayerController;
_latestValue = controller.value;
if (oldController != chewieController) { if (oldController != chewieController) {
_dispose(); _dispose();
@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (_latestValue.isPlaying) { if (!_latestValue.isPlaying) {
ref.read(showControlsProvider.notifier).show = false;
} else {
_playPause(); _playPause();
ref.read(showControlsProvider.notifier).show = false;
} }
ref.read(showControlsProvider.notifier).show = false;
}, },
child: CenterPlayButton( child: CenterPlayButton(
backgroundColor: Colors.black54, backgroundColor: Colors.black54,
@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
} }
Future<void> _initialize() async { Future<void> _initialize() async {
ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
controller.addListener(_updateState);
_latestValue = controller.value; _latestValue = controller.value;
controller.addListener(_updateState);
if (controller.value.isPlaying || chewieController.autoPlay) { if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer(); _startHideTimer();
@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
} }
void _startHideTimer() { void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative final hideControlsTimer = chewieController.hideControlsTimer;
? ChewieController.defaultHideControlsTimer _hideTimer?.cancel();
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () { _hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false; ref.read(showControlsProvider.notifier).show = false;
}); });

View File

@ -704,6 +704,18 @@ class GalleryViewerPage extends HookConsumerWidget {
); );
} }
useEffect(
() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) { ref.listen(showControlsProvider, (_, show) {
if (show) { if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -794,7 +806,9 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0, minScale: 1.0,
basePosition: Alignment.center, basePosition: Alignment.center,
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true, onPlaying: () {
isPlayingVideo.value = true;
},
onPaused: () => onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false, (_) => isPlayingVideo.value = false,

View File

@ -1,23 +1,15 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage() @RoutePage()
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget { class VideoViewerPage extends HookWidget {
final Asset asset; final Asset asset;
final bool isMotionVideo; final bool isMotionVideo;
final Widget? placeholder; final Widget? placeholder;
@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget {
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
if (asset.isLocal && asset.livePhotoVideoId == null) { final controller = useChewieController(
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!)); asset,
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: videoFile.when(
data: (data) => VideoPlayer(
file: data,
isMotionVideo: false,
onVideoEnded: () {},
),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
),
loading: () => showDownloadingIndicator
? const Center(child: ImmichLoadingIndicator())
: Container(),
),
);
}
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final String videoUrl = isMotionVideo
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
return Stack(
children: [
VideoPlayer(
url: videoUrl,
accessToken: Store.get(StoreKey.accessToken),
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,
onPlaying: onPlaying,
placeholder: placeholder,
hideControlsTimer: hideControlsTimer,
showControls: showControls,
showDownloadingIndicator: showDownloadingIndicator,
),
AnimatedOpacity(
duration: const Duration(milliseconds: 400),
opacity: (downloadAssetStatus == DownloadAssetStatus.loading &&
showDownloadingIndicator)
? 1.0
: 0.0,
child: SizedBox(
height: context.height,
width: context.width,
child: const Center(
child: ImmichLoadingIndicator(),
),
),
),
],
);
}
}
final _fileFamily =
FutureProvider.family<File, AssetEntity>((ref, entity) async {
final file = await entity.file;
if (file == null) {
throw Exception();
}
return file;
});
class VideoPlayer extends StatefulWidget {
final String? url;
final String? accessToken;
final File? file;
final bool isMotionVideo;
final VoidCallback? onVideoEnded;
final Duration hideControlsTimer;
final bool showControls;
final Function()? onPlaying;
final Function()? onPaused;
/// The placeholder to show while the video is loading
/// usually, a thumbnail of the video
final Widget? placeholder;
final bool showDownloadingIndicator;
const VideoPlayer({
super.key,
this.url,
this.accessToken,
this.file,
this.onVideoEnded,
required this.isMotionVideo,
this.onPlaying,
this.onPaused,
this.placeholder,
this.hideControlsTimer = const Duration(
seconds: 5,
),
this.showControls = true,
this.showDownloadingIndicator = true,
});
@override
State<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
@override
void initState() {
super.initState();
initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) {
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
WakelockPlus.disable();
widget.onPaused?.call();
}
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
WakelockPlus.disable();
widget.onVideoEnded?.call();
}
}
});
}
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"x-immich-user-token": widget.accessToken ?? ""},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player $e");
}
}
_createChewieController() {
chewieController = ChewieController(
controlsSafeAreaMinimum: const EdgeInsets.only( controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100, bottom: 100,
), ),
showOptions: true, placeholder: placeholder,
showControlsOnInitialize: false, showControls: showControls && !isMotionVideo,
videoPlayerController: videoPlayerController, hideControlsTimer: hideControlsTimer,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: widget.showControls && !widget.isMotionVideo,
customControls: const VideoPlayerControls(), customControls: const VideoPlayerControls(),
hideControlsTimer: widget.hideControlsTimer, onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
);
// Loading
return PopScope(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Builder(
builder: (context) {
if (controller == null) {
return Stack(
children: [
if (placeholder != null) placeholder!,
const DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
],
);
}
final size = MediaQuery.of(context).size;
return SizedBox(
height: size.height,
width: size.width,
child: Chewie(
controller: controller,
),
);
},
),
),
); );
} }
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
if (chewieController?.videoPlayerController.value.isInitialized == true) {
return SizedBox(
height: context.height,
width: context.width,
child: Chewie(
controller: chewieController!,
),
);
} else {
return SizedBox(
height: context.height,
width: context.width,
child: Center(
child: Stack(
children: [
if (widget.placeholder != null) widget.placeholder!,
if (widget.showDownloadingIndicator)
const Center(
child: ImmichLoadingIndicator(),
),
],
),
),
);
}
}
} }

View File

@ -245,7 +245,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (e, stack) { } catch (e, stack) {
log.severe( log.severe(
"Failed to get thumbnail for album ${album.name}", "Failed to get thumbnail for album ${album.name}",
e.toString(), e,
stack, stack,
); );
} }

View File

@ -108,7 +108,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
.then((_) => log.info("Logout was successful for $userEmail")) .then((_) => log.info("Logout was successful for $userEmail"))
.onError( .onError(
(error, stackTrace) => (error, stackTrace) =>
log.severe("Error logging out $userEmail", error, stackTrace), log.severe("Logout failed for $userEmail", error, stackTrace),
); );
await Future.wait([ await Future.wait([
@ -129,8 +129,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
shouldChangePassword: false, shouldChangePassword: false,
isAuthenticated: false, isAuthenticated: false,
); );
} catch (e) { } catch (e, stack) {
log.severe("Error logging out $e"); log.severe('Logout failed', e, stack);
} }
} }

View File

@ -36,7 +36,7 @@ class OAuthService {
), ),
); );
} catch (e, stack) { } catch (e, stack) {
log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack); log.severe("OAuth login failed", e, stack);
return null; return null;
} }
} }

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/modules/map/models/map_state.model.dart'; import 'package:immich_mobile/modules/map/models/map_state.model.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@ -51,7 +52,8 @@ class MapStateNotifier extends _$MapStateNotifier {
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
); );
_log.severe( _log.severe(
"Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}", "Cannot fetch map light style",
lightResponse.toLoggerString(),
); );
return; return;
} }
@ -77,9 +79,7 @@ class MapStateNotifier extends _$MapStateNotifier {
state = state.copyWith( state = state.copyWith(
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
); );
_log.severe( _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString());
"Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
);
return; return;
} }

View File

@ -28,6 +28,7 @@ class MapSerivce with ErrorLoggerMixin {
return markers?.map(MapMarker.fromDto) ?? []; return markers?.map(MapMarker.fromDto) ?? [];
}, },
defaultValue: [], defaultValue: [],
errorMessage: "Failed to get map markers",
); );
} }
} }

View File

@ -105,10 +105,8 @@ class MapUtils {
timeLimit: const Duration(seconds: 5), timeLimit: const Duration(seconds: 5),
); );
return (currentUserLocation, null); return (currentUserLocation, null);
} catch (error) { } catch (error, stack) {
_log.severe( _log.severe("Cannot get user's current location", error, stack);
"Cannot get user's current location due to ${error.toString()}",
);
return (null, LocationPermission.unableToDetermine); return (null, LocationPermission.unableToDetermine);
} }
} }

View File

@ -147,7 +147,7 @@ class MapAssetGrid extends HookConsumerWidget {
}, },
error: (error, stackTrace) { error: (error, stackTrace) {
log.warning( log.warning(
"Cannot get assets in the current map bounds $error", "Cannot get assets in the current map bounds",
error, error,
stackTrace, stackTrace,
); );

View File

@ -47,7 +47,7 @@ class MemoryService {
return memories.isNotEmpty ? memories : null; return memories.isNotEmpty ? memories : null;
} catch (error, stack) { } catch (error, stack) {
log.severe("Cannot get memories ${error.toString()}", error, stack); log.severe("Cannot get memories", error, stack);
return null; return null;
} }
} }

View File

@ -55,9 +55,9 @@ class MemoryCard extends StatelessWidget {
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Determine the fit using the aspect ratio // Determine the fit using the aspect ratio
BoxFit fit = BoxFit.fitWidth; BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) { if (asset.width != null && asset.height != null) {
final aspectRatio = asset.height! / asset.width!; final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = final phoneAspectRatio =
constraints.maxWidth / constraints.maxHeight; constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction // Look for a 25% difference in either direction

View File

@ -40,7 +40,7 @@ class PartnerService {
return userDtos.map((u) => User.fromPartnerDto(u)).toList(); return userDtos.map((u) => User.fromPartnerDto(u)).toList();
} }
} catch (e) { } catch (e) {
_log.warning("failed to get partners for direction $direction:\n$e"); _log.warning("Failed to get partners for direction $direction", e);
} }
return null; return null;
} }
@ -51,7 +51,7 @@ class PartnerService {
partner.isPartnerSharedBy = false; partner.isPartnerSharedBy = false;
await _db.writeTxn(() => _db.users.put(partner)); await _db.writeTxn(() => _db.users.put(partner));
} catch (e) { } catch (e) {
_log.warning("failed to remove partner ${partner.id}:\n$e"); _log.warning("Failed to remove partner ${partner.id}", e);
return false; return false;
} }
return true; return true;
@ -66,7 +66,7 @@ class PartnerService {
return true; return true;
} }
} catch (e) { } catch (e) {
_log.warning("failed to add partner ${partner.id}:\n$e"); _log.warning("Failed to add partner ${partner.id}", e);
} }
return false; return false;
} }
@ -81,7 +81,7 @@ class PartnerService {
return true; return true;
} }
} catch (e) { } catch (e) {
_log.warning("failed to update partner ${partner.id}:\n$e"); _log.warning("Failed to update partner ${partner.id}", e);
} }
return false; return false;
} }

View File

@ -22,7 +22,7 @@ class SharedLinkService {
? AsyncData(list.map(SharedLink.fromDto).toList()) ? AsyncData(list.map(SharedLink.fromDto).toList())
: const AsyncData([]); : const AsyncData([]);
} catch (e, stack) { } catch (e, stack) {
_log.severe("failed to fetch shared links - $e"); _log.severe("Failed to fetch shared links", e, stack);
return AsyncError(e, stack); return AsyncError(e, stack);
} }
} }
@ -31,7 +31,7 @@ class SharedLinkService {
try { try {
return await _apiService.sharedLinkApi.removeSharedLink(id); return await _apiService.sharedLinkApi.removeSharedLink(id);
} catch (e) { } catch (e) {
_log.severe("failed to delete shared link id - $id with error - $e"); _log.severe("Failed to delete shared link id - $id", e);
} }
} }
@ -81,7 +81,7 @@ class SharedLinkService {
} }
} }
} catch (e) { } catch (e) {
_log.severe("failed to create shared link with error - $e"); _log.severe("Failed to create shared link", e);
} }
return null; return null;
} }
@ -113,7 +113,7 @@ class SharedLinkService {
return SharedLink.fromDto(responseDto); return SharedLink.fromDto(responseDto);
} }
} catch (e) { } catch (e) {
_log.severe("failed to update shared link id - $id with error - $e"); _log.severe("Failed to update shared link id - $id", e);
} }
return null; return null;
} }

View File

@ -44,7 +44,7 @@ class TrashNotifier extends StateNotifier<bool> {
.read(syncServiceProvider) .read(syncServiceProvider)
.handleRemoteAssetRemoval(idsToRemove.cast<String>().toList()); .handleRemoteAssetRemoval(idsToRemove.cast<String>().toList());
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot empty trash", error, stack);
} }
} }
@ -70,7 +70,7 @@ class TrashNotifier extends StateNotifier<bool> {
return isRemoved; return isRemoved;
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot remove assets", error, stack);
} }
return false; return false;
} }
@ -93,7 +93,7 @@ class TrashNotifier extends StateNotifier<bool> {
return true; return true;
} }
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore assets", error, stack);
} }
return false; return false;
} }
@ -123,7 +123,7 @@ class TrashNotifier extends StateNotifier<bool> {
await _db.assets.putAll(updatedAssets); await _db.assets.putAll(updatedAssets);
}); });
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash", error, stack);
} }
} }
} }

View File

@ -25,7 +25,7 @@ class TrashService {
await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds)); await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds));
return true; return true;
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore assets ${error.toString()}", error, stack); _log.severe("Cannot restore assets", error, stack);
return false; return false;
} }
} }
@ -34,7 +34,7 @@ class TrashService {
try { try {
await _apiService.trashApi.emptyTrash(); await _apiService.trashApi.emptyTrash();
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot empty trash ${error.toString()}", error, stack); _log.severe("Cannot empty trash", error, stack);
} }
} }
@ -42,7 +42,7 @@ class TrashService {
try { try {
await _apiService.trashApi.restoreTrash(); await _apiService.trashApi.restoreTrash();
} catch (error, stack) { } catch (error, stack) {
_log.severe("Cannot restore trash ${error.toString()}", error, stack); _log.severe("Cannot restore trash", error, stack);
} }
} }
} }

View File

@ -1,8 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -16,28 +16,31 @@ class AuthGuard extends AutoRouteGuard {
resolver.next(true); resolver.next(true);
try { try {
var res = await _apiService.authenticationApi.validateAccessToken(); // Look in the store for an access token
Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) { if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login // If the access token is invalid, take user back to login
_log.fine("User token is invalid. Redirecting to login"); _log.fine('User token is invalid. Redirecting to login');
router.replaceAll([const LoginRoute()]); router.replaceAll([const LoginRoute()]);
} }
} on StoreKeyNotFoundException catch (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.');
router.replaceAll([const LoginRoute()]);
return;
} on ApiException catch (e) { } on ApiException catch (e) {
if (e.code == HttpStatus.badRequest && // On an unauthorized request, take us to the login page
e.innerException is SocketException) { if (e.code == HttpStatus.unauthorized) {
// offline? _log.warning("Unauthorized access token.");
_log.fine(
"Unable to validate user token. User may be offline and offline browsing is allowed.",
);
} else {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]); router.replaceAll([const LoginRoute()]);
return; return;
} }
} catch (e) { } catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}"); // Otherwise, this is not fatal, but we still log the warning
router.replaceAll([const LoginRoute()]); _log.warning('Error validating access token from server: $e');
return;
} }
} }
} }

View File

@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused, void Function()? onPaused,
Widget? placeholder, Widget? placeholder,
bool showControls = true, bool showControls = true,
Duration hideControlsTimer = const Duration(seconds: 5), Duration hideControlsTimer = const Duration(milliseconds: 1500),
bool showDownloadingIndicator = true, bool showDownloadingIndicator = true,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused, this.onPaused,
this.placeholder, this.placeholder,
this.showControls = true, this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5), this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true, this.showDownloadingIndicator = true,
}); });

View File

@ -171,6 +171,11 @@ class Asset {
int? stackCount; int? stackCount;
/// Aspect ratio of the asset
@ignore
double? get aspectRatio =>
width == null || height == null ? 0 : width! / height!;
/// `true` if this [Asset] is present on the device /// `true` if this [Asset] is present on the device
@ignore @ignore
bool get isLocal => localId != null; bool get isLocal => localId != null;

View File

@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
class LoggerMessage { class LoggerMessage {
Id id = Isar.autoIncrement; Id id = Isar.autoIncrement;
String message; String message;
String? details;
@Enumerated(EnumType.ordinal) @Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO; LogLevel level = LogLevel.INFO;
DateTime createdAt; DateTime createdAt;
@ -17,6 +18,7 @@ class LoggerMessage {
LoggerMessage({ LoggerMessage({
required this.message, required this.message,
required this.details,
required this.level, required this.level,
required this.createdAt, required this.createdAt,
required this.context1, required this.context1,

View File

@ -32,14 +32,19 @@ const LoggerMessageSchema = CollectionSchema(
name: r'createdAt', name: r'createdAt',
type: IsarType.dateTime, type: IsarType.dateTime,
), ),
r'level': PropertySchema( r'details': PropertySchema(
id: 3, id: 3,
name: r'details',
type: IsarType.string,
),
r'level': PropertySchema(
id: 4,
name: r'level', name: r'level',
type: IsarType.byte, type: IsarType.byte,
enumMap: _LoggerMessagelevelEnumValueMap, enumMap: _LoggerMessagelevelEnumValueMap,
), ),
r'message': PropertySchema( r'message': PropertySchema(
id: 4, id: 5,
name: r'message', name: r'message',
type: IsarType.string, type: IsarType.string,
) )
@ -76,6 +81,12 @@ int _loggerMessageEstimateSize(
bytesCount += 3 + value.length * 3; bytesCount += 3 + value.length * 3;
} }
} }
{
final value = object.details;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
bytesCount += 3 + object.message.length * 3; bytesCount += 3 + object.message.length * 3;
return bytesCount; return bytesCount;
} }
@ -89,8 +100,9 @@ void _loggerMessageSerialize(
writer.writeString(offsets[0], object.context1); writer.writeString(offsets[0], object.context1);
writer.writeString(offsets[1], object.context2); writer.writeString(offsets[1], object.context2);
writer.writeDateTime(offsets[2], object.createdAt); writer.writeDateTime(offsets[2], object.createdAt);
writer.writeByte(offsets[3], object.level.index); writer.writeString(offsets[3], object.details);
writer.writeString(offsets[4], object.message); writer.writeByte(offsets[4], object.level.index);
writer.writeString(offsets[5], object.message);
} }
LoggerMessage _loggerMessageDeserialize( LoggerMessage _loggerMessageDeserialize(
@ -103,9 +115,10 @@ LoggerMessage _loggerMessageDeserialize(
context1: reader.readStringOrNull(offsets[0]), context1: reader.readStringOrNull(offsets[0]),
context2: reader.readStringOrNull(offsets[1]), context2: reader.readStringOrNull(offsets[1]),
createdAt: reader.readDateTime(offsets[2]), createdAt: reader.readDateTime(offsets[2]),
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[3])] ?? details: reader.readStringOrNull(offsets[3]),
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ??
LogLevel.ALL, LogLevel.ALL,
message: reader.readString(offsets[4]), message: reader.readString(offsets[5]),
); );
object.id = id; object.id = id;
return object; return object;
@ -125,9 +138,11 @@ P _loggerMessageDeserializeProp<P>(
case 2: case 2:
return (reader.readDateTime(offset)) as P; return (reader.readDateTime(offset)) as P;
case 3: case 3:
return (reader.readStringOrNull(offset)) as P;
case 4:
return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ??
LogLevel.ALL) as P; LogLevel.ALL) as P;
case 4: case 5:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); throw IsarError('Unknown property with id $propertyId');
@ -619,6 +634,160 @@ extension LoggerMessageQueryFilter
}); });
} }
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'details',
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'details',
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'details',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'details',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'details',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'details',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'details',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'details',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'details',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'details',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'details',
value: '',
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition>
detailsIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'details',
value: '',
));
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition> idEqualTo( QueryBuilder<LoggerMessage, LoggerMessage, QAfterFilterCondition> idEqualTo(
Id value) { Id value) {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
@ -913,6 +1082,18 @@ extension LoggerMessageQuerySortBy
}); });
} }
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByDetails() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'details', Sort.asc);
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByDetailsDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'details', Sort.desc);
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByLevel() { QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> sortByLevel() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'level', Sort.asc); return query.addSortBy(r'level', Sort.asc);
@ -979,6 +1160,18 @@ extension LoggerMessageQuerySortThenBy
}); });
} }
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenByDetails() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'details', Sort.asc);
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenByDetailsDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'details', Sort.desc);
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenById() { QueryBuilder<LoggerMessage, LoggerMessage, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc); return query.addSortBy(r'id', Sort.asc);
@ -1038,6 +1231,13 @@ extension LoggerMessageQueryWhereDistinct
}); });
} }
QueryBuilder<LoggerMessage, LoggerMessage, QDistinct> distinctByDetails(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'details', caseSensitive: caseSensitive);
});
}
QueryBuilder<LoggerMessage, LoggerMessage, QDistinct> distinctByLevel() { QueryBuilder<LoggerMessage, LoggerMessage, QDistinct> distinctByLevel() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'level'); return query.addDistinctBy(r'level');
@ -1078,6 +1278,12 @@ extension LoggerMessageQueryProperty
}); });
} }
QueryBuilder<LoggerMessage, String?, QQueryOperations> detailsProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'details');
});
}
QueryBuilder<LoggerMessage, LogLevel, QQueryOperations> levelProperty() { QueryBuilder<LoggerMessage, LogLevel, QQueryOperations> levelProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'level'); return query.addPropertyName(r'level');

View File

@ -90,7 +90,7 @@ class AssetService {
return allAssets; return allAssets;
} catch (error, stack) { } catch (error, stack) {
log.severe( log.severe(
'Error while getting remote assets: ${error.toString()}', 'Error while getting remote assets',
error, error,
stack, stack,
); );
@ -117,7 +117,7 @@ class AssetService {
); );
return true; return true;
} catch (error, stack) { } catch (error, stack) {
log.severe("Error deleteAssets ${error.toString()}", error, stack); log.severe("Error while deleting assets", error, stack);
} }
return false; return false;
} }

View File

@ -12,7 +12,7 @@ import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package. /// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to the database and onto console, using `debugPrint` method. /// The logs are written to the database and onto console, using `debugPrint` method.
/// ///
/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property /// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
/// in the class. /// in the class.
/// ///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog /// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
@ -58,6 +58,7 @@ class ImmichLogger {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}'); debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage( final lm = LoggerMessage(
message: record.message, message: record.message,
details: record.error?.toString(),
level: record.level.toLogLevel(), level: record.level.toLogLevel(),
createdAt: record.time, createdAt: record.time,
context1: record.loggerName, context1: record.loggerName,

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -41,7 +42,8 @@ class ShareService {
if (res.statusCode != 200) { if (res.statusCode != 200) {
_log.severe( _log.severe(
"Asset download failed with status - ${res.statusCode} and response - ${res.body}", "Asset download for ${asset.fileName} failed",
res.toLoggerString(),
); );
continue; continue;
} }
@ -68,7 +70,7 @@ class ShareService {
); );
return true; return true;
} catch (error) { } catch (error) {
_log.severe("Share failed with error $error"); _log.severe("Share failed", error);
} }
return false; return false;
} }

View File

@ -140,7 +140,7 @@ class SyncService {
try { try {
await _db.writeTxn(() => a.put(_db)); await _db.writeTxn(() => a.put(_db));
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to put new asset into db: $e"); _log.severe("Failed to put new asset into db", e);
return false; return false;
} }
return true; return true;
@ -173,7 +173,7 @@ class SyncService {
} }
return false; return false;
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e"); _log.severe("Failed to sync remote assets to db", e);
} }
return null; return null;
} }
@ -232,7 +232,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(toAdd + toUpdate); await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db: $e"); _log.severe("Failed to sync remote assets to db", e);
} }
await _updateUserAssetsETag(user, now); await _updateUserAssetsETag(user, now);
return true; return true;
@ -364,7 +364,7 @@ class SyncService {
}); });
_log.info("Synced changes of remote album ${album.name} to DB"); _log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to sync remote album to database $e"); _log.severe("Failed to sync remote album to database", e);
} }
if (album.shared || dto.shared) { if (album.shared || dto.shared) {
@ -441,7 +441,7 @@ class SyncService {
assert(ok); assert(ok);
_log.info("Removed local album $album from DB"); _log.info("Removed local album $album from DB");
} catch (e) { } catch (e) {
_log.severe("Failed to remove local album $album from DB"); _log.severe("Failed to remove local album $album from DB", e);
} }
} }
@ -577,7 +577,7 @@ class SyncService {
}); });
_log.info("Synced changes of local album ${ape.name} to DB"); _log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB: $e"); _log.severe("Failed to update synced album ${ape.name} in DB", e);
} }
return true; return true;
@ -623,7 +623,7 @@ class SyncService {
}); });
_log.info("Fast synced local album ${ape.name} to DB"); _log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB: $e"); _log.severe("Failed to fast sync local album ${ape.name} to DB", e);
return false; return false;
} }
@ -656,7 +656,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}"); _log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB: $e"); _log.severe("Failed to add new local album ${ape.name} to DB", e);
} }
} }
@ -706,9 +706,7 @@ class SyncService {
}); });
_log.info("Upserted ${assets.length} assets into the DB"); _log.info("Upserted ${assets.length} assets into the DB");
} on IsarError catch (e) { } on IsarError catch (e) {
_log.severe( _log.severe("Failed to upsert ${assets.length} assets into the DB", e);
"Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
);
// give details on the errors // give details on the errors
assets.sort(Asset.compareByOwnerChecksum); assets.sort(Asset.compareByOwnerChecksum);
final inDb = await _db.assets.getAllByOwnerIdChecksum( final inDb = await _db.assets.getAllByOwnerIdChecksum(
@ -776,7 +774,7 @@ class SyncService {
}); });
return true; return true;
} catch (e) { } catch (e) {
_log.severe("Failed to remove all local albums and assets: $e"); _log.severe("Failed to remove all local albums and assets", e);
return false; return false;
} }
} }

View File

@ -42,7 +42,7 @@ class UserService {
final dto = await _apiService.userApi.getAllUsers(isAll); final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromUserDto).toList(); return dto?.map(User.fromUserDto).toList();
} catch (e) { } catch (e) {
_log.warning("Failed get all users:\n$e"); _log.warning("Failed get all users", e);
return null; return null;
} }
} }
@ -65,7 +65,7 @@ class UserService {
), ),
); );
} catch (e) { } catch (e) {
_log.warning("Failed to upload profile image:\n$e"); _log.warning("Failed to upload profile image", e);
return null; return null;
} }
} }

View File

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class DelayedLoadingIndicator extends StatelessWidget {
/// The delay to avoid showing the loading indicator
final Duration delay;
/// Defaults to using the [ImmichLoadingIndicator]
final Widget? child;
/// An optional fade in duration to animate the loading
final Duration? fadeInDuration;
const DelayedLoadingIndicator({
super.key,
this.delay = const Duration(seconds: 3),
this.child,
this.fadeInDuration,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
}
return Container(key: const ValueKey('hiding'));
},
),
);
}
}

View File

@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme; var isDarkTheme = context.isDarkTheme;
buildStackMessage(String stackTrace) { buildTextWithCopyButton(String header, String text) {
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.only(bottom: 8.0),
child: Text( child: Text(
"STACK TRACES", header,
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: context.primaryColor, color: context.primaryColor,
@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
), ),
IconButton( IconButton(
onPressed: () { onPressed: () {
Clipboard.setData(ClipboardData(text: stackTrace)) Clipboard.setData(ClipboardData(text: text)).then((_) {
.then((_) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: SelectableText( child: SelectableText(
stackTrace, text,
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
fontFamily: "Inconsolata",
),
),
),
),
],
),
);
}
buildLogMessage(String message) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"MESSAGE",
style: TextStyle(
fontSize: 12.0,
color: context.primaryColor,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: message)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Copied to clipboard",
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
),
);
});
},
icon: Icon(
Icons.copy,
size: 16.0,
color: context.primaryColor,
),
),
],
),
Container(
decoration: BoxDecoration(
color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
borderRadius: BorderRadius.circular(15.0),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SelectableText(
message,
style: const TextStyle( style: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
body: SafeArea( body: SafeArea(
child: ListView( child: ListView(
children: [ children: [
buildLogMessage(logMessage.message), buildTextWithCopyButton("MESSAGE", logMessage.message),
if (logMessage.details != null)
buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
if (logMessage.context1 != null) if (logMessage.context1 != null)
buildLogContext1(logMessage.context1.toString()), buildLogContext1(logMessage.context1.toString()),
if (logMessage.context2 != null) if (logMessage.context2 != null)
buildStackMessage(logMessage.context2.toString()), buildTextWithCopyButton(
"STACK TRACE",
logMessage.context2.toString(),
),
], ],
), ),
), ),

View File

@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: const Text(
"Logs - ${logMessages.value.length}", "Logs",
style: const TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16.0, fontSize: 16.0,
), ),
@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
dense: true, dense: true,
tileColor: getTileColor(logMessage.level), tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10, minLeadingWidth: 10,
title: Text.rich( title: Text(
TextSpan( truncateLogMessage(logMessage.message, 4),
children: [ style: const TextStyle(
TextSpan( fontSize: 14.0,
text: "#$index ", fontFamily: "Inconsolata",
style: TextStyle(
color: isDarkTheme ? Colors.white70 : Colors.grey[600],
fontSize: 14.0,
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: truncateLogMessage(logMessage.message, 4),
style: const TextStyle(
fontSize: 14.0,
),
),
],
), ),
style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
), ),
subtitle: Text( subtitle: Text(
"[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}", "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
style: TextStyle( style: TextStyle(
fontSize: 12.0, fontSize: 12.0,
color: Colors.grey[600], color: Colors.grey[600],

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
final _loadingEntry = OverlayEntry( final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square( builder: (context) => SizedBox.square(
@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox( child: DecoratedBox(
decoration: decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)), BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
child: const Center(child: ImmichLoadingIndicator()), child: const Center(
child: DelayedLoadingIndicator(
delay: Duration(seconds: 1),
fadeInDuration: Duration(milliseconds: 400),
),
),
), ),
), ),
); );
@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
class _LoadingOverlayState class _LoadingOverlayState
extends HookState<ValueNotifier<bool>, _LoadingOverlay> { extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
late final _isProcessing = ValueNotifier(false)..addListener(_listener); late final _isLoading = ValueNotifier(false)..addListener(_listener);
OverlayEntry? overlayEntry; OverlayEntry? _loadingOverlay;
void _listener() { void _listener() {
setState(() { setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isProcessing.value) { if (_isLoading.value) {
overlayEntry?.remove(); _loadingOverlay?.remove();
overlayEntry = _loadingEntry; _loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry); Overlay.of(context).insert(_loadingEntry);
} else { } else {
overlayEntry?.remove(); _loadingOverlay?.remove();
overlayEntry = null; _loadingOverlay = null;
} }
}); });
}); });
@ -47,17 +52,17 @@ class _LoadingOverlayState
@override @override
ValueNotifier<bool> build(BuildContext context) { ValueNotifier<bool> build(BuildContext context) {
return _isProcessing; return _isLoading;
} }
@override @override
void dispose() { void dispose() {
_isProcessing.dispose(); _isLoading.dispose();
super.dispose(); super.dispose();
} }
@override @override
Object? get debugValue => _isProcessing.value; Object? get debugValue => _isLoading.value;
@override @override
String get debugLabel => 'useProcessingOverlay<>'; String get debugLabel => 'useProcessingOverlay<>';

View File

@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
deviceIsOffline = true; deviceIsOffline = true;
log.fine("Device seems to be offline upon launch"); log.fine("Device seems to be offline upon launch");
} else { } else {
log.severe(e); log.severe("Failed to resolve endpoint", e);
} }
} catch (e) { } catch (e) {
log.severe(e); log.severe("Failed to resolve endpoint", e);
} }
try { try {
@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
ref.read(authenticationProvider.notifier).logout(); ref.read(authenticationProvider.notifier).logout();
log.severe( log.severe(
'Cannot set success login info: $error', 'Cannot set success login info',
error, error,
stackTrace, stackTrace,
); );

View File

@ -108,6 +108,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md doc/PersonWithFacesResponseDto.md
doc/PlacesResponseDto.md
doc/QueueStatusDto.md doc/QueueStatusDto.md
doc/ReactionLevel.md doc/ReactionLevel.md
doc/ReactionType.md doc/ReactionType.md
@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart lib/model/person_with_faces_response_dto.dart
lib/model/places_response_dto.dart
lib/model/queue_status_dto.dart lib/model/queue_status_dto.dart
lib/model/reaction_level.dart lib/model/reaction_level.dart
lib/model/reaction_type.dart lib/model/reaction_type.dart
@ -485,6 +487,7 @@ test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart test/person_with_faces_response_dto_test.dart
test/places_response_dto_test.dart
test/queue_status_dto_test.dart test/queue_status_dto_test.dart
test/reaction_level_test.dart test/reaction_level_test.dart
test/reaction_type_test.dart test/reaction_type_test.dart

View File

@ -166,6 +166,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md) - [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionLevel](doc//ReactionLevel.md) - [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md) - [ReactionType](doc//ReactionType.md)

19
mobile/openapi/doc/PlacesResponseDto.md generated Normal file
View File

@ -0,0 +1,19 @@
# openapi.model.PlacesResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**admin1name** | **String** | | [optional]
**admin2name** | **String** | | [optional]
**latitude** | **num** | |
**longitude** | **num** | |
**name** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -14,6 +14,7 @@ Method | HTTP request | Description
[**search**](SearchApi.md#search) | **GET** /search | [**search**](SearchApi.md#search) | **GET** /search |
[**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata | [**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata |
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person | [**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
[**searchPlaces**](SearchApi.md#searchplaces) | **GET** /search/places |
[**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart | [**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart |
@ -316,6 +317,61 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **searchPlaces**
> List<PlacesResponseDto> searchPlaces(name)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SearchApi();
final name = name_example; // String |
try {
final result = api_instance.searchPlaces(name);
print(result);
} catch (e) {
print('Exception when calling SearchApi->searchPlaces: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**name** | **String**| |
### Return type
[**List<PlacesResponseDto>**](PlacesResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **searchSmart** # **searchSmart**
> SearchResponseDto searchSmart(smartSearchDto) > SearchResponseDto searchSmart(smartSearchDto)

View File

@ -142,6 +142,7 @@ part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart'; part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart'; part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart'; part 'model/person_with_faces_response_dto.dart';
part 'model/places_response_dto.dart';
part 'model/queue_status_dto.dart'; part 'model/queue_status_dto.dart';
part 'model/reaction_level.dart'; part 'model/reaction_level.dart';
part 'model/reaction_type.dart'; part 'model/reaction_type.dart';

View File

@ -360,6 +360,58 @@ class SearchApi {
return null; return null;
} }
/// Performs an HTTP 'GET /search/places' operation and returns the [Response].
/// Parameters:
///
/// * [String] name (required):
Future<Response> searchPlacesWithHttpInfo(String name,) async {
// ignore: prefer_const_declarations
final path = r'/search/places';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'name', name));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] name (required):
Future<List<PlacesResponseDto>?> searchPlaces(String name,) async {
final response = await searchPlacesWithHttpInfo(name,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PlacesResponseDto>') as List)
.cast<PlacesResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -366,6 +366,8 @@ class ApiClient {
return PersonUpdateDto.fromJson(value); return PersonUpdateDto.fromJson(value);
case 'PersonWithFacesResponseDto': case 'PersonWithFacesResponseDto':
return PersonWithFacesResponseDto.fromJson(value); return PersonWithFacesResponseDto.fromJson(value);
case 'PlacesResponseDto':
return PlacesResponseDto.fromJson(value);
case 'QueueStatusDto': case 'QueueStatusDto':
return QueueStatusDto.fromJson(value); return QueueStatusDto.fromJson(value);
case 'ReactionLevel': case 'ReactionLevel':

View File

@ -0,0 +1,148 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PlacesResponseDto {
/// Returns a new [PlacesResponseDto] instance.
PlacesResponseDto({
this.admin1name,
this.admin2name,
required this.latitude,
required this.longitude,
required this.name,
});
///
/// 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? admin1name;
///
/// 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? admin2name;
num latitude;
num longitude;
String name;
@override
bool operator ==(Object other) => identical(this, other) || other is PlacesResponseDto &&
other.admin1name == admin1name &&
other.admin2name == admin2name &&
other.latitude == latitude &&
other.longitude == longitude &&
other.name == name;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(admin1name == null ? 0 : admin1name!.hashCode) +
(admin2name == null ? 0 : admin2name!.hashCode) +
(latitude.hashCode) +
(longitude.hashCode) +
(name.hashCode);
@override
String toString() => 'PlacesResponseDto[admin1name=$admin1name, admin2name=$admin2name, latitude=$latitude, longitude=$longitude, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.admin1name != null) {
json[r'admin1name'] = this.admin1name;
} else {
// json[r'admin1name'] = null;
}
if (this.admin2name != null) {
json[r'admin2name'] = this.admin2name;
} else {
// json[r'admin2name'] = null;
}
json[r'latitude'] = this.latitude;
json[r'longitude'] = this.longitude;
json[r'name'] = this.name;
return json;
}
/// Returns a new [PlacesResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PlacesResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PlacesResponseDto(
admin1name: mapValueOfType<String>(json, r'admin1name'),
admin2name: mapValueOfType<String>(json, r'admin2name'),
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
name: mapValueOfType<String>(json, r'name')!,
);
}
return null;
}
static List<PlacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PlacesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PlacesResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PlacesResponseDto> mapFromJson(dynamic json) {
final map = <String, PlacesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PlacesResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PlacesResponseDto-objects as value to a dart map
static Map<String, List<PlacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PlacesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PlacesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'latitude',
'longitude',
'name',
};
}

View File

@ -0,0 +1,47 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for PlacesResponseDto
void main() {
// final instance = PlacesResponseDto();
group('test PlacesResponseDto', () {
// String admin1name
test('to test the property `admin1name`', () async {
// TODO
});
// String admin2name
test('to test the property `admin2name`', () async {
// TODO
});
// num latitude
test('to test the property `latitude`', () async {
// TODO
});
// num longitude
test('to test the property `longitude`', () async {
// TODO
});
// String name
test('to test the property `name`', () async {
// TODO
});
});
}

View File

@ -42,6 +42,11 @@ void main() {
// TODO // TODO
}); });
//Future<List<PlacesResponseDto>> searchPlaces(String name) async
test('test searchPlaces', () async {
// TODO
});
//Future<SearchResponseDto> searchSmart(SmartSearchDto smartSearchDto) async //Future<SearchResponseDto> searchSmart(SmartSearchDto smartSearchDto) async
test('test searchSmart', () async { test('test searchSmart', () async {
// TODO // TODO

View File

@ -413,10 +413,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file name: file
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.4" version: "7.0.0"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@ -569,10 +569,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_udid name: flutter_udid
sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84" sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "3.0.0"
flutter_web_auth: flutter_web_auth:
dependency: "direct main" dependency: "direct main"
description: description:
@ -619,10 +619,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: geolocator name: geolocator
sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02 sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.1.0" version: "11.0.0"
geolocator_android: geolocator_android:
dependency: transitive dependency: transitive
description: description:
@ -651,10 +651,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: geolocator_web name: geolocator_web
sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58" sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "3.0.0"
geolocator_windows: geolocator_windows:
dependency: transitive dependency: transitive
description: description:
@ -860,6 +860,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.1" version: "4.8.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
url: "https://pub.dev"
source: hosted
version: "2.0.1"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
url: "https://pub.dev"
source: hosted
version: "2.0.1"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@ -907,18 +931,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.16" version: "0.12.16+1"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.8.0"
meta: meta:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
@ -1002,10 +1026,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path name: path
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.8.3" version: "1.9.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1138,10 +1162,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.4"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1170,10 +1194,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: process name: process
sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.4" version: "5.0.2"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@ -1298,10 +1322,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_linux name: shared_preferences_linux
sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.2"
shared_preferences_platform_interface: shared_preferences_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1322,10 +1346,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_windows name: shared_preferences_windows
sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.2"
shelf: shelf:
dependency: transitive dependency: transitive
description: description:
@ -1631,10 +1655,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.10.0" version: "13.0.0"
wakelock_plus: wakelock_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1679,10 +1703,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webdriver name: webdriver
sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.2" version: "3.0.3"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@ -32,8 +32,8 @@ dependencies:
git: git:
url: https://github.com/maplibre/flutter-maplibre-gl.git url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
geolocator: ^10.1.0 # used to move to current location in map view geolocator: ^11.0.0 # used to move to current location in map view
flutter_udid: ^2.1.1 flutter_udid: ^3.0.0
package_info_plus: ^5.0.1 package_info_plus: ^5.0.1
url_launcher: ^6.2.4 url_launcher: ^6.2.4
http: 0.13.5 http: 0.13.5

View File

@ -4691,6 +4691,50 @@
] ]
} }
}, },
"/search/places": {
"get": {
"operationId": "searchPlaces",
"parameters": [
{
"name": "name",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PlacesResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Search"
]
}
},
"/search/smart": { "/search/smart": {
"post": { "post": {
"operationId": "searchSmart", "operationId": "searchSmart",
@ -8756,6 +8800,31 @@
], ],
"type": "object" "type": "object"
}, },
"PlacesResponseDto": {
"properties": {
"admin1name": {
"type": "string"
},
"admin2name": {
"type": "string"
},
"latitude": {
"type": "number"
},
"longitude": {
"type": "number"
},
"name": {
"type": "string"
}
},
"required": [
"latitude",
"longitude",
"name"
],
"type": "object"
},
"QueueStatusDto": { "QueueStatusDto": {
"properties": { "properties": {
"isActive": { "isActive": {

View File

@ -2994,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
*/ */
'thumbnailPath': string; 'thumbnailPath': string;
} }
/**
*
* @export
* @interface PlacesResponseDto
*/
export interface PlacesResponseDto {
/**
*
* @type {string}
* @memberof PlacesResponseDto
*/
'admin1name'?: string;
/**
*
* @type {string}
* @memberof PlacesResponseDto
*/
'admin2name'?: string;
/**
*
* @type {number}
* @memberof PlacesResponseDto
*/
'latitude': number;
/**
*
* @type {number}
* @memberof PlacesResponseDto
*/
'longitude': number;
/**
*
* @type {string}
* @memberof PlacesResponseDto
*/
'name': string;
}
/** /**
* *
* @export * @export
@ -15447,6 +15484,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPlaces: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('searchPlaces', 'name', name)
const localVarPath = `/search/places`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -15584,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url; const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
}, },
/**
*
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async searchPlaces(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PlacesResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPlaces(name, options);
const index = configuration?.serverIndex ?? 0;
const operationBasePath = operationServerMap['SearchApi.searchPlaces']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
/** /**
* *
* @param {SmartSearchDto} smartSearchDto * @param {SmartSearchDto} smartSearchDto
@ -15651,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PlacesResponseDto>> {
return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters. * @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
@ -15817,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
readonly withHidden?: boolean readonly withHidden?: boolean
} }
/**
* Request parameters for searchPlaces operation in SearchApi.
* @export
* @interface SearchApiSearchPlacesRequest
*/
export interface SearchApiSearchPlacesRequest {
/**
*
* @type {string}
* @memberof SearchApiSearchPlaces
*/
readonly name: string
}
/** /**
* Request parameters for searchSmart operation in SearchApi. * Request parameters for searchSmart operation in SearchApi.
* @export * @export
@ -15893,6 +16010,17 @@ export class SearchApi extends BaseAPI {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig) {
return SearchApiFp(this.configuration).searchPlaces(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters. * @param {SearchApiSearchSmartRequest} requestParameters Request parameters.

View File

@ -646,6 +646,13 @@ export type MetadataSearchDto = {
withPeople?: boolean; withPeople?: boolean;
withStacked?: boolean; withStacked?: boolean;
}; };
export type PlacesResponseDto = {
admin1name?: string;
admin2name?: string;
latitude: number;
longitude: number;
name: string;
};
export type SmartSearchDto = { export type SmartSearchDto = {
city?: string; city?: string;
country?: string; country?: string;
@ -2198,6 +2205,18 @@ export function searchPerson({ name, withHidden }: {
...opts ...opts
})); }));
} }
export function searchPlaces({ name }: {
name: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PlacesResponseDto[];
}>(`/search/places${QS.query(QS.explode({
name
}))}`, {
...opts
}));
}
export function searchSmart({ smartSearchDto }: { export function searchSmart({ smartSearchDto }: {
smartSearchDto: SmartSearchDto; smartSearchDto: SmartSearchDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {

View File

@ -0,0 +1,15 @@
import { HttpError } from '@oazapfts/runtime';
export interface ApiExceptionResponse {
message: string;
error?: string;
statusCode: number;
}
export interface ApiHttpError extends HttpError {
data: ApiExceptionResponse;
}
export function isHttpError(error: unknown): error is ApiHttpError {
return error instanceof HttpError;
}

View File

@ -1 +1,2 @@
export * from './fetch-client'; export * from './fetch-client';
export * from './fetch-errors';

View File

@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt';
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt'); export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt'); export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt'); export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt');
export const geodataCitites500Path = join(GEODATA_ROOT_PATH, citiesFile); export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
const image: Record<string, string[]> = { const image: Record<string, string[]> = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],

View File

@ -1,4 +1,4 @@
import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities'; import { AssetEntity, AssetFaceEntity, AssetType, GeodataPlacesEntity, SmartInfoEntity } from '@app/infra/entities';
import { Paginated } from '../domain.util'; import { Paginated } from '../domain.util';
export const ISearchRepository = 'ISearchRepository'; export const ISearchRepository = 'ISearchRepository';
@ -186,4 +186,5 @@ export interface ISearchRepository {
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>; searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>; searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>; upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
} }

View File

@ -1,5 +1,5 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
import { AssetType } from '@app/infra/entities'; import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
@ -241,6 +241,12 @@ export class SearchDto {
size?: number; size?: number;
} }
export class SearchPlacesDto {
@IsString()
@IsNotEmpty()
name!: string;
}
export class SearchPeopleDto { export class SearchPeopleDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ -251,3 +257,21 @@ export class SearchPeopleDto {
@Optional() @Optional()
withHidden?: boolean; withHidden?: boolean;
} }
export class PlacesResponseDto {
name!: string;
latitude!: number;
longitude!: number;
admin1name?: string;
admin2name?: string;
}
export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto {
return {
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
admin1name: place.admin1Name,
admin2name: place.admin2Name,
};
}

View File

@ -16,7 +16,15 @@ import {
SearchStrategy, SearchStrategy,
} from '../repositories'; } from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config'; import { FeatureFlag, SystemConfigCore } from '../system-config';
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; import {
MetadataSearchDto,
PlacesResponseDto,
SearchDto,
SearchPeopleDto,
SearchPlacesDto,
SmartSearchDto,
mapPlaces,
} from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto'; import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto'; import { SearchResponseDto } from './response-dto';
@ -41,6 +49,11 @@ export class SearchService {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
} }
async searchPlaces(dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
const places = await this.searchRepository.searchPlaces(dto.name);
return places.map((place) => mapPlaces(place));
}
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> { async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
await this.configCore.requireFeature(FeatureFlag.SEARCH); await this.configCore.requireFeature(FeatureFlag.SEARCH);
const options = { maxFields: 12, minAssetsPerField: 5 }; const options = { maxFields: 12, minAssetsPerField: 5 };
@ -182,26 +195,22 @@ export class SearchService {
} }
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> { async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
if (dto.type === SearchSuggestionType.COUNTRY) { switch (dto.type) {
return this.metadataRepository.getCountries(auth.user.id); case SearchSuggestionType.COUNTRY: {
return this.metadataRepository.getCountries(auth.user.id);
}
case SearchSuggestionType.STATE: {
return this.metadataRepository.getStates(auth.user.id, dto.country);
}
case SearchSuggestionType.CITY: {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
}
case SearchSuggestionType.CAMERA_MAKE: {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
}
case SearchSuggestionType.CAMERA_MODEL: {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
}
} }
if (dto.type === SearchSuggestionType.STATE) {
return this.metadataRepository.getStates(auth.user.id, dto.country);
}
if (dto.type === SearchSuggestionType.CITY) {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
}
if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
}
if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
}
return [];
} }
} }

View File

@ -117,7 +117,7 @@ export class StorageTemplateService {
return true; return true;
} }
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination), this.assetRepository.getAll(pagination, { withExif: true }),
); );
const users = await this.userRepository.getList(); const users = await this.userRepository.getList();

View File

@ -2,9 +2,11 @@ import {
AuthDto, AuthDto,
MetadataSearchDto, MetadataSearchDto,
PersonResponseDto, PersonResponseDto,
PlacesResponseDto,
SearchDto, SearchDto,
SearchExploreResponseDto, SearchExploreResponseDto,
SearchPeopleDto, SearchPeopleDto,
SearchPlacesDto,
SearchResponseDto, SearchResponseDto,
SearchService, SearchService,
SmartSearchDto, SmartSearchDto,
@ -48,6 +50,11 @@ export class SearchController {
return this.service.searchPerson(auth, dto); return this.service.searchPerson(auth, dto);
} }
@Get('places')
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
return this.service.searchPlaces(dto);
}
@Get('suggestions') @Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> { getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto); return this.service.getSearchSuggestions(auth, dto);

View File

@ -1,10 +0,0 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_admin1')
export class GeodataAdmin1Entity {
@PrimaryColumn({ type: 'varchar' })
key!: string;
@Column({ type: 'varchar' })
name!: string;
}

View File

@ -1,10 +0,0 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_admin2')
export class GeodataAdmin2Entity {
@PrimaryColumn({ type: 'varchar' })
key!: string;
@Column({ type: 'varchar' })
name!: string;
}

View File

@ -1,6 +1,4 @@
import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity'; import { Column, Entity, PrimaryColumn } from 'typeorm';
import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
@Entity('geodata_places', { synchronize: false }) @Entity('geodata_places', { synchronize: false })
export class GeodataPlacesEntity { export class GeodataPlacesEntity {
@ -21,7 +19,7 @@ export class GeodataPlacesEntity {
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)', // asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
// type: 'earth', // type: 'earth',
// }) // })
earthCoord!: unknown; // earthCoord!: unknown;
@Column({ type: 'char', length: 2 }) @Column({ type: 'char', length: 2 })
countryCode!: string; countryCode!: string;
@ -32,27 +30,14 @@ export class GeodataPlacesEntity {
@Column({ type: 'varchar', length: 80, nullable: true }) @Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string; admin2Code!: string;
@Column({ @Column({ type: 'varchar', nullable: true })
type: 'varchar', admin1Name!: string;
generatedType: 'STORED',
asExpression: `"countryCode" || '.' || "admin1Code"`,
nullable: true,
})
admin1Key!: string;
@ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false }) @Column({ type: 'varchar', nullable: true })
admin1!: GeodataAdmin1Entity; admin2Name!: string;
@Column({ @Column({ type: 'varchar', nullable: true })
type: 'varchar', alternateNames!: string;
generatedType: 'STORED',
asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
nullable: true,
})
admin2Key!: string;
@ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
admin2!: GeodataAdmin2Entity;
@Column({ type: 'date' }) @Column({ type: 'date' })
modificationDate!: Date; modificationDate!: Date;

View File

@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity';
import { AssetEntity } from './asset.entity'; import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity'; import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity'; import { ExifEntity } from './exif.entity';
import { GeodataAdmin1Entity } from './geodata-admin1.entity';
import { GeodataAdmin2Entity } from './geodata-admin2.entity';
import { GeodataPlacesEntity } from './geodata-places.entity'; import { GeodataPlacesEntity } from './geodata-places.entity';
import { LibraryEntity } from './library.entity'; import { LibraryEntity } from './library.entity';
import { MoveEntity } from './move.entity'; import { MoveEntity } from './move.entity';
@ -32,8 +30,6 @@ export * from './asset-stack.entity';
export * from './asset.entity'; export * from './asset.entity';
export * from './audit.entity'; export * from './audit.entity';
export * from './exif.entity'; export * from './exif.entity';
export * from './geodata-admin1.entity';
export * from './geodata-admin2.entity';
export * from './geodata-places.entity'; export * from './geodata-places.entity';
export * from './library.entity'; export * from './library.entity';
export * from './move.entity'; export * from './move.entity';
@ -59,8 +55,6 @@ export const databaseEntities = [
AuditEntity, AuditEntity,
ExifEntity, ExifEntity,
GeodataPlacesEntity, GeodataPlacesEntity,
GeodataAdmin1Entity,
GeodataAdmin2Entity,
MoveEntity, MoveEntity,
PartnerEntity, PartnerEntity,
PersonEntity, PersonEntity,

View File

@ -0,0 +1,152 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class GeodataLocationSearch1708059341865 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`);
// https://stackoverflow.com/a/11007216
await queryRunner.query(`
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
RETURN unaccent('unaccent', $1)`);
await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin1Name" varchar`);
await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin2Name" varchar`);
await queryRunner.query(`
UPDATE geodata_places
SET "admin1Name" = admin1.name
FROM geodata_admin1 admin1
WHERE admin1.key = "admin1Key"`);
await queryRunner.query(`
UPDATE geodata_places
SET "admin2Name" = admin2.name
FROM geodata_admin2 admin2
WHERE admin2.key = "admin2Key"`);
await queryRunner.query(`DROP TABLE geodata_admin1 CASCADE`);
await queryRunner.query(`DROP TABLE geodata_admin2 CASCADE`);
await queryRunner.query(`
ALTER TABLE geodata_places
DROP COLUMN "admin1Key",
DROP COLUMN "admin2Key"`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_name
ON geodata_places
USING gin (f_unaccent(name) gin_trgm_ops)`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_admin1_name
ON geodata_places
USING gin (f_unaccent("admin1Name") gin_trgm_ops)`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_admin2_name
ON geodata_places
USING gin (f_unaccent("admin2Name") gin_trgm_ops)`);
await queryRunner.query(
`
DELETE FROM "typeorm_metadata"
WHERE
"type" = $1 AND
"name" = $2 AND
"database" = $3 AND
"schema" = $4 AND
"table" = $5`,
['GENERATED_COLUMN', 'admin1Key', 'immich', 'public', 'geodata_places'],
);
await queryRunner.query(
`
DELETE FROM "typeorm_metadata"
WHERE
"type" = $1 AND
"name" = $2 AND
"database" = $3 AND
"schema" = $4 AND
"table" = $5`,
['GENERATED_COLUMN', 'admin2Key', 'immich', 'public', 'geodata_places'],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "geodata_admin1" (
"key" character varying NOT NULL,
"name" character varying NOT NULL,
CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key")
)`);
await queryRunner.query(`
CREATE TABLE "geodata_admin2" (
"key" character varying NOT NULL,
"name" character varying NOT NULL,
CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key")
)`);
await queryRunner.query(`
ALTER TABLE geodata_places
ADD COLUMN "admin1Key" character varying
GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED,
ADD COLUMN "admin2Key" character varying
GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED`);
await queryRunner.query(
`
INSERT INTO "geodata_admin1"
SELECT DISTINCT
"admin1Key" AS "key",
"admin1Name" AS "name"
FROM geodata_places
WHERE "admin1Name" IS NOT NULL`,
);
await queryRunner.query(
`
INSERT INTO "geodata_admin2"
SELECT DISTINCT
"admin2Key" AS "key",
"admin2Name" AS "name"
FROM geodata_places
WHERE "admin2Name" IS NOT NULL`,
);
await queryRunner.query(`
UPDATE geodata_places
SET "admin1Name" = admin1.name
FROM geodata_admin1 admin1
WHERE admin1.key = "admin1Key"`);
await queryRunner.query(`
UPDATE geodata_places
SET "admin2Name" = admin2.name
FROM geodata_admin2 admin2
WHERE admin2.key = "admin2Key";`);
await queryRunner.query(
`
INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
VALUES ($1, $2, $3, $4, $5, $6)`,
['immich', 'public', 'geodata_places', 'GENERATED_COLUMN', 'admin1Key', '"countryCode" || \'.\' || "admin1Code"'],
);
await queryRunner.query(
`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
VALUES ($1, $2, $3, $4, $5, $6)`,
[
'immich',
'public',
'geodata_places',
'GENERATED_COLUMN',
'admin2Key',
'"countryCode" || \'.\' || "admin1Code" || \'.\' || "admin2Code"',
],
);
}
}

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class GeonamesEnhancement1708116312820 implements MigrationInterface {
name = 'GeonamesEnhancement1708116312820'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "alternateNames" varchar`);
await queryRunner.query(`
CREATE INDEX idx_geodata_places_admin2_alternate_names
ON geodata_places
USING gin (f_unaccent("alternateNames") gin_trgm_ops)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`);
}
}

View File

@ -2,7 +2,7 @@ import {
citiesFile, citiesFile,
geodataAdmin1Path, geodataAdmin1Path,
geodataAdmin2Path, geodataAdmin2Path,
geodataCitites500Path, geodataCities500Path,
geodataDatePath, geodataDatePath,
GeoPoint, GeoPoint,
IMetadataRepository, IMetadataRepository,
@ -10,13 +10,7 @@ import {
ISystemMetadataRepository, ISystemMetadataRepository,
ReverseGeocodeResult, ReverseGeocodeResult,
} from '@app/domain'; } from '@app/domain';
import { import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
ExifEntity,
GeodataAdmin1Entity,
GeodataAdmin2Entity,
GeodataPlacesEntity,
SystemMetadataKey,
} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs'; import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline'; import * as readLine from 'node:readline';
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm'; import { DataSource, QueryRunner, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { DummyValue, GenerateSql } from '../infra.util'; import { DummyValue, GenerateSql } from '../infra.util';
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
export class MetadataRepository implements IMetadataRepository { export class MetadataRepository implements IMetadataRepository {
constructor( constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>, @InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>, @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>, @Inject(ISystemMetadataRepository)
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>, private readonly systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
) {} ) {}
@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository {
return; return;
} }
this.logger.log('Importing geodata to database from file');
await this.importGeodata(); await this.importGeodata();
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
const admin1 = await this.loadAdmin(geodataAdmin1Path);
const admin2 = await this.loadAdmin(geodataAdmin2Path);
try { try {
await queryRunner.startTransaction(); await queryRunner.startTransaction();
await this.loadCities500(queryRunner); await queryRunner.manager.clear(GeodataPlacesEntity);
await this.loadAdmin1(queryRunner); await this.loadCities500(queryRunner, admin1, admin2);
await this.loadAdmin2(queryRunner);
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();
} catch (error) { } catch (error) {
@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository {
} }
} }
private async loadGeodataToTableFromFile<T extends GeoEntity>( private async loadGeodataToTableFromFile(
queryRunner: QueryRunner, queryRunner: QueryRunner,
lineToEntityMapper: (lineSplit: string[]) => T, lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
filePath: string, filePath: string,
entity: GeoEntityClass,
) { ) {
if (!existsSync(filePath)) { if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`); this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`); throw new Error(`Geodata file ${filePath} not found`);
} }
await queryRunner.manager.clear(entity);
const input = createReadStream(filePath); const input = createReadStream(filePath);
let buffer: DeepPartial<T>[] = []; let bufferGeodata: QueryDeepPartialEntity<GeodataPlacesEntity>[] = [];
const lineReader = readLine.createInterface({ input: input }); const lineReader = readLine.createInterface({ input });
for await (const line of lineReader) { for await (const line of lineReader) {
const lineSplit = line.split('\t'); const lineSplit = line.split('\t');
buffer.push(lineToEntityMapper(lineSplit)); const geoData = lineToEntityMapper(lineSplit);
if (buffer.length > 1000) { bufferGeodata.push(geoData);
await queryRunner.manager.save(buffer); if (bufferGeodata.length > 1000) {
buffer = []; await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
bufferGeodata = [];
} }
} }
await queryRunner.manager.save(buffer); await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
} }
private async loadCities500(queryRunner: QueryRunner) { private async loadCities500(
await this.loadGeodataToTableFromFile<GeodataPlacesEntity>( queryRunner: QueryRunner,
admin1Map: Map<string, string>,
admin2Map: Map<string, string>,
) {
await this.loadGeodataToTableFromFile(
queryRunner, queryRunner,
(lineSplit: string[]) => (lineSplit: string[]) =>
this.geodataPlacesRepository.create({ this.geodataPlacesRepository.create({
id: Number.parseInt(lineSplit[0]), id: Number.parseInt(lineSplit[0]),
name: lineSplit[1], name: lineSplit[1],
alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]), latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]), longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8], countryCode: lineSplit[8],
admin1Code: lineSplit[10], admin1Code: lineSplit[10],
admin2Code: lineSplit[11], admin2Code: lineSplit[11],
modificationDate: lineSplit[18], modificationDate: lineSplit[18],
admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}), }),
geodataCitites500Path, geodataCities500Path,
GeodataPlacesEntity,
); );
} }
private async loadAdmin1(queryRunner: QueryRunner) { private async loadAdmin(filePath: string) {
await this.loadGeodataToTableFromFile<GeodataAdmin1Entity>( if (!existsSync(filePath)) {
queryRunner, this.logger.error(`Geodata file ${filePath} not found`);
(lineSplit: string[]) => throw new Error(`Geodata file ${filePath} not found`);
this.geodataAdmin1Repository.create({ }
key: lineSplit[0],
name: lineSplit[1],
}),
geodataAdmin1Path,
GeodataAdmin1Entity,
);
}
private async loadAdmin2(queryRunner: QueryRunner) { const input = createReadStream(filePath);
await this.loadGeodataToTableFromFile<GeodataAdmin2Entity>( const lineReader = readLine.createInterface({ input: input });
queryRunner,
(lineSplit: string[]) => const adminMap = new Map<string, string>();
this.geodataAdmin2Repository.create({ for await (const line of lineReader) {
key: lineSplit[0], const lineSplit = line.split('\t');
name: lineSplit[1], adminMap.set(lineSplit[0], lineSplit[1]);
}), }
geodataAdmin2Path,
GeodataAdmin2Entity, return adminMap;
);
} }
async teardown() { async teardown() {
@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository {
const response = await this.geodataPlacesRepository const response = await this.geodataPlacesRepository
.createQueryBuilder('geoplaces') .createQueryBuilder('geoplaces')
.leftJoinAndSelect('geoplaces.admin1', 'admin1')
.leftJoinAndSelect('geoplaces.admin2', 'admin2')
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point) .where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")') .orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
.limit(1) .limit(1)
@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
const { countryCode, name: city, admin1, admin2 } = response; const { countryCode, name: city, admin1Name, admin2Name } = response;
const country = getName(countryCode, 'en') ?? null; const country = getName(countryCode, 'en') ?? null;
const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name); const stateParts = [admin2Name, admin1Name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null; const state = stateParts.length > 0 ? stateParts.join(', ') : null;
return { country, state, city }; return { country, state, city };

View File

@ -12,7 +12,13 @@ import {
SmartSearchOptions, SmartSearchOptions,
} from '@app/domain'; } from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant'; import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities'; import {
AssetEntity,
AssetFaceEntity,
GeodataPlacesEntity,
SmartInfoEntity,
SmartSearchEntity,
} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>, @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
) { ) {
this.faceColumns = this.assetFaceRepository.manager.connection this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity) .getMetadata(AssetFaceEntity)
@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository {
})); }));
} }
@GenerateSql({ params: [DummyValue.STRING] })
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
return await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
.where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
.orderBy(
`
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
`,
)
.setParameters({ placeName })
.limit(20)
.getMany();
}
async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> { async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] }); await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) { if (!smartInfo.assetId || !embedding) {

View File

@ -238,3 +238,37 @@ FROM
WHERE WHERE
res.distance <= $3 res.distance <= $3
COMMIT COMMIT
-- SearchRepository.searchPlaces
SELECT
"geoplaces"."id" AS "geoplaces_id",
"geoplaces"."name" AS "geoplaces_name",
"geoplaces"."longitude" AS "geoplaces_longitude",
"geoplaces"."latitude" AS "geoplaces_latitude",
"geoplaces"."countryCode" AS "geoplaces_countryCode",
"geoplaces"."admin1Code" AS "geoplaces_admin1Code",
"geoplaces"."admin2Code" AS "geoplaces_admin2Code",
"geoplaces"."admin1Name" AS "geoplaces_admin1Name",
"geoplaces"."admin2Name" AS "geoplaces_admin2Name",
"geoplaces"."alternateNames" AS "geoplaces_alternateNames",
"geoplaces"."modificationDate" AS "geoplaces_modificationDate"
FROM
"geodata_places" "geoplaces"
WHERE
f_unaccent (name) %>> f_unaccent ($1)
OR f_unaccent ("admin2Name") %>> f_unaccent ($1)
OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
ORDER BY
COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0) + COALESCE(
f_unaccent ("admin2Name") <->>> f_unaccent ($1),
0
) + COALESCE(
f_unaccent ("admin1Name") <->>> f_unaccent ($1),
0
) + COALESCE(
f_unaccent ("alternateNames") <->>> f_unaccent ($1),
0
) ASC
LIMIT
20

View File

@ -7,5 +7,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
searchSmart: jest.fn(), searchSmart: jest.fn(),
searchFaces: jest.fn(), searchFaces: jest.fn(),
upsert: jest.fn(), upsert: jest.fn(),
searchPlaces: jest.fn(),
}; };
}; };

8
web/package-lock.json generated
View File

@ -32,7 +32,7 @@
"@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.1.8", "@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.0", "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2", "@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3", "@testing-library/svelte": "^4.0.3",
@ -1859,9 +1859,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.5.0", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
"integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", "integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {

View File

@ -27,7 +27,7 @@
"@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.1.8", "@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.0", "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2", "@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3", "@testing-library/svelte": "^4.0.3",

View File

@ -1,34 +1,22 @@
import { isHttpError } from '@immich/sdk';
import type { HandleClientError } from '@sveltejs/kit'; import type { HandleClientError } from '@sveltejs/kit';
import type { AxiosError, AxiosResponse } from 'axios';
const LOG_PREFIX = '[hooks.client.ts]'; const LOG_PREFIX = '[hooks.client.ts]';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?'; const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
const parseError = (error: unknown) => { const parseError = (error: unknown) => {
const httpError = error as AxiosError; const httpError = isHttpError(error) ? error : undefined;
const request = httpError?.request as Request & { path: string }; const statusCode = httpError?.status || httpError?.data?.statusCode || 500;
const response = httpError?.response as AxiosResponse<{ const message = httpError?.data?.message || (httpError?.data && String(httpError.data)) || httpError?.message;
message: string;
statusCode: number;
error: string;
}>;
let code = response?.data?.statusCode || response?.status || httpError.code || '500'; console.log({
if (response) { status: statusCode,
code += ` - ${response.data?.error || response.statusText}`; response: httpError?.data || 'No data',
} });
if (request && response) {
console.log({
status: response.status,
url: `${request.method} ${request.path}`,
response: response.data || 'No data',
});
}
return { return {
message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE, message: message || DEFAULT_MESSAGE,
code, code: statusCode,
stack: httpError?.stack, stack: httpError?.stack,
}; };
}; };

View File

@ -112,8 +112,8 @@
desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives." desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives."
bind:value={config.machineLearning.facialRecognition.minScore} bind:value={config.machineLearning.facialRecognition.minScore}
step="0.1" step="0.1"
min="0" min={0}
max="1" max={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minScore !== isEdited={config.machineLearning.facialRecognition.minScore !==
savedConfig.machineLearning.facialRecognition.minScore} savedConfig.machineLearning.facialRecognition.minScore}
@ -125,8 +125,8 @@
desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible." desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible."
bind:value={config.machineLearning.facialRecognition.maxDistance} bind:value={config.machineLearning.facialRecognition.maxDistance}
step="0.1" step="0.1"
min="0" min={0}
max="2" max={2}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.maxDistance !== isEdited={config.machineLearning.facialRecognition.maxDistance !==
savedConfig.machineLearning.facialRecognition.maxDistance} savedConfig.machineLearning.facialRecognition.maxDistance}
@ -138,7 +138,7 @@
desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person." desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person."
bind:value={config.machineLearning.facialRecognition.minFaces} bind:value={config.machineLearning.facialRecognition.minFaces}
step="1" step="1"
min="1" min={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minFaces !== isEdited={config.machineLearning.facialRecognition.minFaces !==
savedConfig.machineLearning.facialRecognition.minFaces} savedConfig.machineLearning.facialRecognition.minFaces}

View File

@ -84,7 +84,26 @@
}; };
</script> </script>
<section class="dark:text-immich-dark-fg"> <section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration: 500 }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
>Storage Template
</a>
and its
<a
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
>implications
</a>
</p>
</div>
{#await getTemplateOptions() then} {#await getTemplateOptions() then}
<div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}"> <div id="directory-path-builder" class="flex flex-col gap-4 {minified ? '' : 'ml-4 mt-4'}">
<SettingSwitch <SettingSwitch

View File

@ -1,23 +1,16 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
import { slideshowStore } from '$lib/stores/slideshow.store'; import { slideshowStore } from '$lib/stores/slideshow.store';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte'; import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiPause,
mdiPlay,
mdiShuffle,
mdiShuffleDisabled,
} from '@mdi/js';
const { slideshowShuffle } = slideshowStore; const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore;
const { restartProgress, stopProgress } = slideshowStore;
let progressBarStatus: ProgressBarStatus; let progressBarStatus: ProgressBarStatus;
let progressBar: ProgressBar; let progressBar: ProgressBar;
let showSettings = false;
let unsubscribeRestart: () => void; let unsubscribeRestart: () => void;
let unsubscribeStop: () => void; let unsubscribeStop: () => void;
@ -54,25 +47,27 @@
</script> </script>
<div class="m-4 flex gap-2"> <div class="m-4 flex gap-2">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" /> <CircleIconButton buttonSize="50" icon={mdiClose} on:click={() => dispatch('close')} title="Exit Slideshow" />
{#if $slideshowShuffle}
<CircleIconButton icon={mdiShuffle} on:click={() => ($slideshowShuffle = false)} title="Shuffle" />
{:else}
<CircleIconButton icon={mdiShuffleDisabled} on:click={() => ($slideshowShuffle = true)} title="No shuffle" />
{/if}
<CircleIconButton <CircleIconButton
buttonSize="50"
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause} icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} on:click={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/> />
<CircleIconButton icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" /> <CircleIconButton buttonSize="50" icon={mdiChevronLeft} on:click={() => dispatch('prev')} title="Previous" />
<CircleIconButton icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" /> <CircleIconButton buttonSize="50" icon={mdiChevronRight} on:click={() => dispatch('next')} title="Next" />
<CircleIconButton buttonSize="50" icon={mdiCog} on:click={() => (showSettings = !showSettings)} title="Next" />
</div> </div>
{#if showSettings}
<SlideshowSettings onClose={() => (showSettings = false)} />
{/if}
<ProgressBar <ProgressBar
autoplay autoplay
hidden={!$showProgressBar}
duration={$slideshowDelay}
bind:this={progressBar} bind:this={progressBar}
bind:status={progressBarStatus} bind:status={progressBarStatus}
on:done={() => dispatch('next')} on:done={() => dispatch('next')}
duration={5000}
/> />

View File

@ -3,7 +3,7 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants'; import { ProjectionType } from '$lib/constants';
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/time-to-seconds'; import { timeToSeconds } from '$lib/utils/date-time';
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,

View File

@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
interface $$Props extends HTMLInputAttributes {
type: 'date' | 'datetime-local';
}
export let value: $$Props['value'] = undefined;
$: updatedValue = value;
</script>
<input
{...$$restProps}
{value}
on:input={(e) => {
updatedValue = e.currentTarget.value;
// Only update when value is not empty to prevent resetting the input
if (updatedValue !== '') {
value = updatedValue;
}
}}
on:blur={() => (value = updatedValue)}
/>

View File

@ -6,6 +6,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let name: string; export let name: string;
export let roundedBottom = true;
export let isSearching: boolean; export let isSearching: boolean;
export let placeholder: string; export let placeholder: string;
@ -17,7 +18,11 @@
}; };
</script> </script>
<div class="flex items-center text-sm rounded-lg bg-gray-100 p-2 dark:bg-gray-700 gap-2 place-items-center h-full"> <div
class="flex items-center text-sm {roundedBottom
? 'rounded-lg'
: 'rounded-t-lg'} bg-gray-100 p-2 dark:bg-gray-700 gap-2 place-items-center h-full"
>
<button on:click={() => dispatch('search', { force: true })}> <button on:click={() => dispatch('search', { force: true })}>
<div class="w-fit"> <div class="w-fit">
<Icon path={mdiMagnify} size="24" /> <Icon path={mdiMagnify} size="24" />

View File

@ -0,0 +1,72 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let checked = false;
export let disabled = false;
const dispatch = createEventDispatcher<{ toggle: boolean }>();
const onToggle = (event: Event) => dispatch('toggle', (event.target as HTMLInputElement).checked);
</script>
<label class="relative inline-block h-[10px] w-[36px] flex-none">
<input
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
type="checkbox"
bind:checked
on:click={onToggle}
{disabled}
/>
{#if disabled}
<span class="slider slider-disabled cursor-not-allowed" />
{:else}
<span class="slider slider-enabled cursor-pointer" />
{/if}
</label>
<style>
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 34px;
}
input:disabled {
cursor: not-allowed;
}
.slider:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 0px;
right: 0px;
bottom: -4px;
background-color: gray;
-webkit-transition: 0.4s;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider:before {
-webkit-transform: translateX(18px);
-ms-transform: translateX(18px);
transform: translateX(18px);
background-color: #4250af;
}
input:checked + .slider-disabled {
background-color: gray;
}
input:checked + .slider-enabled {
background-color: #adcbfa;
}
</style>

View File

@ -100,7 +100,7 @@
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}"> <div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)} {#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28"> <div class="h-24 w-24 md:h-28 md:w-28">
<button class="p-2" on:click={() => changePersonToMerge(person)}> <button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<ImageThumbnail <ImageThumbnail
border={true} border={true}
circle circle

View File

@ -4,6 +4,7 @@
import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiCake } from '@mdi/js'; import { mdiCake } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import DateInput from '../elements/date-input.svelte';
export let birthDate: string; export let birthDate: string;
@ -37,7 +38,7 @@
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off"> <form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<input <DateInput
class="immich-form-input" class="immich-form-input"
id="birthDate" id="birthDate"
name="birthDate" name="birthDate"

View File

@ -4,14 +4,15 @@
import { signUpAdmin } from '@immich/sdk'; import { signUpAdmin } from '@immich/sdk';
import { handleError } from '../../utils/handle-error'; import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte';
let errorMessage: string; let errorMessage: string;
let password = ''; let password = '';
let confirmPassowrd = ''; let confirmPassword = '';
let canRegister = false; let canRegister = false;
$: { $: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) { if (password !== confirmPassword && confirmPassword.length > 0) {
errorMessage = 'Password does not match'; errorMessage = 'Password does not match';
canRegister = false; canRegister = false;
} else { } else {
@ -56,28 +57,12 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">Admin Password</label> <label class="immich-form-label" for="password">Admin Password</label>
<input <PasswordField id="password" name="password" bind:password autocomplete="new-password" />
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label> <label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
<input <PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={confirmPassowrd}
/>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte';
import { updateUser, type UserResponseDto } from '@immich/sdk'; import { updateUser, type UserResponseDto } from '@immich/sdk';
export let user: UserResponseDto; export let user: UserResponseDto;
@ -46,28 +47,12 @@
<form on:submit|preventDefault={changePassword} method="post" class="mt-5 flex flex-col gap-5"> <form on:submit|preventDefault={changePassword} method="post" class="mt-5 flex flex-col gap-5">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">New Password</label> <label class="immich-form-label" for="password">New Password</label>
<input <PasswordField id="password" bind:password autocomplete="new-password" />
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label> <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input <PasswordField id="confirmPassword" bind:password={passwordConfirm} autocomplete="new-password" />
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="current-password"
required
bind:value={passwordConfirm}
/>
</div> </div>
{#if errorMessage} {#if errorMessage}

View File

@ -6,12 +6,13 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte'; import ImmichLogo from '../shared-components/immich-logo.svelte';
import PasswordField from '../shared-components/password-field.svelte';
let error: string; let error: string;
let success: string; let success: string;
let password = ''; let password = '';
let confirmPassowrd = ''; let confirmPassword = '';
let canCreateUser = false; let canCreateUser = false;
let quotaSize: number | undefined; let quotaSize: number | undefined;
@ -20,7 +21,7 @@
$: quotaSizeWarning = quotaSize && convertToBytes(Number(quotaSize), 'GiB') > $serverInfo.diskSizeRaw; $: quotaSizeWarning = quotaSize && convertToBytes(Number(quotaSize), 'GiB') > $serverInfo.diskSizeRaw;
$: { $: {
if (password !== confirmPassowrd && confirmPassowrd.length > 0) { if (password !== confirmPassword && confirmPassword.length > 0) {
error = 'Password does not match'; error = 'Password does not match';
canCreateUser = false; canCreateUser = false;
} else { } else {
@ -91,19 +92,12 @@
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label> <label class="immich-form-label" for="password">Password</label>
<input class="immich-form-input" id="password" name="password" type="password" required bind:value={password} /> <PasswordField id="password" name="password" bind:password autocomplete="new-password" />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label> <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input <PasswordField id="confirmPassword" bind:password={confirmPassword} autocomplete="new-password" />
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="m-4 flex flex-col gap-2">

View File

@ -9,6 +9,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte';
export let onSuccess: () => unknown | Promise<unknown>; export let onSuccess: () => unknown | Promise<unknown>;
export let onFirstLogin: () => unknown | Promise<unknown>; export let onFirstLogin: () => unknown | Promise<unknown>;
@ -112,15 +113,7 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label> <label class="immich-form-label" for="password">Password</label>
<input <PasswordField id="password" bind:password autocomplete="current-password" />
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="current-password"
bind:value={password}
required
/>
</div> </div>
<div class="my-5 flex w-full"> <div class="my-5 flex w-full">

View File

@ -8,6 +8,7 @@
import LinkButton from '../elements/buttons/link-button.svelte'; import LinkButton from '../elements/buttons/link-button.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import DateInput from '../elements/date-input.svelte';
export let settings: MapSettings; export let settings: MapSettings;
let customDateRange = !!settings.dateAfter || !!settings.dateBefore; let customDateRange = !!settings.dateAfter || !!settings.dateBefore;
@ -38,7 +39,7 @@
<div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4"> <div in:fly={{ y: 10, duration: 200 }} class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-8"> <div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label> <label class="immich-form-label shrink-0 text-sm" for="date-after">Date after</label>
<input <DateInput
class="immich-form-input w-40" class="immich-form-input w-40"
type="date" type="date"
id="date-after" id="date-after"
@ -48,7 +49,7 @@
</div> </div>
<div class="flex items-center justify-between gap-8"> <div class="flex items-center justify-between gap-8">
<label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label> <label class="immich-form-label shrink-0 text-sm" for="date-before">Date before</label>
<input class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} /> <DateInput class="immich-form-input w-40" type="date" id="date-before" bind:value={settings.dateBefore} />
</div> </div>
<div class="flex justify-center text-xs"> <div class="flex justify-center text-xs">
<LinkButton <LinkButton

View File

@ -456,9 +456,9 @@
asset={$viewingAsset} asset={$viewingAsset}
{isShared} {isShared}
{album} {album}
on:previous={() => handlePrevious()} on:previous={handlePrevious}
on:next={() => handleNext()} on:next={handleNext}
on:close={() => handleClose()} on:close={handleClose}
on:action={({ detail: action }) => handleAction(action.type, action.asset)} on:action={({ detail: action }) => handleAction(action.type, action.asset)}
/> />
{/if} {/if}

Some files were not shown because too many files have changed in this diff Show More