diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 66f4d7b9f..24919347f 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -44,22 +44,13 @@ Below is an example config for Apache2 site configuration. ``` - ServerName + ServerName + 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 - - - ProxyPass http://localhost:2283/ - ProxyPassReverse http://localhost:2283/ - ``` + +**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. diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 9fe651a52..44155ac83 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -904,9 +904,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.0.tgz", - "integrity": "sha512-e5Y5uK5NNoQMQaNitGQQjo9FoA5ZNcu7Bn6pH+dxUf48u6po1cX38kFBYUHZ9GNVkF4JLbncE0WeWwTw+nLrxg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz", + "integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -927,17 +927,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.3.0" + "vitest": "1.3.1" } }, "node_modules/@vitest/expect": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.0.tgz", - "integrity": "sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz", + "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==", "dev": true, "dependencies": { - "@vitest/spy": "1.3.0", - "@vitest/utils": "1.3.0", + "@vitest/spy": "1.3.1", + "@vitest/utils": "1.3.1", "chai": "^4.3.10" }, "funding": { @@ -945,12 +945,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.0.tgz", - "integrity": "sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz", + "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==", "dev": true, "dependencies": { - "@vitest/utils": "1.3.0", + "@vitest/utils": "1.3.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -959,9 +959,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.0.tgz", - "integrity": "sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz", + "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -973,9 +973,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.0.tgz", - "integrity": "sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz", + "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -985,9 +985,9 @@ } }, "node_modules/@vitest/utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.0.tgz", - "integrity": "sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz", + "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -2551,9 +2551,9 @@ } }, "node_modules/vite": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", - "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz", + "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==", "dev": true, "dependencies": { "esbuild": "^0.19.3", @@ -2606,9 +2606,9 @@ } }, "node_modules/vite-node": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.0.tgz", - "integrity": "sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz", + "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -2642,16 +2642,16 @@ } }, "node_modules/vitest": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.0.tgz", - "integrity": "sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz", + "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==", "dev": true, "dependencies": { - "@vitest/expect": "1.3.0", - "@vitest/runner": "1.3.0", - "@vitest/snapshot": "1.3.0", - "@vitest/spy": "1.3.0", - "@vitest/utils": "1.3.0", + "@vitest/expect": "1.3.1", + "@vitest/runner": "1.3.1", + "@vitest/snapshot": "1.3.1", + "@vitest/spy": "1.3.1", + "@vitest/utils": "1.3.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", @@ -2665,7 +2665,7 @@ "tinybench": "^2.5.1", "tinypool": "^0.8.2", "vite": "^5.0.0", - "vite-node": "1.3.0", + "vite-node": "1.3.1", "why-is-node-running": "^2.2.2" }, "bin": { @@ -2680,8 +2680,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.3.0", - "@vitest/ui": "1.3.0", + "@vitest/browser": "1.3.1", + "@vitest/ui": "1.3.1", "happy-dom": "*", "jsdom": "*" }, diff --git a/machine-learning/ann/__init__.py b/machine-learning/ann/__init__.py index 0793d1011..e69de29bb 100644 --- a/machine-learning/ann/__init__.py +++ b/machine-learning/ann/__init__.py @@ -1 +0,0 @@ -from .ann import Ann, is_available diff --git a/machine-learning/ann/ann.py b/machine-learning/ann/ann.py index 94f665bfc..148d5ba10 100644 --- a/machine-learning/ann/ann.py +++ b/machine-learning/ann/ann.py @@ -32,8 +32,7 @@ T = TypeVar("T", covariant=True) class Newable(Protocol[T]): - def new(self) -> None: - ... + def new(self) -> None: ... class _Singleton(type, Newable[T]): diff --git a/machine-learning/app/models/base.py b/machine-learning/app/models/base.py index 6097c7c98..ad48624b4 100644 --- a/machine-learning/app/models/base.py +++ b/machine-learning/app/models/base.py @@ -1,18 +1,16 @@ from __future__ import annotations +import os from abc import ABC, abstractmethod from pathlib import Path from shutil import rmtree from typing import Any -import onnx import onnxruntime as ort 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 -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 ..schemas import ModelRuntime, ModelType @@ -113,63 +111,25 @@ class InferenceModel(ABC): ) 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: case ".armnn": session = AnnSession(model_path) case ".onnx": - session = ort.InferenceSession( - model_path.as_posix(), - sess_options=self.sess_options, - providers=self.providers, - provider_options=self.provider_options, - ) + cwd = os.getcwd() + try: + os.chdir(model_path.parent) + session = ort.InferenceSession( + model_path.as_posix(), + sess_options=self.sess_options, + providers=self.providers, + provider_options=self.provider_options, + ) + finally: + os.chdir(cwd) case _: raise ValueError(f"Unsupported model file type: {model_path.suffix}") 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 def model_type(self) -> ModelType: return self._model_type @@ -205,6 +165,14 @@ class InferenceModel(ABC): def providers_default(self) -> list[str]: available_providers = set(ort.get_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] @property @@ -224,15 +192,7 @@ class InferenceModel(ABC): case "CPUExecutionProvider" | "CUDAExecutionProvider": option = {"arena_extend_strategy": "kSameAsRequested"} case "OpenVINOExecutionProvider": - try: - 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 = {} + option = {"device_type": "GPU_FP32"} case _: option = {} options.append(option) diff --git a/machine-learning/app/models/constants.py b/machine-learning/app/models/constants.py index 18965d2b1..b112e9279 100644 --- a/machine-learning/app/models/constants.py +++ b/machine-learning/app/models/constants.py @@ -54,9 +54,6 @@ _INSIGHTFACE_MODELS = { SUPPORTED_PROVIDERS = ["CUDAExecutionProvider", "OpenVINOExecutionProvider", "CPUExecutionProvider"] -STATIC_INPUT_PROVIDERS = ["OpenVINOExecutionProvider"] - - def is_openclip(model_name: str) -> bool: return clean_name(model_name) in _OPENCLIP_MODELS diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index cf941c1bb..0f802997f 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -1,4 +1,5 @@ import json +import os from io import BytesIO from pathlib import Path from random import randint @@ -44,11 +45,23 @@ class TestBase: assert encoder.providers == self.CUDA_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") 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) def test_sets_providers_in_correct_order(self, providers: list[str]) -> None: encoder = OpenCLIPEncoder("ViT-B-32__openai") @@ -67,22 +80,14 @@ class TestBase: assert encoder.providers == providers - def test_sets_default_provider_options(self) -> 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: + def test_sets_default_provider_options(self, 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", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) assert encoder.provider_options == [ - {"device_id": "GPU.0"}, + {"device_type": "GPU_FP32"}, {"arena_extend_strategy": "kSameAsRequested"}, ] @@ -237,12 +242,12 @@ class TestBase: mock_model_path.is_file.return_value = True mock_model_path.suffix = ".armnn" 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._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: mock_armnn_path = mocker.Mock() @@ -256,6 +261,7 @@ class TestBase: mock_ann = mocker.patch("app.models.base.AnnSession") mock_ort = mocker.patch("app.models.base.ort.InferenceSession") + mocker.patch("app.models.base.os.chdir") encoder = OpenCLIPEncoder("ViT-B-32__openai") encoder._make_session(mock_armnn_path) @@ -278,6 +284,26 @@ class TestBase: mock_ann.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: mock_snapshot_download = mocker.patch("app.models.base.snapshot_download") diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 40402886f..c22668380 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -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 \ TRANSFORMERS_CACHE=/cache \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index c8ae6c741..782269c8c 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -1250,13 +1250,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.26.0" +version = "0.27.0" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, - {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, ] [package.dependencies] @@ -2465,13 +2465,13 @@ files = [ [[package]] name = "pytest" -version = "8.0.0" +version = "8.0.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"}, - {file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"}, + {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, + {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, ] [package.dependencies] @@ -2836,28 +2836,28 @@ files = [ [[package]] name = "ruff" -version = "0.2.1" +version = "0.2.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" 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.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"}, - {file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"}, - {file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"}, - {file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"}, - {file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"}, - {file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"}, - {file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"}, + {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.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"}, + {file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"}, + {file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"}, + {file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"}, + {file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"}, + {file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"}, + {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, ] [[package]] diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 750ca65f2..fec5c7213 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -82,10 +82,10 @@ warn_untyped_fields = true [tool.ruff] line-length = 120 target-version = "py311" -select = ["E", "F", "I"] -[tool.ruff.per-file-ignores] -"test_main.py" = ["F403"] +[tool.ruff.lint] +select = ["E", "F", "I"] +per-file-ignores = { "test_main.py" = ["F403"] } [tool.black] line-length = 120 diff --git a/mobile/lib/extensions/asyncvalue_extensions.dart b/mobile/lib/extensions/asyncvalue_extensions.dart index c616835a8..af02ff13c 100644 --- a/mobile/lib/extensions/asyncvalue_extensions.dart +++ b/mobile/lib/extensions/asyncvalue_extensions.dart @@ -30,7 +30,7 @@ extension LogOnError on AsyncValue { } if (hasError && !hasValue) { - _asyncErrorLogger.severe("$error", error, stackTrace); + _asyncErrorLogger.severe('Could not load value', error, stackTrace); return onError?.call(error, stackTrace) ?? ScaffoldErrorBody(errorMsg: error?.toString()); } diff --git a/mobile/lib/extensions/response_extensions.dart b/mobile/lib/extensions/response_extensions.dart new file mode 100644 index 000000000..7fec41d07 --- /dev/null +++ b/mobile/lib/extensions/response_extensions.dart @@ -0,0 +1,5 @@ +import 'package:http/http.dart'; + +extension LoggerExtension on Response { + String toLoggerString() => "Status: $statusCode $reasonPhrase\n\n$body"; +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 293867fb3..f20cf7ecc 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -73,15 +73,14 @@ Future initApp() async { FlutterError.onError = (details) { FlutterError.presentError(details); log.severe( - 'FlutterError - Catch all error: ${details.toString()} - ${details.exception} - ${details.library} - ${details.context} - ${details.stack}', - details, + 'FlutterError - Catch all', + "${details.toString()}\nException: ${details.exception}\nLibrary: ${details.library}\nContext: ${details.context}", details.stack, ); }; PlatformDispatcher.instance.onError = (error, stack) { - log.severe('PlatformDispatcher - Catch all error: $error', error, stack); - debugPrint("PlatformDispatcher - Catch all error: $error $stack"); + log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; diff --git a/mobile/lib/mixins/error_logger.mixin.dart b/mobile/lib/mixins/error_logger.mixin.dart index 38837a716..9b2bc6f98 100644 --- a/mobile/lib/mixins/error_logger.mixin.dart +++ b/mobile/lib/mixins/error_logger.mixin.dart @@ -10,13 +10,14 @@ mixin ErrorLoggerMixin { /// Else, logs the error to the overrided logger and returns an AsyncError<> AsyncFuture guardError( Future Function() fn, { + required String errorMessage, Level logLevel = Level.SEVERE, }) async { try { final result = await fn(); return AsyncData(result); } catch (error, stackTrace) { - logger.log(logLevel, "$error", error, stackTrace); + logger.log(logLevel, errorMessage, error, stackTrace); return AsyncError(error, stackTrace); } } @@ -26,12 +27,13 @@ mixin ErrorLoggerMixin { Future logError( Future Function() fn, { required T defaultValue, + required String errorMessage, Level logLevel = Level.SEVERE, }) async { try { return await fn(); } catch (error, stackTrace) { - logger.log(logLevel, "$error", error, stackTrace); + logger.log(logLevel, errorMessage, error, stackTrace); } return defaultValue; } diff --git a/mobile/lib/modules/activities/services/activity.service.dart b/mobile/lib/modules/activities/services/activity.service.dart index db35c17ae..cde98f73a 100644 --- a/mobile/lib/modules/activities/services/activity.service.dart +++ b/mobile/lib/modules/activities/services/activity.service.dart @@ -24,6 +24,7 @@ class ActivityService with ErrorLoggerMixin { return list != null ? list.map(Activity.fromDto).toList() : []; }, defaultValue: [], + errorMessage: "Failed to get all activities for album $albumId", ); } @@ -35,6 +36,7 @@ class ActivityService with ErrorLoggerMixin { return dto?.comments ?? 0; }, defaultValue: 0, + errorMessage: "Failed to statistics for album $albumId", ); } @@ -45,6 +47,7 @@ class ActivityService with ErrorLoggerMixin { return true; }, defaultValue: false, + errorMessage: "Failed to delete activity", ); } @@ -54,21 +57,24 @@ class ActivityService with ErrorLoggerMixin { String? assetId, String? comment, }) async { - return guardError(() async { - final dto = await _apiService.activityApi.createActivity( - ActivityCreateDto( - albumId: albumId, - type: type == ActivityType.comment - ? ReactionType.comment - : ReactionType.like, - assetId: assetId, - comment: comment, - ), - ); - if (dto != null) { - return Activity.fromDto(dto); - } - throw NoResponseDtoError(); - }); + return guardError( + () async { + final dto = await _apiService.activityApi.createActivity( + ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ), + ); + if (dto != null) { + return Activity.fromDto(dto); + } + throw NoResponseDtoError(); + }, + errorMessage: "Failed to create $type for album $albumId", + ); } } diff --git a/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart new file mode 100644 index 000000000..224eb838e --- /dev/null +++ b/mobile/lib/modules/asset_viewer/hooks/chewiew_controller_hook.dart @@ -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 { + 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? 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 _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, + ); + }); + } +} diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart index db527c6e2..54682fdee 100644 --- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart +++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart @@ -1,6 +1,7 @@ import 'dart:io'; 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/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; @@ -39,7 +40,8 @@ class ImageViewerService { final failedResponse = imageResponse.statusCode != 200 ? imageResponse : motionReponse; _log.severe( - "Motion asset download failed with status - ${failedResponse.statusCode} and response - ${failedResponse.body}", + "Motion asset download failed", + failedResponse.toLoggerString(), ); return false; } @@ -75,9 +77,7 @@ class ImageViewerService { .downloadFileWithHttpInfo(asset.remoteId!); if (res.statusCode != 200) { - _log.severe( - "Asset download failed with status - ${res.statusCode} and response - ${res.body}", - ); + _log.severe("Asset download failed", res.toLoggerString()); return false; } @@ -98,7 +98,7 @@ class ImageViewerService { return entity != null; } } catch (error, stack) { - _log.severe("Error saving file ${error.toString()}", error, stack); + _log.severe("Error saving downloaded asset", error, stack); return false; } finally { // Clear temp files diff --git a/mobile/lib/modules/asset_viewer/ui/description_input.dart b/mobile/lib/modules/asset_viewer/ui/description_input.dart index c5972a822..c5bae07cd 100644 --- a/mobile/lib/modules/asset_viewer/ui/description_input.dart +++ b/mobile/lib/modules/asset_viewer/ui/description_input.dart @@ -48,7 +48,7 @@ class DescriptionInput extends HookConsumerWidget { ); } catch (error, stack) { hasError.value = true; - _log.severe("Error updating description $error", error, stack); + _log.severe("Error updating description", error, stack); ImmichToast.show( context: context, msg: "description_input_submit_error".tr(), diff --git a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart index 781e84e45..bfc45b8a3 100644 --- a/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart +++ b/mobile/lib/modules/asset_viewer/ui/video_player_controls.dart @@ -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_value_provider.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'; class VideoPlayerControls extends ConsumerStatefulWidget { @@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState children: [ if (_displayBufferingIndicator) const Center( - child: ImmichLoadingIndicator(), + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 400), + ), ) else _buildHitArea(), @@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState @override void dispose() { _dispose(); + super.dispose(); } @@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState final oldController = _chewieController; _chewieController = ChewieController.of(context); controller = chewieController.videoPlayerController; + _latestValue = controller.value; if (oldController != chewieController) { _dispose(); @@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState return GestureDetector( onTap: () { - if (_latestValue.isPlaying) { - ref.read(showControlsProvider.notifier).show = false; - } else { + if (!_latestValue.isPlaying) { _playPause(); - ref.read(showControlsProvider.notifier).show = false; } + ref.read(showControlsProvider.notifier).show = false; }, child: CenterPlayButton( backgroundColor: Colors.black54, @@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState } Future _initialize() async { + ref.read(showControlsProvider.notifier).show = false; _mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute))); - controller.addListener(_updateState); _latestValue = controller.value; + controller.addListener(_updateState); if (controller.value.isPlaying || chewieController.autoPlay) { _startHideTimer(); @@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState } void _startHideTimer() { - final hideControlsTimer = chewieController.hideControlsTimer.isNegative - ? ChewieController.defaultHideControlsTimer - : chewieController.hideControlsTimer; + final hideControlsTimer = chewieController.hideControlsTimer; + _hideTimer?.cancel(); _hideTimer = Timer(hideControlsTimer, () { ref.read(showControlsProvider.notifier).show = false; }); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 59dfef816..a78c085de 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -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) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -794,7 +806,9 @@ class GalleryViewerPage extends HookConsumerWidget { minScale: 1.0, basePosition: Alignment.center, child: VideoViewerPage( - onPlaying: () => isPlayingVideo.value = true, + onPlaying: () { + isPlayingVideo.value = true; + }, onPaused: () => WidgetsBinding.instance.addPostFrameCallback( (_) => isPlayingVideo.value = false, diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index 72aa397f6..0967bf52a 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -1,23 +1,15 @@ -import 'dart:io'; - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:chewie/chewie.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; -import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.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/store.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'; +import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; @RoutePage() // ignore: must_be_immutable -class VideoViewerPage extends HookConsumerWidget { +class VideoViewerPage extends HookWidget { final Asset asset; final bool isMotionVideo; final Widget? placeholder; @@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { - if (asset.isLocal && asset.livePhotoVideoId == null) { - final AsyncValue videoFile = ref.watch(_fileFamily(asset.local!)); - 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((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 createState() => _VideoPlayerState(); -} - -class _VideoPlayerState extends State { - 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 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( + Widget build(BuildContext context) { + final controller = useChewieController( + asset, controlsSafeAreaMinimum: const EdgeInsets.only( bottom: 100, ), - showOptions: true, - showControlsOnInitialize: false, - videoPlayerController: videoPlayerController, - autoPlay: true, - autoInitialize: true, - allowFullScreen: false, - allowedScreenSleep: false, - showControls: widget.showControls && !widget.isMotionVideo, + placeholder: placeholder, + showControls: showControls && !isMotionVideo, + hideControlsTimer: hideControlsTimer, 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(), - ), - ], - ), - ), - ); - } - } } diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index a175a17de..68c6bf9e6 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -245,7 +245,7 @@ class BackupNotifier extends StateNotifier { } catch (e, stack) { log.severe( "Failed to get thumbnail for album ${album.name}", - e.toString(), + e, stack, ); } diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 23dcd5053..e01002433 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -108,7 +108,7 @@ class AuthenticationNotifier extends StateNotifier { .then((_) => log.info("Logout was successful for $userEmail")) .onError( (error, stackTrace) => - log.severe("Error logging out $userEmail", error, stackTrace), + log.severe("Logout failed for $userEmail", error, stackTrace), ); await Future.wait([ @@ -129,8 +129,8 @@ class AuthenticationNotifier extends StateNotifier { shouldChangePassword: false, isAuthenticated: false, ); - } catch (e) { - log.severe("Error logging out $e"); + } catch (e, stack) { + log.severe('Logout failed', e, stack); } } diff --git a/mobile/lib/modules/login/services/oauth.service.dart b/mobile/lib/modules/login/services/oauth.service.dart index 8f34c968e..952c6fa8d 100644 --- a/mobile/lib/modules/login/services/oauth.service.dart +++ b/mobile/lib/modules/login/services/oauth.service.dart @@ -36,7 +36,7 @@ class OAuthService { ), ); } catch (e, stack) { - log.severe("Error performing oAuthLogin: ${e.toString()}", e, stack); + log.severe("OAuth login failed", e, stack); return null; } } diff --git a/mobile/lib/modules/map/providers/map_state.provider.dart b/mobile/lib/modules/map/providers/map_state.provider.dart index de6265c23..f1d1a4dde 100644 --- a/mobile/lib/modules/map/providers/map_state.provider.dart +++ b/mobile/lib/modules/map/providers/map_state.provider.dart @@ -1,6 +1,7 @@ import 'dart:io'; 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/settings/providers/app_settings.provider.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), ); _log.severe( - "Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}", + "Cannot fetch map light style", + lightResponse.toLoggerString(), ); return; } @@ -77,9 +79,7 @@ class MapStateNotifier extends _$MapStateNotifier { state = state.copyWith( darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), ); - _log.severe( - "Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}", - ); + _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString()); return; } diff --git a/mobile/lib/modules/map/services/map.service.dart b/mobile/lib/modules/map/services/map.service.dart index b3a904cbf..0a5036056 100644 --- a/mobile/lib/modules/map/services/map.service.dart +++ b/mobile/lib/modules/map/services/map.service.dart @@ -28,6 +28,7 @@ class MapSerivce with ErrorLoggerMixin { return markers?.map(MapMarker.fromDto) ?? []; }, defaultValue: [], + errorMessage: "Failed to get map markers", ); } } diff --git a/mobile/lib/modules/map/utils/map_utils.dart b/mobile/lib/modules/map/utils/map_utils.dart index 46af81ce1..f6e8349f5 100644 --- a/mobile/lib/modules/map/utils/map_utils.dart +++ b/mobile/lib/modules/map/utils/map_utils.dart @@ -105,10 +105,8 @@ class MapUtils { timeLimit: const Duration(seconds: 5), ); return (currentUserLocation, null); - } catch (error) { - _log.severe( - "Cannot get user's current location due to ${error.toString()}", - ); + } catch (error, stack) { + _log.severe("Cannot get user's current location", error, stack); return (null, LocationPermission.unableToDetermine); } } diff --git a/mobile/lib/modules/map/widgets/map_asset_grid.dart b/mobile/lib/modules/map/widgets/map_asset_grid.dart index d1f187e25..ad90d36ed 100644 --- a/mobile/lib/modules/map/widgets/map_asset_grid.dart +++ b/mobile/lib/modules/map/widgets/map_asset_grid.dart @@ -147,7 +147,7 @@ class MapAssetGrid extends HookConsumerWidget { }, error: (error, stackTrace) { log.warning( - "Cannot get assets in the current map bounds $error", + "Cannot get assets in the current map bounds", error, stackTrace, ); diff --git a/mobile/lib/modules/memories/services/memory.service.dart b/mobile/lib/modules/memories/services/memory.service.dart index 8d2cd226a..8ee203e6c 100644 --- a/mobile/lib/modules/memories/services/memory.service.dart +++ b/mobile/lib/modules/memories/services/memory.service.dart @@ -47,7 +47,7 @@ class MemoryService { return memories.isNotEmpty ? memories : null; } catch (error, stack) { - log.severe("Cannot get memories ${error.toString()}", error, stack); + log.severe("Cannot get memories", error, stack); return null; } } diff --git a/mobile/lib/modules/memories/ui/memory_card.dart b/mobile/lib/modules/memories/ui/memory_card.dart index 7c998e8f5..7a9355044 100644 --- a/mobile/lib/modules/memories/ui/memory_card.dart +++ b/mobile/lib/modules/memories/ui/memory_card.dart @@ -55,9 +55,9 @@ class MemoryCard extends StatelessWidget { LayoutBuilder( builder: (context, constraints) { // Determine the fit using the aspect ratio - BoxFit fit = BoxFit.fitWidth; + BoxFit fit = BoxFit.contain; if (asset.width != null && asset.height != null) { - final aspectRatio = asset.height! / asset.width!; + final aspectRatio = asset.width! / asset.height!; final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight; // Look for a 25% difference in either direction diff --git a/mobile/lib/modules/partner/services/partner.service.dart b/mobile/lib/modules/partner/services/partner.service.dart index 32e500353..d1e40076c 100644 --- a/mobile/lib/modules/partner/services/partner.service.dart +++ b/mobile/lib/modules/partner/services/partner.service.dart @@ -40,7 +40,7 @@ class PartnerService { return userDtos.map((u) => User.fromPartnerDto(u)).toList(); } } 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; } @@ -51,7 +51,7 @@ class PartnerService { partner.isPartnerSharedBy = false; await _db.writeTxn(() => _db.users.put(partner)); } catch (e) { - _log.warning("failed to remove partner ${partner.id}:\n$e"); + _log.warning("Failed to remove partner ${partner.id}", e); return false; } return true; @@ -66,7 +66,7 @@ class PartnerService { return true; } } catch (e) { - _log.warning("failed to add partner ${partner.id}:\n$e"); + _log.warning("Failed to add partner ${partner.id}", e); } return false; } @@ -81,7 +81,7 @@ class PartnerService { return true; } } catch (e) { - _log.warning("failed to update partner ${partner.id}:\n$e"); + _log.warning("Failed to update partner ${partner.id}", e); } return false; } diff --git a/mobile/lib/modules/shared_link/services/shared_link.service.dart b/mobile/lib/modules/shared_link/services/shared_link.service.dart index 3ea1d411b..62f431580 100644 --- a/mobile/lib/modules/shared_link/services/shared_link.service.dart +++ b/mobile/lib/modules/shared_link/services/shared_link.service.dart @@ -22,7 +22,7 @@ class SharedLinkService { ? AsyncData(list.map(SharedLink.fromDto).toList()) : const AsyncData([]); } catch (e, stack) { - _log.severe("failed to fetch shared links - $e"); + _log.severe("Failed to fetch shared links", e, stack); return AsyncError(e, stack); } } @@ -31,7 +31,7 @@ class SharedLinkService { try { return await _apiService.sharedLinkApi.removeSharedLink(id); } 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) { - _log.severe("failed to create shared link with error - $e"); + _log.severe("Failed to create shared link", e); } return null; } @@ -113,7 +113,7 @@ class SharedLinkService { return SharedLink.fromDto(responseDto); } } 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; } diff --git a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart index 177e7d2d4..165d1c0f7 100644 --- a/mobile/lib/modules/trash/providers/trashed_asset.provider.dart +++ b/mobile/lib/modules/trash/providers/trashed_asset.provider.dart @@ -44,7 +44,7 @@ class TrashNotifier extends StateNotifier { .read(syncServiceProvider) .handleRemoteAssetRemoval(idsToRemove.cast().toList()); } 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 { return isRemoved; } catch (error, stack) { - _log.severe("Cannot empty trash ${error.toString()}", error, stack); + _log.severe("Cannot remove assets", error, stack); } return false; } @@ -93,7 +93,7 @@ class TrashNotifier extends StateNotifier { return true; } } catch (error, stack) { - _log.severe("Cannot restore trash ${error.toString()}", error, stack); + _log.severe("Cannot restore assets", error, stack); } return false; } @@ -123,7 +123,7 @@ class TrashNotifier extends StateNotifier { await _db.assets.putAll(updatedAssets); }); } catch (error, stack) { - _log.severe("Cannot restore trash ${error.toString()}", error, stack); + _log.severe("Cannot restore trash", error, stack); } } } diff --git a/mobile/lib/modules/trash/services/trash.service.dart b/mobile/lib/modules/trash/services/trash.service.dart index 9a9ff5d0b..96b07ca20 100644 --- a/mobile/lib/modules/trash/services/trash.service.dart +++ b/mobile/lib/modules/trash/services/trash.service.dart @@ -25,7 +25,7 @@ class TrashService { await _apiService.trashApi.restoreAssets(BulkIdsDto(ids: remoteIds)); return true; } catch (error, stack) { - _log.severe("Cannot restore assets ${error.toString()}", error, stack); + _log.severe("Cannot restore assets", error, stack); return false; } } @@ -34,7 +34,7 @@ class TrashService { try { await _apiService.trashApi.emptyTrash(); } 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 { await _apiService.trashApi.restoreTrash(); } catch (error, stack) { - _log.severe("Cannot restore trash ${error.toString()}", error, stack); + _log.severe("Cannot restore trash", error, stack); } } } diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index 6aee9271f..fe212c4ca 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -1,8 +1,8 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.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:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -16,28 +16,31 @@ class AuthGuard extends AutoRouteGuard { resolver.next(true); 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 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()]); } + } 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) { - if (e.code == HttpStatus.badRequest && - e.innerException is SocketException) { - // offline? - _log.fine( - "Unable to validate user token. User may be offline and offline browsing is allowed.", - ); - } else { - debugPrint("Error [onNavigation] ${e.toString()}"); + // On an unauthorized request, take us to the login page + if (e.code == HttpStatus.unauthorized) { + _log.warning("Unauthorized access token."); router.replaceAll([const LoginRoute()]); return; } } catch (e) { - debugPrint("Error [onNavigation] ${e.toString()}"); - router.replaceAll([const LoginRoute()]); - return; + // Otherwise, this is not fatal, but we still log the warning + _log.warning('Error validating access token from server: $e'); } } } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index f6968dafe..16ac5efb0 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo { void Function()? onPaused, Widget? placeholder, bool showControls = true, - Duration hideControlsTimer = const Duration(seconds: 5), + Duration hideControlsTimer = const Duration(milliseconds: 1500), bool showDownloadingIndicator = true, List? children, }) : super( @@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs { this.onPaused, this.placeholder, this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), + this.hideControlsTimer = const Duration(milliseconds: 1500), this.showDownloadingIndicator = true, }); diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index afd49adc6..dd38b050b 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -171,6 +171,11 @@ class Asset { 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 @ignore bool get isLocal => localId != null; diff --git a/mobile/lib/shared/models/logger_message.model.dart b/mobile/lib/shared/models/logger_message.model.dart index cb1d45a58..f657257ea 100644 --- a/mobile/lib/shared/models/logger_message.model.dart +++ b/mobile/lib/shared/models/logger_message.model.dart @@ -9,6 +9,7 @@ part 'logger_message.model.g.dart'; class LoggerMessage { Id id = Isar.autoIncrement; String message; + String? details; @Enumerated(EnumType.ordinal) LogLevel level = LogLevel.INFO; DateTime createdAt; @@ -17,6 +18,7 @@ class LoggerMessage { LoggerMessage({ required this.message, + required this.details, required this.level, required this.createdAt, required this.context1, diff --git a/mobile/lib/shared/models/logger_message.model.g.dart b/mobile/lib/shared/models/logger_message.model.g.dart index a6b960eec..76c823704 100644 --- a/mobile/lib/shared/models/logger_message.model.g.dart +++ b/mobile/lib/shared/models/logger_message.model.g.dart @@ -32,14 +32,19 @@ const LoggerMessageSchema = CollectionSchema( name: r'createdAt', type: IsarType.dateTime, ), - r'level': PropertySchema( + r'details': PropertySchema( id: 3, + name: r'details', + type: IsarType.string, + ), + r'level': PropertySchema( + id: 4, name: r'level', type: IsarType.byte, enumMap: _LoggerMessagelevelEnumValueMap, ), r'message': PropertySchema( - id: 4, + id: 5, name: r'message', type: IsarType.string, ) @@ -76,6 +81,12 @@ int _loggerMessageEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.details; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } bytesCount += 3 + object.message.length * 3; return bytesCount; } @@ -89,8 +100,9 @@ void _loggerMessageSerialize( writer.writeString(offsets[0], object.context1); writer.writeString(offsets[1], object.context2); writer.writeDateTime(offsets[2], object.createdAt); - writer.writeByte(offsets[3], object.level.index); - writer.writeString(offsets[4], object.message); + writer.writeString(offsets[3], object.details); + writer.writeByte(offsets[4], object.level.index); + writer.writeString(offsets[5], object.message); } LoggerMessage _loggerMessageDeserialize( @@ -103,9 +115,10 @@ LoggerMessage _loggerMessageDeserialize( context1: reader.readStringOrNull(offsets[0]), context2: reader.readStringOrNull(offsets[1]), createdAt: reader.readDateTime(offsets[2]), - level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[3])] ?? + details: reader.readStringOrNull(offsets[3]), + level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ?? LogLevel.ALL, - message: reader.readString(offsets[4]), + message: reader.readString(offsets[5]), ); object.id = id; return object; @@ -125,9 +138,11 @@ P _loggerMessageDeserializeProp

( case 2: return (reader.readDateTime(offset)) as P; case 3: + return (reader.readStringOrNull(offset)) as P; + case 4: return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? LogLevel.ALL) as P; - case 4: + case 5: return (reader.readString(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -619,6 +634,160 @@ extension LoggerMessageQueryFilter }); } + QueryBuilder + detailsIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'details', + )); + }); + } + + QueryBuilder + detailsIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'details', + )); + }); + } + + QueryBuilder + detailsEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'details', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + 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 + 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 + 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 + detailsStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'details', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + detailsEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'details', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + detailsContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'details', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + detailsMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'details', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + detailsIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'details', + value: '', + )); + }); + } + + QueryBuilder + detailsIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'details', + value: '', + )); + }); + } + QueryBuilder idEqualTo( Id value) { return QueryBuilder.apply(this, (query) { @@ -913,6 +1082,18 @@ extension LoggerMessageQuerySortBy }); } + QueryBuilder sortByDetails() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'details', Sort.asc); + }); + } + + QueryBuilder sortByDetailsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'details', Sort.desc); + }); + } + QueryBuilder sortByLevel() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'level', Sort.asc); @@ -979,6 +1160,18 @@ extension LoggerMessageQuerySortThenBy }); } + QueryBuilder thenByDetails() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'details', Sort.asc); + }); + } + + QueryBuilder thenByDetailsDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'details', Sort.desc); + }); + } + QueryBuilder thenById() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'id', Sort.asc); @@ -1038,6 +1231,13 @@ extension LoggerMessageQueryWhereDistinct }); } + QueryBuilder distinctByDetails( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'details', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByLevel() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'level'); @@ -1078,6 +1278,12 @@ extension LoggerMessageQueryProperty }); } + QueryBuilder detailsProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'details'); + }); + } + QueryBuilder levelProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'level'); diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 64a0f28ab..3086ab924 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -90,7 +90,7 @@ class AssetService { return allAssets; } catch (error, stack) { log.severe( - 'Error while getting remote assets: ${error.toString()}', + 'Error while getting remote assets', error, stack, ); @@ -117,7 +117,7 @@ class AssetService { ); return true; } catch (error, stack) { - log.severe("Error deleteAssets ${error.toString()}", error, stack); + log.severe("Error while deleting assets", error, stack); } return false; } diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart index b66177e57..967ab2d5f 100644 --- a/mobile/lib/shared/services/immich_logger.service.dart +++ b/mobile/lib/shared/services/immich_logger.service.dart @@ -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. /// 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. /// /// 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}'); final lm = LoggerMessage( message: record.message, + details: record.error?.toString(), level: record.level.toLogLevel(), createdAt: record.time, context1: record.loggerName, diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index d7daa51b8..be7c0c168 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.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/providers/api.provider.dart'; import 'package:logging/logging.dart'; @@ -41,7 +42,8 @@ class ShareService { if (res.statusCode != 200) { _log.severe( - "Asset download failed with status - ${res.statusCode} and response - ${res.body}", + "Asset download for ${asset.fileName} failed", + res.toLoggerString(), ); continue; } @@ -68,7 +70,7 @@ class ShareService { ); return true; } catch (error) { - _log.severe("Share failed with error $error"); + _log.severe("Share failed", error); } return false; } diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index d039b3409..a441091d3 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -140,7 +140,7 @@ class SyncService { try { await _db.writeTxn(() => a.put(_db)); } 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 true; @@ -173,7 +173,7 @@ class SyncService { } return false; } 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; } @@ -232,7 +232,7 @@ class SyncService { await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); await upsertAssetsWithExif(toAdd + toUpdate); } 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); return true; @@ -364,7 +364,7 @@ class SyncService { }); _log.info("Synced changes of remote album ${album.name} to DB"); } 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) { @@ -441,7 +441,7 @@ class SyncService { assert(ok); _log.info("Removed local album $album from DB"); } 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"); } 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; @@ -623,7 +623,7 @@ class SyncService { }); _log.info("Fast synced local album ${ape.name} to DB"); } 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; } @@ -656,7 +656,7 @@ class SyncService { await _db.writeTxn(() => _db.albums.store(a)); _log.info("Added a new local album to DB: ${ape.name}"); } 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"); } on IsarError catch (e) { - _log.severe( - "Failed to upsert ${assets.length} assets into the DB: ${e.toString()}", - ); + _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); final inDb = await _db.assets.getAllByOwnerIdChecksum( @@ -776,7 +774,7 @@ class SyncService { }); return true; } 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; } } diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart index 4d398c3a8..ae65ed31d 100644 --- a/mobile/lib/shared/services/user.service.dart +++ b/mobile/lib/shared/services/user.service.dart @@ -42,7 +42,7 @@ class UserService { final dto = await _apiService.userApi.getAllUsers(isAll); return dto?.map(User.fromUserDto).toList(); } catch (e) { - _log.warning("Failed get all users:\n$e"); + _log.warning("Failed get all users", e); return null; } } @@ -65,7 +65,7 @@ class UserService { ), ); } catch (e) { - _log.warning("Failed to upload profile image:\n$e"); + _log.warning("Failed to upload profile image", e); return null; } } diff --git a/mobile/lib/shared/ui/delayed_loading_indicator.dart b/mobile/lib/shared/ui/delayed_loading_indicator.dart new file mode 100644 index 000000000..b4d9f4c80 --- /dev/null +++ b/mobile/lib/shared/ui/delayed_loading_indicator.dart @@ -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')); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/views/app_log_detail_page.dart b/mobile/lib/shared/views/app_log_detail_page.dart index 126f46c8f..6b99d7f0a 100644 --- a/mobile/lib/shared/views/app_log_detail_page.dart +++ b/mobile/lib/shared/views/app_log_detail_page.dart @@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { var isDarkTheme = context.isDarkTheme; - buildStackMessage(String stackTrace) { + buildTextWithCopyButton(String header, String text) { return Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Text( - "STACK TRACES", + header, style: TextStyle( fontSize: 12.0, color: context.primaryColor, @@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget { ), IconButton( onPressed: () { - Clipboard.setData(ClipboardData(text: stackTrace)) - .then((_) { + Clipboard.setData(ClipboardData(text: text)).then((_) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(8.0), child: SelectableText( - stackTrace, - 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, + text, style: const TextStyle( fontSize: 12.0, fontWeight: FontWeight.bold, @@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget { body: SafeArea( child: ListView( children: [ - buildLogMessage(logMessage.message), + buildTextWithCopyButton("MESSAGE", logMessage.message), + if (logMessage.details != null) + buildTextWithCopyButton("DETAILS", logMessage.details.toString()), if (logMessage.context1 != null) buildLogContext1(logMessage.context1.toString()), if (logMessage.context2 != null) - buildStackMessage(logMessage.context2.toString()), + buildTextWithCopyButton( + "STACK TRACE", + logMessage.context2.toString(), + ), ], ), ), diff --git a/mobile/lib/shared/views/app_log_page.dart b/mobile/lib/shared/views/app_log_page.dart index a0c4553f9..993b25c7c 100644 --- a/mobile/lib/shared/views/app_log_page.dart +++ b/mobile/lib/shared/views/app_log_page.dart @@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: Text( - "Logs - ${logMessages.value.length}", - style: const TextStyle( + title: const Text( + "Logs", + style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16.0, ), @@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget { dense: true, tileColor: getTileColor(logMessage.level), minLeadingWidth: 10, - title: Text.rich( - TextSpan( - children: [ - TextSpan( - text: "#$index ", - 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, - ), - ), - ], + title: Text( + truncateLogMessage(logMessage.message, 4), + style: const TextStyle( + fontSize: 14.0, + fontFamily: "Inconsolata", ), - style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"), ), 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( fontSize: 12.0, color: Colors.grey[600], diff --git a/mobile/lib/shared/views/immich_loading_overlay.dart b/mobile/lib/shared/views/immich_loading_overlay.dart index 85f0123ed..c600d2a72 100644 --- a/mobile/lib/shared/views/immich_loading_overlay.dart +++ b/mobile/lib/shared/views/immich_loading_overlay.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.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( builder: (context) => SizedBox.square( @@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry( child: DecoratedBox( decoration: 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> { class _LoadingOverlayState extends HookState, _LoadingOverlay> { - late final _isProcessing = ValueNotifier(false)..addListener(_listener); - OverlayEntry? overlayEntry; + late final _isLoading = ValueNotifier(false)..addListener(_listener); + OverlayEntry? _loadingOverlay; void _listener() { setState(() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_isProcessing.value) { - overlayEntry?.remove(); - overlayEntry = _loadingEntry; + if (_isLoading.value) { + _loadingOverlay?.remove(); + _loadingOverlay = _loadingEntry; Overlay.of(context).insert(_loadingEntry); } else { - overlayEntry?.remove(); - overlayEntry = null; + _loadingOverlay?.remove(); + _loadingOverlay = null; } }); }); @@ -47,17 +52,17 @@ class _LoadingOverlayState @override ValueNotifier build(BuildContext context) { - return _isProcessing; + return _isLoading; } @override void dispose() { - _isProcessing.dispose(); + _isLoading.dispose(); super.dispose(); } @override - Object? get debugValue => _isProcessing.value; + Object? get debugValue => _isLoading.value; @override String get debugLabel => 'useProcessingOverlay<>'; diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index 8dddb60aa..3c0d65bde 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget { deviceIsOffline = true; log.fine("Device seems to be offline upon launch"); } else { - log.severe(e); + log.severe("Failed to resolve endpoint", e); } } catch (e) { - log.severe(e); + log.severe("Failed to resolve endpoint", e); } try { @@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget { ref.read(authenticationProvider.notifier).logout(); log.severe( - 'Cannot set success login info: $error', + 'Cannot set success login info', error, stackTrace, ); diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 0679a1749..ea413b487 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -108,6 +108,7 @@ doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md doc/PersonWithFacesResponseDto.md +doc/PlacesResponseDto.md doc/QueueStatusDto.md doc/ReactionLevel.md doc/ReactionType.md @@ -308,6 +309,7 @@ lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart lib/model/person_with_faces_response_dto.dart +lib/model/places_response_dto.dart lib/model/queue_status_dto.dart lib/model/reaction_level.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_update_dto_test.dart test/person_with_faces_response_dto_test.dart +test/places_response_dto_test.dart test/queue_status_dto_test.dart test/reaction_level_test.dart test/reaction_type_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5dd6d196d..41e65ee8b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -166,6 +166,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *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 | *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | @@ -306,6 +307,7 @@ Class | Method | HTTP request | Description - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) + - [PlacesResponseDto](doc//PlacesResponseDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) diff --git a/mobile/openapi/doc/PlacesResponseDto.md b/mobile/openapi/doc/PlacesResponseDto.md new file mode 100644 index 000000000..a4bf36493 --- /dev/null +++ b/mobile/openapi/doc/PlacesResponseDto.md @@ -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) + + diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index f975e9448..f63488222 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -14,6 +14,7 @@ Method | HTTP request | Description [**search**](SearchApi.md#search) | **GET** /search | [**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata | [**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person | +[**searchPlaces**](SearchApi.md#searchplaces) | **GET** /search/places | [**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) +# **searchPlaces** +> List searchPlaces(name) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('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.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** > SearchResponseDto searchSmart(smartSearchDto) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 72a656764..56bd907e0 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -142,6 +142,7 @@ part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/person_with_faces_response_dto.dart'; +part 'model/places_response_dto.dart'; part 'model/queue_status_dto.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 062ca4a50..3a0bc56bb 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -360,6 +360,58 @@ class SearchApi { return null; } + /// Performs an HTTP 'GET /search/places' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] name (required): + Future searchPlacesWithHttpInfo(String name,) async { + // ignore: prefer_const_declarations + final path = r'/search/places'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'name', name)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] name (required): + Future?> 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') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 2df5e6711..24cffb7cf 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -366,6 +366,8 @@ class ApiClient { return PersonUpdateDto.fromJson(value); case 'PersonWithFacesResponseDto': return PersonWithFacesResponseDto.fromJson(value); + case 'PlacesResponseDto': + return PlacesResponseDto.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); case 'ReactionLevel': diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart new file mode 100644 index 000000000..a2d837888 --- /dev/null +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return PlacesResponseDto( + admin1name: mapValueOfType(json, r'admin1name'), + admin2name: mapValueOfType(json, r'admin2name'), + latitude: num.parse('${json[r'latitude']}'), + longitude: num.parse('${json[r'longitude']}'), + name: mapValueOfType(json, r'name')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'latitude', + 'longitude', + 'name', + }; +} + diff --git a/mobile/openapi/test/places_response_dto_test.dart b/mobile/openapi/test/places_response_dto_test.dart new file mode 100644 index 000000000..5a320fce6 --- /dev/null +++ b/mobile/openapi/test/places_response_dto_test.dart @@ -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 + }); + + + }); + +} diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 14169e461..aa4a94847 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -42,6 +42,11 @@ void main() { // TODO }); + //Future> searchPlaces(String name) async + test('test searchPlaces', () async { + // TODO + }); + //Future searchSmart(SmartSearchDto smartSearchDto) async test('test searchSmart', () async { // TODO diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index ffa57f826..6bc37c922 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -413,10 +413,10 @@ packages: dependency: transitive description: name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "7.0.0" file_selector_linux: dependency: transitive description: @@ -569,10 +569,10 @@ packages: dependency: "direct main" description: name: flutter_udid - sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84" + sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "3.0.0" flutter_web_auth: dependency: "direct main" description: @@ -619,10 +619,10 @@ packages: dependency: "direct main" description: name: geolocator - sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02 + sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "11.0.0" geolocator_android: dependency: transitive description: @@ -651,10 +651,10 @@ packages: dependency: transitive description: name: geolocator_web - sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58" + sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" geolocator_windows: dependency: transitive description: @@ -860,6 +860,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -907,18 +931,18 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: "direct overridden" description: @@ -1002,10 +1026,10 @@ packages: dependency: "direct main" description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_provider: dependency: "direct main" description: @@ -1138,10 +1162,10 @@ packages: dependency: transitive description: name: platform - sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: @@ -1170,10 +1194,10 @@ packages: dependency: transitive description: name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" url: "https://pub.dev" source: hosted - version: "4.2.4" + version: "5.0.2" provider: dependency: transitive description: @@ -1298,10 +1322,10 @@ packages: dependency: transitive description: name: shared_preferences_linux - sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" shared_preferences_platform_interface: dependency: transitive description: @@ -1322,10 +1346,10 @@ packages: dependency: transitive description: name: shared_preferences_windows - sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.2" shelf: dependency: transitive description: @@ -1631,10 +1655,10 @@ packages: dependency: transitive description: name: vm_service - sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "11.10.0" + version: "13.0.0" wakelock_plus: dependency: "direct main" description: @@ -1679,10 +1703,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" win32: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 47a4d3805..0869d3973 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,8 +32,8 @@ dependencies: git: url: https://github.com/maplibre/flutter-maplibre-gl.git ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5 - geolocator: ^10.1.0 # used to move to current location in map view - flutter_udid: ^2.1.1 + geolocator: ^11.0.0 # used to move to current location in map view + flutter_udid: ^3.0.0 package_info_plus: ^5.0.1 url_launcher: ^6.2.4 http: 0.13.5 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cac1d663b..8fec89327 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { "post": { "operationId": "searchSmart", @@ -8756,6 +8800,31 @@ ], "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": { "properties": { "isActive": { diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index c01b200d0..ad36bb493 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -2994,6 +2994,43 @@ export interface PersonWithFacesResponseDto { */ '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 @@ -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 => { + // 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); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -15584,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) { const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url; 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>> { + 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 @@ -15651,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise> { 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> { + return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath)); + }, /** * * @param {SearchApiSearchSmartRequest} requestParameters Request parameters. @@ -15817,6 +15920,20 @@ export interface SearchApiSearchPersonRequest { 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. * @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)); } + /** + * + * @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. diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index 0ee871ca6..d023f8ef0 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -646,6 +646,13 @@ export type MetadataSearchDto = { withPeople?: boolean; withStacked?: boolean; }; +export type PlacesResponseDto = { + admin1name?: string; + admin2name?: string; + latitude: number; + longitude: number; + name: string; +}; export type SmartSearchDto = { city?: string; country?: string; @@ -2198,6 +2205,18 @@ export function searchPerson({ name, withHidden }: { ...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 }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/open-api/typescript-sdk/fetch-errors.ts b/open-api/typescript-sdk/fetch-errors.ts new file mode 100644 index 000000000..f21f0ed1c --- /dev/null +++ b/open-api/typescript-sdk/fetch-errors.ts @@ -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; +} diff --git a/open-api/typescript-sdk/fetch.ts b/open-api/typescript-sdk/fetch.ts index 5441cd826..5759e66ad 100644 --- a/open-api/typescript-sdk/fetch.ts +++ b/open-api/typescript-sdk/fetch.ts @@ -1 +1,2 @@ export * from './fetch-client'; +export * from './fetch-errors'; diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 4e7c4d552..0dc9c5414 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt'; export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt'); export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.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 = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 7183e9e3f..8566fcd8e 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -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'; export const ISearchRepository = 'ISearchRepository'; @@ -186,4 +186,5 @@ export interface ISearchRepository { searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchFaces(search: FaceEmbeddingSearch): Promise; upsert(smartInfo: Partial, embedding?: Embedding): Promise; + searchPlaces(placeName: string): Promise; } diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 4f2aa1819..877a494e4 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,5 +1,5 @@ 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 { Transform, Type } from 'class-transformer'; import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; @@ -241,6 +241,12 @@ export class SearchDto { size?: number; } +export class SearchPlacesDto { + @IsString() + @IsNotEmpty() + name!: string; +} + export class SearchPeopleDto { @IsString() @IsNotEmpty() @@ -251,3 +257,21 @@ export class SearchPeopleDto { @Optional() 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, + }; +} diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 452c556f4..5b5639998 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -16,7 +16,15 @@ import { SearchStrategy, } from '../repositories'; 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 { SearchResponseDto } from './response-dto'; @@ -41,6 +49,11 @@ export class SearchService { return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); } + async searchPlaces(dto: SearchPlacesDto): Promise { + const places = await this.searchRepository.searchPlaces(dto.name); + return places.map((place) => mapPlaces(place)); + } + async getExploreData(auth: AuthDto): Promise[]> { await this.configCore.requireFeature(FeatureFlag.SEARCH); const options = { maxFields: 12, minAssetsPerField: 5 }; @@ -182,26 +195,22 @@ export class SearchService { } async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { - if (dto.type === SearchSuggestionType.COUNTRY) { - return this.metadataRepository.getCountries(auth.user.id); + switch (dto.type) { + 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 []; } } diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index d69698254..857d1df32 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -117,7 +117,7 @@ export class StorageTemplateService { return true; } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination), + this.assetRepository.getAll(pagination, { withExif: true }), ); const users = await this.userRepository.getList(); diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 4e57cfaa6..b807da966 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -2,9 +2,11 @@ import { AuthDto, MetadataSearchDto, PersonResponseDto, + PlacesResponseDto, SearchDto, SearchExploreResponseDto, SearchPeopleDto, + SearchPlacesDto, SearchResponseDto, SearchService, SmartSearchDto, @@ -48,6 +50,11 @@ export class SearchController { return this.service.searchPerson(auth, dto); } + @Get('places') + searchPlaces(@Query() dto: SearchPlacesDto): Promise { + return this.service.searchPlaces(dto); + } + @Get('suggestions') getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { return this.service.getSearchSuggestions(auth, dto); diff --git a/server/src/infra/entities/geodata-admin1.entity.ts b/server/src/infra/entities/geodata-admin1.entity.ts deleted file mode 100644 index 36cf0a805..000000000 --- a/server/src/infra/entities/geodata-admin1.entity.ts +++ /dev/null @@ -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; -} diff --git a/server/src/infra/entities/geodata-admin2.entity.ts b/server/src/infra/entities/geodata-admin2.entity.ts deleted file mode 100644 index bd03e8377..000000000 --- a/server/src/infra/entities/geodata-admin2.entity.ts +++ /dev/null @@ -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; -} diff --git a/server/src/infra/entities/geodata-places.entity.ts b/server/src/infra/entities/geodata-places.entity.ts index 244e4261b..966a50d5c 100644 --- a/server/src/infra/entities/geodata-places.entity.ts +++ b/server/src/infra/entities/geodata-places.entity.ts @@ -1,6 +1,4 @@ -import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity'; -import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity'; -import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Column, Entity, PrimaryColumn } from 'typeorm'; @Entity('geodata_places', { synchronize: false }) export class GeodataPlacesEntity { @@ -21,7 +19,7 @@ export class GeodataPlacesEntity { // asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)', // type: 'earth', // }) - earthCoord!: unknown; + // earthCoord!: unknown; @Column({ type: 'char', length: 2 }) countryCode!: string; @@ -32,27 +30,14 @@ export class GeodataPlacesEntity { @Column({ type: 'varchar', length: 80, nullable: true }) admin2Code!: string; - @Column({ - type: 'varchar', - generatedType: 'STORED', - asExpression: `"countryCode" || '.' || "admin1Code"`, - nullable: true, - }) - admin1Key!: string; + @Column({ type: 'varchar', nullable: true }) + admin1Name!: string; - @ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false }) - admin1!: GeodataAdmin1Entity; + @Column({ type: 'varchar', nullable: true }) + admin2Name!: string; - @Column({ - type: 'varchar', - generatedType: 'STORED', - asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`, - nullable: true, - }) - admin2Key!: string; - - @ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false }) - admin2!: GeodataAdmin2Entity; + @Column({ type: 'varchar', nullable: true }) + alternateNames!: string; @Column({ type: 'date' }) modificationDate!: Date; diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index 957e15a88..af620790e 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity'; import { AssetEntity } from './asset.entity'; import { AuditEntity } from './audit.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 { LibraryEntity } from './library.entity'; import { MoveEntity } from './move.entity'; @@ -32,8 +30,6 @@ export * from './asset-stack.entity'; export * from './asset.entity'; export * from './audit.entity'; export * from './exif.entity'; -export * from './geodata-admin1.entity'; -export * from './geodata-admin2.entity'; export * from './geodata-places.entity'; export * from './library.entity'; export * from './move.entity'; @@ -59,8 +55,6 @@ export const databaseEntities = [ AuditEntity, ExifEntity, GeodataPlacesEntity, - GeodataAdmin1Entity, - GeodataAdmin2Entity, MoveEntity, PartnerEntity, PersonEntity, diff --git a/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts new file mode 100644 index 000000000..136ca2598 --- /dev/null +++ b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts @@ -0,0 +1,152 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class GeodataLocationSearch1708059341865 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"', + ], + ); + } +} diff --git a/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts new file mode 100644 index 000000000..0cea9a041 --- /dev/null +++ b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class GeonamesEnhancement1708116312820 implements MigrationInterface { + name = 'GeonamesEnhancement1708116312820' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`); + } + +} diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index 6a90ad108..4abfe0eac 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -2,7 +2,7 @@ import { citiesFile, geodataAdmin1Path, geodataAdmin2Path, - geodataCitites500Path, + geodataCities500Path, geodataDatePath, GeoPoint, IMetadataRepository, @@ -10,13 +10,7 @@ import { ISystemMetadataRepository, ReverseGeocodeResult, } from '@app/domain'; -import { - ExifEntity, - GeodataAdmin1Entity, - GeodataAdmin2Entity, - GeodataPlacesEntity, - SystemMetadataKey, -} from '@app/infra/entities'; +import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; 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'; -type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity; -type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity; - export class MetadataRepository implements IMetadataRepository { constructor( @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository, - @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository, - @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository, - @Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) + private readonly systemMetadataRepository: ISystemMetadataRepository, @InjectDataSource() private dataSource: DataSource, ) {} @@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository { return; } - this.logger.log('Importing geodata to database from file'); await this.importGeodata(); await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { @@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); + const admin1 = await this.loadAdmin(geodataAdmin1Path); + const admin2 = await this.loadAdmin(geodataAdmin2Path); + try { await queryRunner.startTransaction(); - await this.loadCities500(queryRunner); - await this.loadAdmin1(queryRunner); - await this.loadAdmin2(queryRunner); + await queryRunner.manager.clear(GeodataPlacesEntity); + await this.loadCities500(queryRunner, admin1, admin2); await queryRunner.commitTransaction(); } catch (error) { @@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository { } } - private async loadGeodataToTableFromFile( + private async loadGeodataToTableFromFile( queryRunner: QueryRunner, - lineToEntityMapper: (lineSplit: string[]) => T, + lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity, filePath: string, - entity: GeoEntityClass, ) { if (!existsSync(filePath)) { this.logger.error(`Geodata file ${filePath} not found`); throw new Error(`Geodata file ${filePath} not found`); } - await queryRunner.manager.clear(entity); const input = createReadStream(filePath); - let buffer: DeepPartial[] = []; - const lineReader = readLine.createInterface({ input: input }); + let bufferGeodata: QueryDeepPartialEntity[] = []; + const lineReader = readLine.createInterface({ input }); for await (const line of lineReader) { const lineSplit = line.split('\t'); - buffer.push(lineToEntityMapper(lineSplit)); - if (buffer.length > 1000) { - await queryRunner.manager.save(buffer); - buffer = []; + const geoData = lineToEntityMapper(lineSplit); + bufferGeodata.push(geoData); + if (bufferGeodata.length > 1000) { + 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) { - await this.loadGeodataToTableFromFile( + private async loadCities500( + queryRunner: QueryRunner, + admin1Map: Map, + admin2Map: Map, + ) { + await this.loadGeodataToTableFromFile( queryRunner, (lineSplit: string[]) => this.geodataPlacesRepository.create({ id: Number.parseInt(lineSplit[0]), name: lineSplit[1], + alternateNames: lineSplit[3], latitude: Number.parseFloat(lineSplit[4]), longitude: Number.parseFloat(lineSplit[5]), countryCode: lineSplit[8], admin1Code: lineSplit[10], admin2Code: lineSplit[11], modificationDate: lineSplit[18], + admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`), + admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`), }), - geodataCitites500Path, - GeodataPlacesEntity, + geodataCities500Path, ); } - private async loadAdmin1(queryRunner: QueryRunner) { - await this.loadGeodataToTableFromFile( - queryRunner, - (lineSplit: string[]) => - this.geodataAdmin1Repository.create({ - key: lineSplit[0], - name: lineSplit[1], - }), - geodataAdmin1Path, - GeodataAdmin1Entity, - ); - } + private async loadAdmin(filePath: string) { + if (!existsSync(filePath)) { + this.logger.error(`Geodata file ${filePath} not found`); + throw new Error(`Geodata file ${filePath} not found`); + } - private async loadAdmin2(queryRunner: QueryRunner) { - await this.loadGeodataToTableFromFile( - queryRunner, - (lineSplit: string[]) => - this.geodataAdmin2Repository.create({ - key: lineSplit[0], - name: lineSplit[1], - }), - geodataAdmin2Path, - GeodataAdmin2Entity, - ); + const input = createReadStream(filePath); + const lineReader = readLine.createInterface({ input: input }); + + const adminMap = new Map(); + for await (const line of lineReader) { + const lineSplit = line.split('\t'); + adminMap.set(lineSplit[0], lineSplit[1]); + } + + return adminMap; } async teardown() { @@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository { const response = await this.geodataPlacesRepository .createQueryBuilder('geoplaces') - .leftJoinAndSelect('geoplaces.admin1', 'admin1') - .leftJoinAndSelect('geoplaces.admin2', 'admin2') .where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point) .orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")') .limit(1) @@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository { 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 stateParts = [admin2?.name, admin1?.name].filter((name) => !!name); + const stateParts = [admin2Name, admin1Name].filter((name) => !!name); const state = stateParts.length > 0 ? stateParts.join(', ') : null; return { country, state, city }; diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index a30c96b10..089640128 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -12,7 +12,13 @@ import { SmartSearchOptions, } from '@app/domain'; 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 { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository { @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository, + @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository, ) { this.faceColumns = this.assetFaceRepository.manager.connection .getMetadata(AssetFaceEntity) @@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository { })); } + @GenerateSql({ params: [DummyValue.STRING] }) + async searchPlaces(placeName: string): Promise { + 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, embedding?: Embedding): Promise { await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] }); if (!smartInfo.assetId || !embedding) { diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index a21697c26..c45d90a7a 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -238,3 +238,37 @@ FROM WHERE res.distance <= $3 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 diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index e0bdab269..06a2cb76d 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -7,5 +7,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => { searchSmart: jest.fn(), searchFaces: jest.fn(), upsert: jest.fn(), + searchPlaces: jest.fn(), }; }; diff --git a/web/package-lock.json b/web/package-lock.json index 84bd64d3e..78e5caf7c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,7 +32,7 @@ "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.1.8", - "@sveltejs/kit": "^2.5.0", + "@sveltejs/kit": "^2.5.1", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/svelte": "^4.0.3", @@ -1859,9 +1859,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", - "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz", + "integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/web/package.json b/web/package.json index 1542acc2d..2b53d0645 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.1.8", - "@sveltejs/kit": "^2.5.0", + "@sveltejs/kit": "^2.5.1", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/svelte": "^4.0.3", diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts index 1e29371fa..802e9a712 100644 --- a/web/src/hooks.client.ts +++ b/web/src/hooks.client.ts @@ -1,34 +1,22 @@ +import { isHttpError } from '@immich/sdk'; import type { HandleClientError } from '@sveltejs/kit'; -import type { AxiosError, AxiosResponse } from 'axios'; const LOG_PREFIX = '[hooks.client.ts]'; const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?'; const parseError = (error: unknown) => { - const httpError = error as AxiosError; - const request = httpError?.request as Request & { path: string }; - const response = httpError?.response as AxiosResponse<{ - message: string; - statusCode: number; - error: string; - }>; + const httpError = isHttpError(error) ? error : undefined; + const statusCode = httpError?.status || httpError?.data?.statusCode || 500; + const message = httpError?.data?.message || (httpError?.data && String(httpError.data)) || httpError?.message; - let code = response?.data?.statusCode || response?.status || httpError.code || '500'; - if (response) { - code += ` - ${response.data?.error || response.statusText}`; - } - - if (request && response) { - console.log({ - status: response.status, - url: `${request.method} ${request.path}`, - response: response.data || 'No data', - }); - } + console.log({ + status: statusCode, + response: httpError?.data || 'No data', + }); return { - message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE, - code, + message: message || DEFAULT_MESSAGE, + code: statusCode, stack: httpError?.stack, }; }; diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 6a542d81d..ba24f3aab 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -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." bind:value={config.machineLearning.facialRecognition.minScore} step="0.1" - min="0" - max="1" + min={0} + max={1} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.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." bind:value={config.machineLearning.facialRecognition.maxDistance} step="0.1" - min="0" - max="2" + min={0} + max={2} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.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." bind:value={config.machineLearning.facialRecognition.minFaces} step="1" - min="1" + min={1} disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled} isEdited={config.machineLearning.facialRecognition.minFaces !== savedConfig.machineLearning.facialRecognition.minFaces} diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 5fc3b3e22..11e07d002 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -84,7 +84,26 @@ }; -

+
+
+

+ For more details about this feature, refer to the Storage Template + + and its + implications + +

+
{#await getTemplateOptions() then}
- import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; - import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; + import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.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 { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js'; import { createEventDispatcher, onDestroy, onMount } from 'svelte'; - import { - mdiChevronLeft, - mdiChevronRight, - mdiClose, - mdiPause, - mdiPlay, - mdiShuffle, - mdiShuffleDisabled, - } from '@mdi/js'; - const { slideshowShuffle } = slideshowStore; - const { restartProgress, stopProgress } = slideshowStore; + const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore; let progressBarStatus: ProgressBarStatus; let progressBar: ProgressBar; + let showSettings = false; let unsubscribeRestart: () => void; let unsubscribeStop: () => void; @@ -54,25 +47,27 @@
- dispatch('close')} title="Exit Slideshow" /> - {#if $slideshowShuffle} - ($slideshowShuffle = false)} title="Shuffle" /> - {:else} - ($slideshowShuffle = true)} title="No shuffle" /> - {/if} + dispatch('close')} title="Exit Slideshow" /> (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} /> - dispatch('prev')} title="Previous" /> - dispatch('next')} title="Next" /> + dispatch('prev')} title="Previous" /> + dispatch('next')} title="Next" /> + (showSettings = !showSettings)} title="Next" />
+{#if showSettings} + (showSettings = false)} /> +{/if} +
{/if}
- {#if isLoading} -
- -
- {:else if searchResultAssets.length > 0} + {#if searchResultAssets.length > 0} - {:else} + {:else if !isLoading}
@@ -307,6 +307,12 @@
{/if} + + {#if isLoading} +
+ +
+ {/if}