Compare commits

..

5 Commits

Author SHA1 Message Date
Yaros
589e0a7bc5 Merge branch 'main' into feat/custom-date-range 2026-02-26 13:10:18 +01:00
Yaros
2424952b9a refactor: add back setRelativeTime 2026-02-19 14:11:41 +01:00
Yaros
733100f6ec refactor: rename customtimerange variables 2026-02-19 14:08:50 +01:00
Yaros
b0f6d5cf38 refactor: rename timerange & remove isvalid 2026-02-19 13:23:40 +01:00
Yaros
39d2e14d3a feat(mobile): custom date range for map 2026-02-14 09:56:09 +01:00
95 changed files with 1077 additions and 1692 deletions

View File

@@ -24,7 +24,7 @@ Immich has three main clients:
3. CLI - Command-line utility for bulk upload
:::info
All three clients use [OpenAPI](/api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](/api.md).
All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](./open-api.md).
:::
### Mobile App
@@ -71,7 +71,7 @@ An incoming HTTP request is mapped to a controller (`src/controllers`). Controll
### Domain Transfer Objects (DTOs)
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](/api.md) schemas and control the generated code used by each client.
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client.
### Background Jobs

View File

@@ -1,4 +1,4 @@
# API
# OpenAPI
Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/).

View File

@@ -53,7 +53,7 @@ You can use `dart fix --apply` and `dcm fix lib` to potentially correct some iss
## OpenAPI
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details.
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/developer/open-api.md) for more details.
## Database Migrations

View File

@@ -50,7 +50,6 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`.
- The ROCm image is quite large and requires at least 35GiB of free disk space. However, pulling later updates to the service through Docker will generally only amount to a few hundred megabytes as the rest will be cached.
- This backend is new and may experience some issues. For example, GPU power consumption can be higher than usual after running inference, even if the machine learning service is idle. In this case, it will only go back to normal after being idle for 5 minutes (configurable with the [MACHINE_LEARNING_MODEL_TTL](/install/environment-variables) setting).
- MIGraphX is a new backend for AMD cards, which compiles models at runtime. As such, the first few inferences will be slow.
#### OpenVINO

View File

@@ -6,7 +6,7 @@ const prism = require('prism-react-renderer');
/** @type {import('@docusaurus/types').Config} */
const config = {
title: 'Immich',
tagline: 'Self-hosted photo and video management solution',
tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
url: 'https://docs.immich.app',
baseUrl: '/',
onBrokenLinks: 'throw',
@@ -93,15 +93,35 @@ const config = {
position: 'right',
},
{
href: 'https://immich.app/',
to: '/overview/quick-start',
position: 'right',
label: 'Home',
label: 'Docs',
},
{
href: 'https://immich.app/roadmap',
position: 'right',
label: 'Roadmap',
},
{
href: 'https://api.immich.app/',
position: 'right',
label: 'API',
},
{
href: 'https://immich.store',
position: 'right',
label: 'Merch',
},
{
href: 'https://github.com/immich-app/immich',
label: 'GitHub',
position: 'right',
},
{
href: 'https://discord.immich.app',
label: 'Discord',
position: 'right',
},
{
type: 'html',
position: 'right',
@@ -114,78 +134,19 @@ const config = {
style: 'light',
links: [
{
title: 'Download',
title: 'Overview',
items: [
{
label: 'Android',
href: 'https://get.immich.app/android',
label: 'Quick start',
to: '/overview/quick-start',
},
{
label: 'iOS',
href: 'https://get.immich.app/ios',
label: 'Installation',
to: '/install/requirements',
},
{
label: 'Server',
href: 'https://immich.app/download',
},
],
},
{
title: 'Company',
items: [
{
label: 'FUTO',
href: 'https://futo.tech/',
},
{
label: 'Purchase',
href: 'https://buy.immich.app/',
},
{
label: 'Merch',
href: 'https://immich.store/',
},
],
},
{
title: 'Sites',
items: [
{
label: 'Home',
href: 'https://immich.app',
},
{
label: 'My Immich',
href: 'https://my.immich.app/',
},
{
label: 'Awesome Immich',
href: 'https://awesome.immich.app/',
},
{
label: 'Immich API',
href: 'https://api.immich.app/',
},
{
label: 'Immich Data',
href: 'https://data.immich.app/',
},
{
label: 'Immich Datasets',
href: 'https://datasets.immich.app/',
},
],
},
{
title: 'Miscellaneous',
items: [
{
label: 'Roadmap',
href: 'https://immich.app/roadmap',
},
{
label: 'Cursed Knowledge',
href: 'https://immich.app/cursed-knowledge',
label: 'Contributing',
to: '/overview/support-the-project',
},
{
label: 'Privacy Policy',
@@ -194,7 +155,24 @@ const config = {
],
},
{
title: 'Social',
title: 'Documentation',
items: [
{
label: 'Roadmap',
href: 'https://immich.app/roadmap',
},
{
label: 'API',
href: 'https://api.immich.app/',
},
{
label: 'Cursed Knowledge',
href: 'https://immich.app/cursed-knowledge',
},
],
},
{
title: 'Links',
items: [
{
label: 'GitHub',

View File

@@ -23,7 +23,6 @@
/features/storage-template /administration/storage-template 307
/features/user-management /administration/user-management 307
/developer/contributing /developer/pr-checklist 307
/developer/open-api /api 307
/guides/machine-learning /guides/remote-machine-learning 307
/administration/password-login /administration/system-settings 307
/features/search /features/searching 307

View File

@@ -1621,6 +1621,7 @@
"not_available": "N/A",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
@@ -1810,8 +1811,9 @@
"rate_asset": "Rate Asset",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}",
"rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",
@@ -1883,10 +1885,7 @@
"reset_pin_code_success": "Successfully reset PIN code",
"reset_pin_code_with_password": "You can always reset your PIN code with your password",
"reset_sqlite": "Reset SQLite Database",
"reset_sqlite_clear_app_data": "Clear Data",
"reset_sqlite_confirmation": "Are you sure you want to clear the app data? This will remove all settings and sign you out.",
"reset_sqlite_confirmation_note": "Note: You will need to restart the app after clearing.",
"reset_sqlite_done": "App data has been cleared. Please restart Immich and log in again.",
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
"reset_sqlite_success": "Successfully reset the SQLite database",
"reset_to_default": "Reset to default",
"resolution": "Resolution",
@@ -1914,7 +1913,6 @@
"saved_settings": "Saved settings",
"say_something": "Say something",
"scaffold_body_error_occurred": "Error occurred",
"scaffold_body_error_unrecoverable": "An unrecoverable error has occurred. Please share the error and stack trace on Discord or GitHub so we can help. If advised, you can clear the app data below.",
"scan": "Scan",
"scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan",

View File

@@ -22,7 +22,48 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS builder-rocm
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
chmod +x cmake-3.31.9-linux-x86_64.sh && \
mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
rm cmake-3.31.9-linux-x86_64.sh
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
WORKDIR /code/onnxruntime
# Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567
# TODO: find a way to fix this without disabling algo caching
COPY ./patches/* /tmp/
RUN git apply /tmp/*.patch
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
ENV CCACHE_DIR="/ccache"
# Note: the `parallel` setting uses a substantial amount of RAM
RUN --mount=type=cache,target=/ccache \
./build.sh \
--allow_running_as_root \
--config Release \
--build_wheel \
--update \
--build \
--parallel 48 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
--skip_tests \
--use_rocm \
--rocm_home=/opt/rocm \
--use_cache \
--compile_no_warning_as_error
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
FROM builder-${DEVICE} AS builder
@@ -38,6 +79,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:04cd27899595a99dfe77709d96f08876bf2ee99139ee2f0fe9ac948005034e5b AS prod-cpu
@@ -76,11 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS prod-rocm
RUN apt-get update && apt-get install --no-install-recommends -yqq migraphx miopen-hip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
FROM prod-cpu AS prod-armnn

View File

@@ -79,7 +79,6 @@ class Settings(BaseSettings):
preload: PreloadModelData | None = None
max_batch_size: MaxBatchSize | None = None
openvino_precision: ModelPrecision = ModelPrecision.FP32
rocm_precision: ModelPrecision = ModelPrecision.FP32
@property
def device_id(self) -> str:

View File

@@ -90,7 +90,7 @@ _PADDLE_MODELS = {
SUPPORTED_PROVIDERS = [
"CUDAExecutionProvider",
"MIGraphXExecutionProvider",
"ROCMExecutionProvider",
"OpenVINOExecutionProvider",
"CoreMLExecutionProvider",
"CPUExecutionProvider",

View File

@@ -8,7 +8,7 @@ import onnxruntime as ort
from numpy.typing import NDArray
from immich_ml.models.constants import SUPPORTED_PROVIDERS
from immich_ml.schemas import ModelPrecision, SessionNode
from immich_ml.schemas import SessionNode
from ..config import log, settings
@@ -90,17 +90,8 @@ class OrtSession:
match provider:
case "CPUExecutionProvider":
options = {"arena_extend_strategy": "kSameAsRequested"}
case "CUDAExecutionProvider":
case "CUDAExecutionProvider" | "ROCMExecutionProvider":
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id}
case "MIGraphXExecutionProvider":
migraphx_dir = self.model_path.parent / "migraphx"
# MIGraphX does not create the underlying folder and will crash if it does not exist
migraphx_dir.mkdir(parents=True, exist_ok=True)
options = {
"device_id": settings.device_id,
"migraphx_model_cache_dir": migraphx_dir.as_posix(),
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
}
case "OpenVINOExecutionProvider":
openvino_dir = self.model_path.parent / "openvino"
device = f"GPU.{settings.device_id}"

View File

@@ -0,0 +1,179 @@
commit 16839b58d9b3c3162a67ce5d776b36d4d24e801f
Author: mertalev <101130780+mertalev@users.noreply.github.com>
Date: Wed Mar 5 11:25:38 2025 -0500
disable algo caching (attributed to @dmnieto in https://github.com/microsoft/onnxruntime/pull/19567)
diff --git a/onnxruntime/core/providers/rocm/nn/conv.cc b/onnxruntime/core/providers/rocm/nn/conv.cc
index d7f47d07a8..4060a2af52 100644
--- a/onnxruntime/core/providers/rocm/nn/conv.cc
+++ b/onnxruntime/core/providers/rocm/nn/conv.cc
@@ -127,7 +127,6 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
if (w_dims_changed) {
s_.last_w_dims = gsl::make_span(w_dims);
- s_.cached_benchmark_fwd_results.clear();
}
ORT_RETURN_IF_ERROR(conv_attrs_.ValidateInputShape(X->Shape(), W->Shape(), channels_last, channels_last));
@@ -277,35 +276,6 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
HIP_CALL_THROW(hipMalloc(&s_.b_zero, malloc_size));
HIP_CALL_THROW(hipMemsetAsync(s_.b_zero, 0, malloc_size, Stream(context)));
}
-
- if (!s_.cached_benchmark_fwd_results.contains(x_dims_miopen)) {
- miopenConvAlgoPerf_t perf;
- int algo_count = 1;
- const ROCMExecutionProvider* rocm_ep = static_cast<const ROCMExecutionProvider*>(this->Info().GetExecutionProvider());
- static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT;
- size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId())
- : AlgoSearchWorkspaceSize;
- IAllocatorUniquePtr<void> algo_search_workspace = GetTransientScratchBuffer<void>(max_ws_size);
- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm(
- GetMiopenHandle(context),
- s_.x_tensor,
- s_.x_data,
- s_.w_desc,
- s_.w_data,
- s_.conv_desc,
- s_.y_tensor,
- s_.y_data,
- 1, // requestedAlgoCount
- &algo_count, // returnedAlgoCount
- &perf,
- algo_search_workspace.get(),
- max_ws_size,
- false)); // Do not do exhaustive algo search.
- s_.cached_benchmark_fwd_results.insert(x_dims_miopen, {perf.fwd_algo, perf.memory});
- }
- const auto& perf = s_.cached_benchmark_fwd_results.at(x_dims_miopen);
- s_.fwd_algo = perf.fwd_algo;
- s_.workspace_bytes = perf.memory;
} else {
// set Y
s_.Y = context->Output(0, TensorShape(s_.y_dims));
@@ -319,6 +289,31 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
s_.y_data = reinterpret_cast<HipT*>(s_.Y->MutableData<T>());
}
}
+
+ miopenConvAlgoPerf_t perf;
+ int algo_count = 1;
+ const ROCMExecutionProvider* rocm_ep = static_cast<const ROCMExecutionProvider*>(this->Info().GetExecutionProvider());
+ static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT;
+ size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId())
+ : AlgoSearchWorkspaceSize;
+ IAllocatorUniquePtr<void> algo_search_workspace = GetTransientScratchBuffer<void>(max_ws_size);
+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm(
+ GetMiopenHandle(context),
+ s_.x_tensor,
+ s_.x_data,
+ s_.w_desc,
+ s_.w_data,
+ s_.conv_desc,
+ s_.y_tensor,
+ s_.y_data,
+ 1, // requestedAlgoCount
+ &algo_count, // returnedAlgoCount
+ &perf,
+ algo_search_workspace.get(),
+ max_ws_size,
+ false)); // Do not do exhaustive algo search.
+ s_.fwd_algo = perf.fwd_algo;
+ s_.workspace_bytes = perf.memory;
return Status::OK();
}
diff --git a/onnxruntime/core/providers/rocm/nn/conv.h b/onnxruntime/core/providers/rocm/nn/conv.h
index bc9846203e..d54218f258 100644
--- a/onnxruntime/core/providers/rocm/nn/conv.h
+++ b/onnxruntime/core/providers/rocm/nn/conv.h
@@ -108,9 +108,6 @@ class lru_unordered_map {
list_type lru_list_;
};
-// cached miopen descriptors
-constexpr size_t MAX_CACHED_ALGO_PERF_RESULTS = 10000;
-
template <typename AlgoPerfType>
struct MiopenConvState {
// if x/w dims changed, update algo and miopenTensors
@@ -148,9 +145,6 @@ struct MiopenConvState {
decltype(AlgoPerfType().memory) memory;
};
- lru_unordered_map<TensorShapeVector, PerfFwdResultParams, vector_hash> cached_benchmark_fwd_results{MAX_CACHED_ALGO_PERF_RESULTS};
- lru_unordered_map<TensorShapeVector, PerfBwdResultParams, vector_hash> cached_benchmark_bwd_results{MAX_CACHED_ALGO_PERF_RESULTS};
-
// Some properties needed to support asymmetric padded Conv nodes
bool post_slicing_required;
TensorShapeVector slice_starts;
diff --git a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
index 7447113fdf..a662e35b2e 100644
--- a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
+++ b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
@@ -76,7 +76,6 @@ Status ConvTranspose<T, NHWC>::DoConvTranspose(OpKernelContext* context, bool dy
if (w_dims_changed) {
s_.last_w_dims = gsl::make_span(w_dims);
- s_.cached_benchmark_bwd_results.clear();
}
ConvTransposeAttributes::Prepare p;
@@ -126,35 +125,29 @@ Status ConvTranspose<T, NHWC>::DoConvTranspose(OpKernelContext* context, bool dy
}
y_data = reinterpret_cast<HipT*>(p.Y->MutableData<T>());
-
- if (!s_.cached_benchmark_bwd_results.contains(x_dims)) {
- IAllocatorUniquePtr<void> algo_search_workspace = GetScratchBuffer<void>(AlgoSearchWorkspaceSize, context->GetComputeStream());
-
- miopenConvAlgoPerf_t perf;
- int algo_count = 1;
- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm(
- GetMiopenHandle(context),
- s_.x_tensor,
- x_data,
- s_.w_desc,
- w_data,
- s_.conv_desc,
- s_.y_tensor,
- y_data,
- 1,
- &algo_count,
- &perf,
- algo_search_workspace.get(),
- AlgoSearchWorkspaceSize,
- false));
- s_.cached_benchmark_bwd_results.insert(x_dims, {perf.bwd_data_algo, perf.memory});
- }
-
- const auto& perf = s_.cached_benchmark_bwd_results.at(x_dims);
- s_.bwd_data_algo = perf.bwd_data_algo;
- s_.workspace_bytes = perf.memory;
}
+ IAllocatorUniquePtr<void> algo_search_workspace = GetScratchBuffer<void>(AlgoSearchWorkspaceSize, context->GetComputeStream());
+ miopenConvAlgoPerf_t perf;
+ int algo_count = 1;
+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm(
+ GetMiopenHandle(context),
+ s_.x_tensor,
+ x_data,
+ s_.w_desc,
+ w_data,
+ s_.conv_desc,
+ s_.y_tensor,
+ y_data,
+ 1,
+ &algo_count,
+ &perf,
+ algo_search_workspace.get(),
+ AlgoSearchWorkspaceSize,
+ false));
+ s_.bwd_data_algo = perf.bwd_data_algo;
+ s_.workspace_bytes = perf.memory;
+
// The following block will be executed in case there has been no change in the shapes of the
// input and the filter compared to the previous run
if (!y_data) {

View File

@@ -0,0 +1,33 @@
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
index bbb672a99e..0dc652fbda 100644
--- a/dockerfiles/scripts/install_common_deps.sh
+++ b/dockerfiles/scripts/install_common_deps.sh
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
curl \
libcurl4-openssl-dev \
libssl-dev \
- python3-dev
+ python3-dev \
+ ccache
# Dependencies: conda
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
rm ~/miniconda.sh
/opt/miniconda/bin/conda clean -ya
-pip install numpy
-pip install packaging
-pip install "wheel>=0.35.1"
+# Dependencies: venv and packages
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
+/opt/rocm-venv/bin/pip install --no-cache-dir \
+ "numpy==2.3.4" \
+ "packaging==25.0" \
+ "wheel==0.45.1" \
+ "setuptools==80.9.0"
+
rm -rf /opt/miniconda/pkgs
# Dependencies: cmake

View File

@@ -52,7 +52,7 @@ cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
rocm = []
[tool.uv]
compile-bytecode = true

View File

@@ -179,7 +179,7 @@ class TestOrtSession:
OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"]
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
ROCM_EP = ["MIGraphXExecutionProvider", "CPUExecutionProvider"]
ROCM_EP = ["ROCMExecutionProvider", "CPUExecutionProvider"]
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
@pytest.mark.providers(CPU_EP)
@@ -289,38 +289,12 @@ class TestOrtSession:
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
def test_sets_provider_options_for_rocm(self, mocker: MockerFixture) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
def test_sets_provider_options_for_rocm(self) -> None:
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
session = OrtSession("ViT-B-32__openai", providers=["ROCMExecutionProvider"])
assert session.provider_options == [
{
"device_id": "1",
"migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx",
"migraphx_fp16_enable": "0",
}
]
mkdir.assert_called_once_with(parents=True, exist_ok=True)
def test_sets_rocm_to_fp16_if_enabled(self, path: mock.Mock, mocker: MockerFixture) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
mocker.patch.object(settings, "rocm_precision", ModelPrecision.FP16)
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
assert session.provider_options == [
{
"device_id": "1",
"migraphx_model_cache_dir": "/cache/ViT-B-32__openai/textual/migraphx",
"migraphx_fp16_enable": "1",
}
]
mkdir.assert_called_once_with(parents=True, exist_ok=True)
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
def test_sets_provider_options_kwarg(self) -> None:
session = OrtSession(

View File

@@ -960,9 +960,6 @@ rknn = [
{ name = "onnxruntime" },
{ name = "rknn-toolkit-lite2" },
]
rocm = [
{ name = "onnxruntime-migraphx" },
]
[package.dev-dependencies]
dev = [
@@ -1016,7 +1013,6 @@ requires-dist = [
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
{ name = "orjson", specifier = ">=3.9.5" },
@@ -1433,55 +1429,32 @@ wheels = [
[[package]]
name = "msgpack"
version = "1.1.2"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/d5/5662032db1571110b5b51647aed4b56dfbd01bfae789fa566a2be1f385d1/msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", size = 166311, upload-time = "2023-09-28T13:20:36.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" },
{ url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" },
{ url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" },
{ url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" },
{ url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" },
{ url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" },
{ url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" },
{ url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
{ url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
{ url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
{ url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
{ url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
{ url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b3/309de40dc7406b7f3492332c5ee2b492a593c2a9bb97ea48ebf2f5279999/msgpack-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", size = 305096, upload-time = "2023-09-28T13:18:49.678Z" },
{ url = "https://files.pythonhosted.org/packages/15/56/a677cd761a2cefb2e3ffe7e684633294dccb161d78e8ea6da9277e45b4a2/msgpack-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", size = 235210, upload-time = "2023-09-28T13:18:51.039Z" },
{ url = "https://files.pythonhosted.org/packages/f5/4e/1ab4a982cbd90f988e49f849fc1212f2c04a59870c59daabf8950617e2aa/msgpack-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", size = 231952, upload-time = "2023-09-28T13:18:52.871Z" },
{ url = "https://files.pythonhosted.org/packages/6d/74/bd02044eb628c7361ad2bd8c1a6147af5c6c2bbceb77b3b1da20f4a8a9c5/msgpack-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", size = 549511, upload-time = "2023-09-28T13:18:54.422Z" },
{ url = "https://files.pythonhosted.org/packages/df/09/dee50913ba5cc047f7fd7162f09453a676e7935c84b3bf3a398e12108677/msgpack-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", size = 557980, upload-time = "2023-09-28T13:18:56.058Z" },
{ url = "https://files.pythonhosted.org/packages/26/a5/78a7d87f5f8ffe4c32167afa15d4957db649bab4822f909d8d765339bbab/msgpack-1.0.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", size = 545547, upload-time = "2023-09-28T13:18:57.396Z" },
{ url = "https://files.pythonhosted.org/packages/d4/53/698c10913947f97f6fe7faad86a34e6aa1b66cea2df6f99105856bd346d9/msgpack-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", size = 554669, upload-time = "2023-09-28T13:18:58.957Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3f/9730c6cb574b15d349b80cd8523a7df4b82058528339f952ea1c32ac8a10/msgpack-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", size = 583353, upload-time = "2023-09-28T13:19:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/4c/bc/dc184d943692671149848438fb3bed3a3de288ce7998cb91bc98f40f201b/msgpack-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", size = 557455, upload-time = "2023-09-28T13:19:03.201Z" },
{ url = "https://files.pythonhosted.org/packages/cf/7b/1bc69d4a56c8d2f4f2dfbe4722d40344af9a85b6fb3b09cfb350ba6a42f6/msgpack-1.0.7-cp311-cp311-win32.whl", hash = "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", size = 216367, upload-time = "2023-09-28T13:19:04.554Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/c8dd23050eefa3d9b9c5b8329ed3308c2f2f80f65825e9ea4b7fa621cdab/msgpack-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", size = 222860, upload-time = "2023-09-28T13:19:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/d7/47/20dff6b4512cf3575550c8801bc53fe7d540f4efef9c5c37af51760fcdcf/msgpack-1.0.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", size = 305759, upload-time = "2023-09-28T13:19:08.148Z" },
{ url = "https://files.pythonhosted.org/packages/6f/8a/34f1726d2c9feccec3d946776e9bce8f20ae09d8b91899fc20b296c942af/msgpack-1.0.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", size = 235330, upload-time = "2023-09-28T13:19:09.417Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f6/e64c72577d6953789c3cb051b059a4b56317056b3c65013952338ed8a34e/msgpack-1.0.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", size = 232537, upload-time = "2023-09-28T13:19:10.898Z" },
{ url = "https://files.pythonhosted.org/packages/89/75/1ed3a96e12941873fd957e016cc40c0c178861a872bd45e75b9a188eb422/msgpack-1.0.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", size = 546561, upload-time = "2023-09-28T13:19:12.779Z" },
{ url = "https://files.pythonhosted.org/packages/e5/0a/c6a1390f9c6a31da0fecbbfdb86b1cb39ad302d9e24f9cca3d9e14c364f0/msgpack-1.0.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", size = 559009, upload-time = "2023-09-28T13:19:14.373Z" },
{ url = "https://files.pythonhosted.org/packages/a5/74/99f6077754665613ea1f37b3d91c10129f6976b7721ab4d0973023808e5a/msgpack-1.0.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", size = 543882, upload-time = "2023-09-28T13:19:16.277Z" },
{ url = "https://files.pythonhosted.org/packages/9c/7e/dc0dc8de2bf27743b31691149258f9b1bd4bf3c44c105df3df9b97081cd1/msgpack-1.0.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", size = 546949, upload-time = "2023-09-28T13:19:18.114Z" },
{ url = "https://files.pythonhosted.org/packages/78/61/91bae9474def032f6c333d62889bbeda9e1554c6b123375ceeb1767efd78/msgpack-1.0.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", size = 579836, upload-time = "2023-09-28T13:19:19.729Z" },
{ url = "https://files.pythonhosted.org/packages/5d/4d/d98592099d4f18945f89cf3e634dc0cb128bb33b1b93f85a84173d35e181/msgpack-1.0.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", size = 556587, upload-time = "2023-09-28T13:19:21.666Z" },
{ url = "https://files.pythonhosted.org/packages/5e/44/6556ffe169bf2c0e974e2ea25fb82a7e55ebcf52a81b03a5e01820de5f84/msgpack-1.0.7-cp312-cp312-win32.whl", hash = "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", size = 216509, upload-time = "2023-09-28T13:19:23.161Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c1/63903f30d51d165e132e5221a2a4a1bbfab7508b68131c871d70bffac78a/msgpack-1.0.7-cp312-cp312-win_amd64.whl", hash = "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", size = 223287, upload-time = "2023-09-28T13:19:25.097Z" },
]
[[package]]
@@ -1728,24 +1701,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/94/a3b20276261f5e64dbd72bda656af988282cff01f18c2685953600e2f810/onnxruntime_gpu-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2cee7e12b0f4813c62f9a48df83fd01d066cc970400c832252cf3c155a6957", size = 252633096, upload-time = "2026-02-05T17:24:53.248Z" },
]
[[package]]
name = "onnxruntime-migraphx"
version = "1.24.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/da/ca7ebc1a8d1193c97ceb9a05fad50f675eb955dc51beb7eb9ba89c8e7db0/onnxruntime_migraphx-1.24.2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:a2b434fb8880cac2b268950bdf279f33741d29c1f1c5461d27af835e8e288043", size = 20339710, upload-time = "2026-02-21T07:25:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/8c83ec45a9365b4256495ca55eea30da7f03b02177b6da423c7da1ff5f6a/onnxruntime_migraphx-1.24.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ec814818da952bda3062e26f56c88bb713c00491ef91f86716c8d7346f9bc31b", size = 20341883, upload-time = "2026-02-21T07:25:17.86Z" },
{ url = "https://files.pythonhosted.org/packages/9f/52/4776ac68dbc46ca02c9a14cc9e5c496017f47a18cedf606cc38f4911b96a/onnxruntime_migraphx-1.24.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:20e497538362170af639b03a40249d7ed61b873ac354f20d732b90252206e320", size = 20342422, upload-time = "2026-02-21T07:25:22.526Z" },
{ url = "https://files.pythonhosted.org/packages/76/44/db9035204a3363f9c0a4822c68e9a7520c13ef8d261f96b89b1375106dab/onnxruntime_migraphx-1.24.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:9d7f1b1a2b9651143a2080b4f42ee99eead02023de1855d1b8a02199a9c179aa", size = 20343783, upload-time = "2026-02-21T07:25:29.155Z" },
]
[[package]]
name = "onnxruntime-openvino"
version = "1.23.0"

View File

@@ -76,6 +76,10 @@ enum StoreKey<T> {
// Image viewer navigation settings
tapToNavigate<bool>._(141),
// Map custom time range settings
mapCustomFrom<String>._(142),
mapCustomTo<String>._(143),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),
betaPromptShown<bool>._(1001),

View File

@@ -27,9 +27,19 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
final from = options.timeRange.from;
final to = options.timeRange.to;
if (from != null || to != null) {
if (from != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from);
}
if (to != null) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to);
}
} else if (options.relativeDays > 0) {
final fromDate = DateTime.now().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(fromDate);
}
return condition;

View File

@@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart';
@@ -21,6 +22,7 @@ class TimelineMapOptions {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({
required this.bounds,
@@ -28,6 +30,7 @@ class TimelineMapOptions {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
}
@@ -535,7 +538,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final from = options.timeRange.from;
final to = options.timeRange.to;
if (from != null || to != null) {
// Use custom from/to filters
if (from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
}
if (to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
}
} else if (options.relativeDays > 0) {
// Use relative days
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -577,7 +592,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final from = options.timeRange.from;
final to = options.timeRange.to;
if (from != null || to != null) {
// Use custom from/to filters
if (from != null) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
}
if (to != null) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
}
} else if (options.relativeDays > 0) {
// Use relative days
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}

View File

@@ -20,7 +20,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/pages/common/splash_screen.page.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
@@ -50,34 +49,30 @@ import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart';
void main() async {
try {
ImmichWidgetsBinding();
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
await EasyLocalization.ensureInitialized();
final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
ImmichWidgetsBinding();
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
final (isar, drift, logDb) = await Bootstrap.initDB();
await Bootstrap.initDomain(isar, drift, logDb);
await initApp();
// Warm-up isolate pool for worker manager
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp(
ProviderScope(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(),
),
);
} catch (error, stack) {
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
}
runApp(
ProviderScope(
overrides: [
dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)),
],
child: const MainWidget(),
),
);
}
Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting();
if (Platform.isAndroid) {

View File

@@ -1,17 +1,10 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -20,254 +13,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/theme/color_scheme.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:logging/logging.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl, LaunchMode;
class BootstrapErrorWidget extends StatelessWidget {
final String error;
final String stack;
const BootstrapErrorWidget({super.key, required this.error, required this.stack});
@override
Widget build(BuildContext _) {
final immichTheme = defaultColorPreset.themeOfPreset;
return EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
useFallbackTranslations: true,
fallbackLocale: locales.values.first,
assetLoader: const CodegenLoader(),
child: Builder(
builder: (lCtx) => MaterialApp(
title: 'Immich',
debugShowCheckedModeBanner: true,
localizationsDelegates: lCtx.localizationDelegates,
supportedLocales: lCtx.supportedLocales,
locale: lCtx.locale,
themeMode: ThemeMode.system,
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: lCtx.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: lCtx.locale),
home: Builder(
builder: (ctx) => Scaffold(
body: Column(
children: [
const SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ImmichLogo(size: 48), SizedBox(width: 12), ImmichTitleText(fontSize: 24)],
),
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: _ErrorCard(error: error, stack: stack),
),
),
const Divider(height: 1),
const SafeArea(
top: false,
child: Padding(padding: EdgeInsets.fromLTRB(24, 16, 24, 16), child: _BottomPanel()),
),
],
),
),
),
),
),
);
}
}
class _BottomPanel extends StatefulWidget {
const _BottomPanel();
@override
State<_BottomPanel> createState() => _BottomPanelState();
}
class _BottomPanelState extends State<_BottomPanel> {
bool _cleared = false;
Future<void> _clearDatabase() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogCtx) => AlertDialog(
title: Text(context.t.reset_sqlite_clear_app_data),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t.reset_sqlite_confirmation),
const SizedBox(height: 12),
Text(
context.t.reset_sqlite_confirmation_note,
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.of(dialogCtx).pop(false), child: Text(context.t.cancel)),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(true),
child: Text(context.t.confirm, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final db = Drift();
try {
await db.reset();
} finally {
await db.close();
}
if (mounted) {
setState(() => _cleared = true);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
Text(
_cleared ? context.t.reset_sqlite_done : context.t.scaffold_body_error_unrecoverable,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_ActionLink(
icon: Icons.chat_bubble_outline,
label: context.t.discord,
onTap: () => launchUrl(Uri.parse('https://discord.immich.app/'), mode: LaunchMode.externalApplication),
),
_ActionLink(
icon: Icons.bug_report_outlined,
label: context.t.profile_drawer_github,
onTap: () => launchUrl(
Uri.parse('https://github.com/immich-app/immich/issues'),
mode: LaunchMode.externalApplication,
),
),
if (!_cleared)
_ActionLink(
icon: Icons.delete_outline,
label: context.t.reset_sqlite_clear_app_data,
onTap: _clearDatabase,
),
],
),
],
);
}
}
class _ActionLink extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _ActionLink({required this.icon, required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 24),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12)),
],
),
),
);
}
}
class _ErrorCard extends StatelessWidget {
final String error;
final String stack;
const _ErrorCard({required this.error, required this.stack});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ColoredBox(
color: scheme.error,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(
child: Text(
context.t.scaffold_body_error_occurred,
style: textTheme.titleSmall?.copyWith(color: scheme.onError),
),
),
IconButton(
tooltip: context.t.copy_error,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(Icons.copy_outlined, size: 16, color: scheme.onError),
onPressed: () => Clipboard.setData(ClipboardData(text: '$error\n\n$stack')),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(error, style: textTheme.bodyMedium),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.t.stacktrace, style: textTheme.labelMedium),
const SizedBox(height: 4),
SelectableText(stack, style: textTheme.bodySmall?.copyWith(fontFamily: 'GoogleSansCode')),
],
),
),
],
),
);
}
}
@RoutePage()
class SplashScreenPage extends StatefulHookConsumerWidget {

View File

@@ -64,6 +64,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
void initState() {
super.initState();
_proxyScrollController.addListener(_onScroll);
_eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return;
@@ -93,7 +94,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic);
}
@@ -105,7 +105,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
}
void _syncShowingDetails() {
void _onScroll() {
final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true);
@@ -149,8 +149,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.scroll:
if (_drag == null) _startProxyDrag();
_drag?.update(details);
_syncShowingDetails();
case _DragIntent.dismiss:
_handleDragDown(context, details.localPosition - _dragStart!.localPosition);
}
@@ -169,8 +167,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.none:
case _DragIntent.scroll:
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
_viewer.setShowingDetails(!_willClose(scrollVelocity));
if (_willClose(scrollVelocity)) {
_viewer.setShowingDetails(false);
}
_drag?.end(details);
_drag = null;
case _DragIntent.dismiss:
@@ -307,7 +306,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData;
return PhotoView(
key: Key(displayAsset.heroTag),
key: ValueKey(displayAsset.heroTag),
index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size),
heroAttributes: heroAttributes,
@@ -335,7 +334,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
}
return PhotoView.customChild(
key: Key(displayAsset.heroTag),
key: ValueKey(displayAsset),
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
@@ -351,11 +350,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
enablePanAlways: true,
backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag),
key: ValueKey(displayAsset),
asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image(
key: ValueKey(displayAsset.heroTag),
image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height,
width: context.width,
@@ -459,25 +459,3 @@ class _AssetPageState extends ConsumerState<AssetPage> {
);
}
}
// A global key is used for video viewers to prevent them from being
// unnecessarily recreated. They're quite expensive, and maintain internal
// state. This can cause videos to restart multiple times during normal usage,
// like a hero animation.
//
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
// rather than equal. Hero tags are created with string interpolation, which
// prevents Dart from interning them. As such, hero tags are not identical, even
// if they are equal.
class _NativeVideoViewerKey extends GlobalKey {
final String value;
const _NativeVideoViewerKey(this.value) : super.constructor();
@override
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
@override
int get hashCode => value.hashCode;
}

View File

@@ -117,9 +117,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
final assetViewer = ref.read(assetViewerProvider);
_setSystemUIMode(assetViewer.showingControls, assetViewer.showingDetails);
}
@override
@@ -229,13 +226,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onAssetChanged(index);
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}
@override
Widget build(BuildContext context) {
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -255,7 +245,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
final (controls, details) = state;
_setSystemUIMode(controls, details);
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
});
return PopScope(

View File

@@ -420,18 +420,20 @@ class NativeVideoViewer extends HookConsumerWidget {
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(child: image),
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset),
enableRotation: false,
disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController),
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
),
),
if (showControls) const Center(child: VideoViewerControls()),

View File

@@ -9,6 +9,20 @@ import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class TimeRange {
final DateTime? from;
final DateTime? to;
const TimeRange({this.from, this.to});
TimeRange copyWith({DateTime? from, DateTime? to}) {
return TimeRange(from: from ?? this.from, to: to ?? this.to);
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}
class MapState {
final ThemeMode themeMode;
final LatLngBounds bounds;
@@ -16,6 +30,7 @@ class MapState {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const MapState({
this.themeMode = ThemeMode.system,
@@ -24,6 +39,7 @@ class MapState {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
@override
@@ -41,6 +57,7 @@ class MapState {
bool? includeArchived,
bool? withPartners,
int? relativeDays,
TimeRange? timeRange,
}) {
return MapState(
bounds: bounds ?? this.bounds,
@@ -49,6 +66,7 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
);
}
@@ -57,7 +75,7 @@ class MapState {
onlyFavorites: onlyFavorites,
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
timeRange: timeRange,
);
}
@@ -104,16 +122,32 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setTimeRange(TimeRange range) {
ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.mapCustomFrom, range.from == null ? "" : range.from!.toIso8601String());
ref
.read(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.mapCustomTo, range.to == null ? "" : range.to!.toIso8601String());
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override
MapState build() {
final appSettingsService = ref.read(appSettingsServiceProvider);
final customFrom = appSettingsService.getSetting(AppSettingsEnum.mapCustomFrom);
final customTo = appSettingsService.getSetting(AppSettingsEnum.mapCustomTo);
return MapState(
themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
timeRange: TimeRange(
from: customFrom.isNotEmpty ? DateTime.parse(customFrom) : null,
to: customTo.isNotEmpty ? DateTime.parse(customTo) : null,
),
);
}
}

View File

@@ -2,20 +2,36 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget {
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
const DriftMapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from != null || timeRange.to != null;
}
@override
Widget build(BuildContext context) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
initialChildSize: useCustomRange ? 0.7 : 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
@@ -47,10 +63,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
if (useCustomRange) ...[
MapTimeRange(
timeRange: mapState.timeRange,
onChanged: (range) {
ref.read(mapStateProvider.notifier).setTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text("remove_custom_date_range".t(context: context)),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text("use_custom_date_range".t(context: context)),
),
),
],
const SizedBox(height: 20),
],
),

View File

@@ -41,6 +41,8 @@ enum AppSettingsEnum<T> {
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
mapwithPartners<bool>(StoreKey.mapwithPartners, null, false),
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
mapCustomFrom<String>(StoreKey.mapCustomFrom, null, ""),
mapCustomTo<String>(StoreKey.mapCustomTo, null, ""),
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),

View File

@@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:intl/intl.dart';
class MapTimeRange extends StatelessWidget {
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
final TimeRange timeRange;
final Function(TimeRange) onChanged;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text("date_after".t(context: context)),
subtitle: Text(
timeRange.from != null
? DateFormat.yMMMd().add_jm().format(timeRange.from!)
: "not_set".t(context: context),
),
trailing: timeRange.from != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
: null,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: timeRange.from ?? DateTime.now(),
firstDate: DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(from: picked));
}
},
),
ListTile(
title: Text("date_before".t(context: context)),
subtitle: Text(
timeRange.to != null ? DateFormat.yMMMd().add_jm().format(timeRange.to!) : "not_set".t(context: context),
),
trailing: timeRange.to != null
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
: null,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: timeRange.to ?? DateTime.now(),
firstDate: DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(to: picked));
}
},
),
],
);
}
}

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -6,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -89,27 +87,25 @@ class SyncStatusAndActions extends HookConsumerWidget {
context: context,
builder: (context) {
return AlertDialog(
title: Text(context.t.reset_sqlite),
content: Text(context.t.reset_sqlite_confirmation),
title: Text("reset_sqlite".t(context: context)),
content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [
TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel)),
TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton(
onPressed: () async {
await ref.read(driftProvider).reset();
context.pop();
unawaited(
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(context.t.reset_sqlite_success),
content: Text(context.t.reset_sqlite_done),
actions: [TextButton(onPressed: () => ctx.pop(), child: Text(context.t.ok))],
),
),
context.scaffoldMessenger.showSnackBar(
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
);
},
child: Text(context.t.confirm, style: TextStyle(color: context.colorScheme.error)),
child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
);

View File

@@ -410,7 +410,7 @@ class SearchApi {
/// Filter by person IDs
///
/// * [num] rating:
/// Filter by rating [1-5], or null for unrated
/// Filter by rating
///
/// * [num] size:
/// Number of results to return
@@ -633,7 +633,7 @@ class SearchApi {
/// Filter by person IDs
///
/// * [num] rating:
/// Filter by rating [1-5], or null for unrated
/// Filter by rating
///
/// * [num] size:
/// Number of results to return

View File

@@ -30,9 +30,6 @@ class TimelineApi {
/// * [String] albumId:
/// Filter assets belonging to a specific album
///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
///
@@ -66,7 +63,7 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket';
@@ -80,9 +77,6 @@ class TimelineApi {
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (bbox != null) {
queryParams.addAll(_queryParams('', 'bbox', bbox));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
@@ -147,9 +141,6 @@ class TimelineApi {
/// * [String] albumId:
/// Filter assets belonging to a specific album
///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
///
@@ -183,8 +174,8 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -209,9 +200,6 @@ class TimelineApi {
/// * [String] albumId:
/// Filter assets belonging to a specific album
///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
///
@@ -245,7 +233,7 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets';
@@ -259,9 +247,6 @@ class TimelineApi {
if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId));
}
if (bbox != null) {
queryParams.addAll(_queryParams('', 'bbox', bbox));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
@@ -322,9 +307,6 @@ class TimelineApi {
/// * [String] albumId:
/// Filter assets belonging to a specific album
///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only)
///
@@ -358,8 +340,8 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -86,10 +86,16 @@ class AssetBulkUpdateDto {
///
num? longitude;
/// Rating in range [1-5], or null for unrated
/// Rating
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Time zone (IANA timezone)
@@ -217,9 +223,7 @@ class AssetBulkUpdateDto {
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: num.parse('${json[r'rating']}'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);

View File

@@ -256,10 +256,16 @@ class MetadataSearchDto {
///
String? previewPath;
/// Filter by rating [1-5], or null for unrated
/// Filter by rating
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Number of results to return
@@ -748,9 +754,7 @@ class MetadataSearchDto {
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -159,10 +159,16 @@ class RandomSearchDto {
/// Filter by person IDs
List<String> personIds;
/// Filter by rating [1-5], or null for unrated
/// Filter by rating
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Number of results to return
@@ -559,9 +565,7 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -199,10 +199,16 @@ class SmartSearchDto {
///
String? queryAssetId;
/// Filter by rating [1-5], or null for unrated
/// Filter by rating
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Number of results to return
@@ -599,9 +605,7 @@ class SmartSearchDto {
: const [],
query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -164,10 +164,16 @@ class StatisticsSearchDto {
/// Filter by person IDs
List<String> personIds;
/// Filter by rating [1-5], or null for unrated
/// Filter by rating
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Filter by state/province name
@@ -489,9 +495,7 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)

View File

@@ -71,10 +71,16 @@ class UpdateAssetDto {
///
num? longitude;
/// Rating in range [1-5], or null for unrated
/// Rating
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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.
///
num? rating;
/// Asset visibility
@@ -172,9 +178,7 @@ class UpdateAssetDto {
latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: num.parse('${json[r'rating']}'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);
}

View File

@@ -9407,27 +9407,10 @@
"name": "rating",
"required": false,
"in": "query",
"description": "Filter by rating [1-5], or null for unrated",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable",
"description": "Filter by rating",
"schema": {
"minimum": -1,
"maximum": 5,
"nullable": true,
"type": "number"
}
},
@@ -13492,16 +13475,6 @@
"type": "string"
}
},
{
"name": "bbox",
"required": false,
"in": "query",
"description": "Bounding box coordinates as west,south,east,north (WGS84)",
"schema": {
"example": "11.075683,49.416711,11.117589,49.454875",
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
@@ -13678,16 +13651,6 @@
"type": "string"
}
},
{
"name": "bbox",
"required": false,
"in": "query",
"description": "Bounding box coordinates as west,south,east,north (WGS84)",
"schema": {
"example": "11.075683,49.416711,11.117589,49.454875",
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
@@ -15909,27 +15872,10 @@
"type": "number"
},
"rating": {
"description": "Rating in range [1-5], or null for unrated",
"description": "Rating",
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
"type": "number"
},
"timeZone": {
"description": "Time zone (IANA timezone)",
@@ -19042,27 +18988,10 @@
"type": "string"
},
"rating": {
"description": "Filter by rating [1-5], or null for unrated",
"description": "Filter by rating",
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
"type": "number"
},
"size": {
"description": "Number of results to return",
@@ -20785,27 +20714,10 @@
"type": "array"
},
"rating": {
"description": "Filter by rating [1-5], or null for unrated",
"description": "Filter by rating",
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
"type": "number"
},
"size": {
"description": "Number of results to return",
@@ -22176,27 +22088,10 @@
"type": "string"
},
"rating": {
"description": "Filter by rating [1-5], or null for unrated",
"description": "Filter by rating",
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
"type": "number"
},
"size": {
"description": "Number of results to return",
@@ -22427,27 +22322,10 @@
"type": "array"
},
"rating": {
"description": "Filter by rating [1-5], or null for unrated",
"description": "Filter by rating",
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
"type": "number"
},
"state": {
"description": "Filter by state/province name",
@@ -25331,27 +25209,10 @@
"type": "number"
},
"rating": {
"description": "Rating in range [1-5], or null for unrated",
"description": "Rating",
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
"type": "number"
},
"visibility": {
"allOf": [

View File

@@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = {
latitude?: number;
/** Longitude coordinate */
longitude?: number;
/** Rating in range [1-5], or null for unrated */
rating?: number | null;
/** Rating */
rating?: number;
/** Time zone (IANA timezone) */
timeZone?: string;
/** Asset visibility */
@@ -944,8 +944,8 @@ export type UpdateAssetDto = {
livePhotoVideoId?: string | null;
/** Longitude coordinate */
longitude?: number;
/** Rating in range [1-5], or null for unrated */
rating?: number | null;
/** Rating */
rating?: number;
/** Asset visibility */
visibility?: AssetVisibility;
};
@@ -1711,8 +1711,8 @@ export type MetadataSearchDto = {
personIds?: string[];
/** Filter by preview file path */
previewPath?: string;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Filter by rating */
rating?: number;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1827,8 +1827,8 @@ export type RandomSearchDto = {
ocr?: string;
/** Filter by person IDs */
personIds?: string[];
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Filter by rating */
rating?: number;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1903,8 +1903,8 @@ export type SmartSearchDto = {
query?: string;
/** Asset ID to use as search reference */
queryAssetId?: string;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Filter by rating */
rating?: number;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = {
ocr?: string;
/** Filter by person IDs */
personIds?: string[];
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Filter by rating */
rating?: number;
/** Filter by state/province name */
state?: string | null;
/** Filter by tag IDs */
@@ -5454,7 +5454,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
model?: string | null;
ocr?: string;
personIds?: string[];
rating?: number | null;
rating?: number;
size?: number;
state?: string | null;
tagIds?: string[] | null;
@@ -6421,9 +6421,8 @@ export function tagAssets({ id, bulkIdsDto }: {
/**
* Get time bucket
*/
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string;
bbox?: string;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
@@ -6443,7 +6442,6 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
data: TimeBucketAssetResponseDto;
}>(`/timeline/bucket${QS.query(QS.explode({
albumId,
bbox,
isFavorite,
isTrashed,
key,
@@ -6464,9 +6462,8 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
/**
* Get time buckets
*/
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string;
bbox?: string;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
@@ -6485,7 +6482,6 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde
data: TimeBucketsResponseDto[];
}>(`/timeline/buckets${QS.query(QS.explode({
albumId,
bbox,
isFavorite,
isTrashed,
key,

18
pnpm-lock.yaml generated
View File

@@ -344,8 +344,8 @@ importers:
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@immich/sql-tools':
specifier: ^0.3.2
version: 0.3.2
specifier: ^0.2.0
version: 0.2.0
'@nestjs/bullmq':
specifier: ^11.0.1
version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.69.3)
@@ -3017,9 +3017,8 @@ packages:
'@immich/justified-layout-wasm@0.4.3':
resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==}
'@immich/sql-tools@0.3.2':
resolution: {integrity: sha512-UWhy/+Lf8C1dJip5wPfFytI3Vq/9UyDKQE1ROjXwVhT6E/CPgBkRLwHPetjYGPJ4o1JVVpRLnEEJCXdvzqVpGw==}
hasBin: true
'@immich/sql-tools@0.2.0':
resolution: {integrity: sha512-AH0GRIUYrckNKuid5uO33vgRbGaznhRtArdQ91K310A1oUFjaoNzOaZyZhXwEmft3WYeC1bx4fdgUeois2QH5A==}
'@immich/svelte-markdown-preprocess@0.2.1':
resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==}
@@ -6059,10 +6058,6 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -14860,9 +14855,8 @@ snapshots:
'@immich/justified-layout-wasm@0.4.3': {}
'@immich/sql-tools@0.3.2':
'@immich/sql-tools@0.2.0':
dependencies:
commander: 14.0.3
kysely: 0.28.11
kysely-postgres-js: 3.0.0(kysely@0.28.11)(postgres@3.4.8)
pg-connection-string: 2.11.0
@@ -18230,8 +18224,6 @@ snapshots:
commander@13.1.0: {}
commander@14.0.3: {}
commander@2.20.3: {}
commander@4.1.1: {}

View File

@@ -22,12 +22,12 @@
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
"migrations:debug": "node ./dist/bin/migrations.js debug",
"migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "node ./dist/bin/migrations.js create",
"migrations:run": "node ./dist/bin/migrations.js run",
"migrations:revert": "node ./dist/bin/migrations.js revert",
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "pnpm run schema:drop && pnpm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
@@ -35,7 +35,7 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@immich/sql-tools": "^0.3.2",
"@immich/sql-tools": "^0.2.0",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Kysely, sql } from 'kysely';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
const main = async () => {
const command = process.argv[2];
const path = process.argv[3] || 'src/Migration';
switch (command) {
case 'debug': {
await debug();
return;
}
case 'run': {
await runMigrations();
return;
}
case 'revert': {
await revert();
return;
}
case 'query': {
const query = process.argv[3];
await runQuery(query);
return;
}
case 'create': {
create(path, [], []);
return;
}
case 'generate': {
await generate(path);
return;
}
default: {
console.log(`Usage:
node dist/bin/migrations.js create <name>
node dist/bin/migrations.js generate <name>
node dist/bin/migrations.js run
node dist/bin/migrations.js revert
`);
}
}
};
const getDatabaseClient = () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
return new Kysely<any>(getKyselyConfig(database.config));
};
const runQuery = async (query: string) => {
const db = getDatabaseClient();
await sql.raw(query).execute(db);
await db.destroy();
};
const runMigrations = async () => {
const configRepository = new ConfigRepository();
const logger = LoggingRepository.create();
const db = getDatabaseClient();
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
await databaseRepository.runMigrations();
await db.destroy();
};
const revert = async () => {
const configRepository = new ConfigRepository();
const logger = LoggingRepository.create();
const db = getDatabaseClient();
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
try {
const migrationName = await databaseRepository.revertLastMigration();
if (!migrationName) {
console.log('No migrations to revert');
return;
}
markMigrationAsReverted(migrationName);
} finally {
await db.destroy();
}
};
const debug = async () => {
const { up } = await compare();
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
// const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
writeFileSync('./migrations.sql', upSql + '\n\n');
console.log('Wrote migrations.sql');
};
const generate = async (path: string) => {
const { up, down } = await compare();
if (up.items.length === 0) {
console.log('No changes detected');
return;
}
create(path, up.asSql(), down.asSql());
};
const create = (path: string, up: string[], down: string[]) => {
const timestamp = Date.now();
const name = basename(path, extname(path));
const filename = `${timestamp}-${name}.ts`;
const folder = dirname(path);
const fullPath = join(folder, filename);
mkdirSync(folder, { recursive: true });
writeFileSync(fullPath, asMigration({ up, down }));
console.log(`Wrote ${fullPath}`);
};
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const target = await schemaFromDatabase({ connection: database.config });
console.log(source.warnings.join('\n'));
const up = schemaDiff(source, target, {
tables: { ignoreExtra: true },
functions: { ignoreExtra: false },
parameters: { ignoreExtra: true },
});
const down = schemaDiff(target, source, {
tables: { ignoreExtra: false, ignoreMissing: true },
functions: { ignoreExtra: false },
extensions: { ignoreMissing: true },
parameters: { ignoreMissing: true },
});
return { up, down };
};
type MigrationProps = {
up: string[];
down: string[];
};
const asMigration = ({ up, down }: MigrationProps) => {
const upSql = up.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
const downSql = down.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
return `import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
${upSql}
}
export async function down(db: Kysely<any>): Promise<void> {
${downSql}
}
`;
};
const markMigrationAsReverted = (migrationName: string) => {
// eslint-disable-next-line unicorn/prefer-module
const distRoot = join(__dirname, '..');
const projectRoot = join(distRoot, '..');
const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations');
const distFolder = join(distRoot, 'schema', 'migrations');
const sourcePath = join(sourceFolder, `${migrationName}.ts`);
const revertedFolder = join(sourceFolder, 'reverted');
const revertedPath = join(revertedFolder, `${migrationName}.ts`);
if (existsSync(revertedPath)) {
console.log(`Migration ${migrationName} is already marked as reverted`);
} else if (existsSync(sourcePath)) {
mkdirSync(revertedFolder, { recursive: true });
renameSync(sourcePath, revertedPath);
console.log(`Moved ${sourcePath} to ${revertedPath}`);
} else {
console.warn(`Source migration file not found for ${migrationName}`);
}
const distBase = join(distFolder, migrationName);
for (const extension of ['.js', '.js.map', '.d.ts']) {
const filePath = `${distBase}${extension}`;
if (existsSync(filePath)) {
rmSync(filePath, { force: true });
console.log(`Removed ${filePath}`);
}
}
};
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
console.log('Something went wrong');
process.exit(1);
});

View File

@@ -207,28 +207,12 @@ describe(AssetController.name, () => {
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
}
});
it('should convert rating 0 to null', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
expect(status).toBe(200);
});
it('should leave correct ratings as-is', async () => {
const assetId = factory.uuid();
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
expect(status).toBe(200);
}
});
});
describe('GET /assets/statistics', () => {

View File

@@ -5,7 +5,6 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
Permission,
PluginContext,
@@ -112,7 +111,6 @@ export type Memory = {
export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
@@ -331,7 +329,6 @@ export const columns = {
asset: [
'asset.id',
'asset.checksum',
'asset.checksumAlgorithm',
'asset.deviceAssetId',
'asset.deviceId',
'asset.fileCreatedAt',

View File

@@ -13,7 +13,7 @@ import {
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { ImageDimensions } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -147,7 +147,6 @@ export type MapAsset = {
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { Type } from 'class-transformer';
import {
IsArray,
IsDateString,
@@ -16,7 +16,6 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { HistoryBuilder, Property } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
@@ -57,19 +56,12 @@ export class UpdateAssetBase {
@IsNotEmpty()
longitude?: number;
@Property({
description: 'Rating in range [1-5], or null for unrated',
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@ApiProperty({ description: 'Rating' })
@Optional()
@IsInt()
@Max(5)
@Min(-1)
@Transform(({ value }) => (value === 0 ? null : value))
rating?: number | null;
rating?: number;
@ApiProperty({ description: 'Asset description' })
@Optional()

View File

@@ -1,25 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude } from 'class-validator';
import { IsGreaterThanOrEqualTo } from 'src/validation';
export class BBoxDto {
@ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' })
@IsLongitude()
west!: number;
@ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' })
@IsLatitude()
south!: number;
@ApiProperty({
format: 'double',
description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
})
@IsLongitude()
east!: number;
@ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' })
@IsLatitude()
@IsGreaterThanOrEqualTo('south')
north!: number;
}

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
@@ -103,21 +103,12 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
albumIds?: string[];
@Property({
type: 'number',
description: 'Filter by rating [1-5], or null for unrated',
minimum: -1,
maximum: 5,
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 })
@Optional()
@IsInt()
@Max(5)
@Min(-1)
rating?: number | null;
rating?: number;
@ApiPropertyOptional({ description: 'Filter by OCR text content' })
@IsString()

View File

@@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import type { BBoxDto } from 'src/dtos/bbox.dto';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { ValidateBBox } from 'src/utils/bbox';
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class TimeBucketDto {
@@ -60,9 +59,6 @@ export class TimeBucketDto {
description: 'Include location data in the response',
})
withCoordinates?: boolean;
@ValidateBBox({ optional: true })
bbox?: BBoxDto;
}
export class TimeBucketAssetDto extends TimeBucketDto {

View File

@@ -37,11 +37,6 @@ export enum AssetType {
Other = 'OTHER',
}
export enum ChecksumAlgorithm {
sha1File = 'sha1-file', // sha1 checksum of the whole file contents
sha1Path = 'sha1-path', // sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated
}
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos

View File

@@ -250,7 +250,6 @@ where
select
"asset"."id",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."deviceAssetId",
"asset"."deviceId",
"asset"."fileCreatedAt",
@@ -563,7 +562,6 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
@@ -595,7 +593,6 @@ from
where
"asset"."deletedAt" is null
and "asset"."id" = $2
and "asset"."visibility" != $3
-- AssetJobRepository.streamForStorageTemplateJob
select
@@ -605,7 +602,6 @@ select
"asset"."checksum",
"asset"."originalPath",
"asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName",
"asset"."livePhotoVideoId",
"asset"."fileCreatedAt",
@@ -636,7 +632,6 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."deletedAt" is null
and "asset"."visibility" != $2
-- AssetJobRepository.streamForDeletedJob
select

View File

@@ -353,7 +353,6 @@ export class AssetJobRepository {
'asset.checksum',
'asset.originalPath',
'asset.isExternal',
'asset.visibility',
'asset.originalFileName',
'asset.livePhotoVideoId',
'asset.fileCreatedAt',
@@ -368,16 +367,13 @@ export class AssetJobRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
return this.storageTemplateAssetQuery()
.where('asset.id', '=', id)
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
.executeTakeFirst();
getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
return this.storageTemplateAssetQuery().stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })

View File

@@ -1,15 +1,5 @@
import { Injectable } from '@nestjs/common';
import {
ExpressionBuilder,
Insertable,
Kysely,
NotNull,
Selectable,
SelectQueryBuilder,
sql,
Updateable,
UpdateResult,
} from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
@@ -46,13 +36,6 @@ import { globToSqlPattern } from 'src/utils/misc';
export type AssetStats = Record<AssetType, number>;
export interface BoundingBox {
west: number;
south: number;
east: number;
north: number;
}
interface AssetStatsOptions {
isFavorite?: boolean;
isTrashed?: boolean;
@@ -81,7 +64,6 @@ interface AssetBuilderOptions {
assetType?: AssetType;
visibility?: AssetVisibility;
withCoordinates?: boolean;
bbox?: BoundingBox;
}
export interface TimeBucketOptions extends AssetBuilderOptions {
@@ -138,34 +120,6 @@ interface GetByIdsRelations {
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
const getBoundingCircle = (bbox: BoundingBox) => {
const { west, south, east, north } = bbox;
const eastUnwrapped = west <= east ? east : east + 360;
const centerLongitude = (((west + eastUnwrapped) / 2 + 540) % 360) - 180;
const centerLatitude = (south + north) / 2;
const radius = sql<number>`greatest(
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${west})),
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${east})),
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${west})),
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${east}))
)`;
return { centerLatitude, centerLongitude, radius };
};
const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T>, bbox: BoundingBox) => {
const { west, south, east, north } = bbox;
const withLatitude = qb.where('asset_exif.latitude', '>=', south).where('asset_exif.latitude', '<=', north);
if (west <= east) {
return withLatitude.where('asset_exif.longitude', '>=', west).where('asset_exif.longitude', '<=', east);
}
return withLatitude.where((eb) =>
eb.or([eb('asset_exif.longitude', '>=', west), eb('asset_exif.longitude', '<=', east)]),
);
};
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -697,20 +651,6 @@ export class AssetRepository {
.select(truncatedDate<Date>().as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(!!options.bbox, (qb) => {
const bbox = options.bbox!;
const circle = getBoundingCircle(bbox);
const withBoundingCircle = qb
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where(
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
'@>',
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
);
return withBoundingBox(withBoundingCircle, bbox);
})
.$if(options.visibility === undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.$if(!!options.albumId, (qb) =>
@@ -785,18 +725,6 @@ export class AssetRepository {
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.$if(!!options.bbox, (qb) => {
const bbox = options.bbox!;
const circle = getBoundingCircle(bbox);
const withBoundingCircle = qb.where(
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
'@>',
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
);
return withBoundingBox(withBoundingCircle, bbox);
})
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, ''))
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>

View File

@@ -22,7 +22,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema';
import { immich_uuid_v7 } from 'src/schema/functions';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
@@ -289,11 +288,7 @@ export class DatabaseRepository {
}
async getSchemaDrift() {
const source = schemaFromCode({
overrides: true,
namingStrategy: 'default',
uuidFunction: (version) => (version === 7 ? `${immich_uuid_v7.name}()` : 'uuid_generate_v4()'),
});
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const { database } = this.configRepository.getEnv();
const target = await schemaFromDatabase({ connection: database.config });

View File

@@ -107,7 +107,7 @@ export class MediaRepository {
ExposureTime: tags.exposureTime,
ProfileDescription: tags.profileDescription,
ColorSpace: tags.colorspace,
Rating: tags.rating === null ? 0 : tags.rating,
Rating: tags.rating,
// specially convert Orientation to numeric Orientation# for exiftool
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
};

View File

@@ -1,5 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
@@ -15,8 +15,3 @@ export const asset_visibility_enum = registerEnum({
name: 'asset_visibility_enum',
values: Object.values(AssetVisibility),
});
export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm),
});

View File

@@ -280,7 +280,7 @@ export const asset_edit_delete = registerFunction({
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END

View File

@@ -1,9 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -1,11 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "IDX_asset_exif_gist_earthcoord" ON "asset_exif" USING gist (ll_to_earth_public(latitude, longitude));`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_asset_exif_gist_earthcoord', '{"type":"index","name":"IDX_asset_exif_gist_earthcoord","sql":"CREATE INDEX \\"IDX_asset_exif_gist_earthcoord\\" ON \\"asset_exif\\" USING gist (ll_to_earth_public(latitude, longitude));"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_asset_exif_gist_earthcoord";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_asset_exif_gist_earthcoord';`.execute(db);
}

View File

@@ -1,36 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$$;`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE asset
SET "isEdited" = false
FROM deleted_edit
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
RETURN NULL;
END
$function$
`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
}

View File

@@ -1,35 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "asset_checksum_algorithm_enum" AS ENUM ('sha1-file','sha1-path');`.execute(db);
await sql`ALTER TABLE "asset" ADD "checksumAlgorithm" asset_checksum_algorithm_enum;`.execute(db);
// Update in batches to handle millions of rows efficiently
const batchSize = 10_000;
let updatedRows: number;
do {
const result = await sql`
UPDATE "asset"
SET "checksumAlgorithm" = CASE
WHEN "isExternal" = true THEN 'sha1-path'::asset_checksum_algorithm_enum
ELSE 'sha1-file'::asset_checksum_algorithm_enum
END
WHERE "id" IN (
SELECT "id"
FROM "asset"
WHERE "checksumAlgorithm" IS NULL
LIMIT ${batchSize}
)
`.execute(db);
updatedRows = Number(result.numAffectedRows ?? 0);
} while (updatedRows > 0);
await sql`ALTER TABLE "asset" ALTER COLUMN "checksumAlgorithm" SET NOT NULL;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "checksumAlgorithm";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}

View File

@@ -1,23 +1,9 @@
import {
Column,
ForeignKeyColumn,
Generated,
Index,
Int8,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
import { LockableProperty } from 'src/database';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_exif')
@Index({
name: 'IDX_asset_exif_gist_earthcoord',
using: 'gist',
expression: 'll_to_earth_public(latitude, longitude)',
})
@UpdatedAtTrigger('asset_exif_updatedAt')
export class AssetExifTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })

View File

@@ -12,8 +12,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { asset_checksum_algorithm_enum, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { asset_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table';
@@ -98,9 +98,6 @@ export class AssetTable {
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum
@Column({ enum: asset_checksum_algorithm_enum })
checksumAlgorithm!: ChecksumAlgorithm;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null;

View File

@@ -27,7 +27,6 @@ import {
AssetStatus,
AssetVisibility,
CacheControl,
ChecksumAlgorithm,
JobName,
Permission,
StorageFolder,
@@ -410,7 +409,6 @@ export class AssetMediaService extends BaseService {
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
checksumAlgorithm: asset.checksumAlgorithm,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
@@ -432,7 +430,6 @@ export class AssetMediaService extends BaseService {
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
deviceAssetId: dto.deviceAssetId,

View File

@@ -516,7 +516,7 @@ export class AssetService extends BaseService {
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number | null;
rating?: number;
}) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy(

View File

@@ -17,17 +17,7 @@ import {
ValidateLibraryImportPathResponseDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import {
AssetStatus,
AssetType,
ChecksumAlgorithm,
CronJob,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
} from 'src/enum';
import { AssetStatus, AssetType, CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AssetSyncResult } from 'src/repositories/library.repository';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -410,7 +400,6 @@ export class LibraryService extends BaseService {
ownerId,
libraryId,
checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`),
checksumAlgorithm: ChecksumAlgorithm.sha1Path,
originalPath: assetPath,
fileCreatedAt: stat.mtime,

View File

@@ -7,7 +7,6 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
ExifOrientation,
ImmichWorker,
JobName,
@@ -652,7 +651,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -706,7 +704,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -760,7 +757,6 @@ describe(MetadataService.name, () => {
expect(mocks.storage.readFile).toHaveBeenCalledWith(asset.originalPath, expect.any(Object));
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -1427,20 +1423,6 @@ describe(MetadataService.name, () => {
);
});
it('should handle 0 as unrated -> null', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ Rating: 0 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: null,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
it('should handle valid negative rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
@@ -1798,28 +1780,6 @@ describe(MetadataService.name, () => {
'timeZone',
]);
});
it('should write rating', async () => {
const asset = factory.jobAssets.sidecarWrite();
asset.exifInfo.rating = 4;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
it('should write null rating as 0', async () => {
const asset = factory.jobAssets.sidecarWrite();
asset.exifInfo.rating = null;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
});
describe('firstDateTime', () => {

View File

@@ -14,7 +14,6 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
DatabaseLock,
ExifOrientation,
ImmichWorker,
@@ -302,7 +301,7 @@ export class MetadataService extends BaseService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5),
rating: validateRange(exifTags.Rating, -1, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
@@ -452,7 +451,7 @@ export class MetadataService extends BaseService {
dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null,
latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating ?? 0,
rating: asset.exifInfo.rating,
tags: asset.exifInfo.tags,
timeZone: asset.exifInfo.timeZone,
},
@@ -676,7 +675,6 @@ export class MetadataService extends BaseService {
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,

View File

@@ -9,9 +9,6 @@ import { userStub } from 'test/fixtures/user.stub';
import { getForStorageTemplate } from 'test/mappers';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build();
const stillAsset = AssetFactory.from({ livePhotoVideoId: motionAsset.id }).exif().build();
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let mocks: ServiceMocks;
@@ -156,58 +153,6 @@ describe(StorageTemplateService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should migrate live photo motion video alongside the still image using album in path', async () => {
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
entityId: stillAsset.id,
pathType: AssetPathType.Original,
oldPath: stillAsset.originalPath,
newPath: newStillPicturePath,
});
mocks.move.create.mockResolvedValueOnce({
id: '124',
entityId: motionAsset.id,
pathType: AssetPathType.Original,
oldPath: motionAsset.originalPath,
newPath: newMotionPicturePath,
});
await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should use handlebar if condition for album', async () => {
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
@@ -764,18 +709,12 @@ describe(StorageTemplateService.name, () => {
})
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -796,53 +735,11 @@ describe(StorageTemplateService.name, () => {
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
});
it('should use still photo album info when migrating live photo motion video', async () => {
const user = userStub.user1;
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
entityId: stillAsset.id,
pathType: AssetPathType.Original,
oldPath: stillAsset.originalPath,
newPath: `/data/library/${user.id}/2022/${album.albumName}/${stillAsset.originalFileName}`,
});
mocks.move.create.mockResolvedValueOnce({
id: '124',
entityId: motionAsset.id,
pathType: AssetPathType.Original,
oldPath: motionAsset.originalPath,
newPath: `/data/library/${user.id}/2022/${album.albumName}/${motionAsset.originalFileName}`,
});
await sut.handleMigration();
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: stillAsset.id,
originalPath: expect.stringContaining(`/${album.albumName}/`),
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: motionAsset.id,
originalPath: expect.stringContaining(`/${album.albumName}/`),
});
});
});
describe('file rename correctness', () => {

View File

@@ -158,14 +158,12 @@ export class StorageTemplateService extends BaseService {
// move motion part of live photo
if (asset.livePhotoVideoId) {
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
includeHidden: true,
});
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
if (!livePhotoVideo) {
return JobStatus.Failed;
}
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
}
return JobStatus.Success;
}
@@ -193,12 +191,10 @@ export class StorageTemplateService extends BaseService {
// move motion part of live photo
if (asset.livePhotoVideoId) {
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
includeHidden: true,
});
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
if (livePhotoVideo) {
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
}
}
}
@@ -218,7 +214,7 @@ export class StorageTemplateService extends BaseService {
await this.moveRepository.cleanMoveHistorySingle(assetId);
}
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) {
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) {
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
// External assets are not affected by storage template
// TODO: shouldn't this only apply to external assets?
@@ -228,7 +224,7 @@ export class StorageTemplateService extends BaseService {
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
const { id, originalPath, checksum, fileSizeInByte } = asset;
const oldPath = originalPath;
const newPath = await this.getTemplatePath(asset, metadata, stillPhoto);
const newPath = await this.getTemplatePath(asset, metadata);
if (!fileSizeInByte) {
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
@@ -259,11 +255,7 @@ export class StorageTemplateService extends BaseService {
});
}
private async getTemplatePath(
asset: StorageAsset,
metadata: MoveAssetMetadata,
stillPhoto?: StorageAsset,
): Promise<string> {
private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise<string> {
const { storageLabel, filename } = metadata;
try {
@@ -304,12 +296,8 @@ export class StorageTemplateService extends BaseService {
let albumName = null;
let albumStartDate = null;
let albumEndDate = null;
const assetForMetadata = stillPhoto || asset;
if (this.template.needsAlbum) {
// For motion videos, use the still photo's album information since motion videos
// don't have album metadata attached directly
const albums = await this.albumRepository.getByAssetId(assetForMetadata.ownerId, assetForMetadata.id);
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
const album = albums?.[0];
if (album) {
albumName = album.albumName || null;
@@ -322,18 +310,16 @@ export class StorageTemplateService extends BaseService {
}
}
// For motion videos that are part of live photos, use the still photo's date
// to ensure both parts end up in the same folder
const storagePath = this.render(this.template.compiled, {
asset: assetForMetadata,
asset,
filename: sanitized,
extension,
albumName,
albumStartDate,
albumEndDate,
make: assetForMetadata.make,
model: assetForMetadata.model,
lensModel: assetForMetadata.lensModel,
make: asset.make,
model: asset.model,
lensModel: asset.lensModel,
});
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${extension}`;

View File

@@ -23,24 +23,6 @@ describe(TimelineService.name, () => {
userIds: [authStub.admin.user.id],
});
});
it('should pass bbox options to repository when all bbox fields are provided', async () => {
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
await sut.getTimeBuckets(authStub.admin, {
bbox: {
west: -70,
south: -30,
east: 120,
north: 55,
},
});
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
userIds: [authStub.admin.user.id],
bbox: { west: -70, south: -30, east: 120, north: 55 },
});
});
});
describe('getTimeBucket', () => {

View File

@@ -1,32 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiPropertyOptions } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsNotEmpty, ValidateNested } from 'class-validator';
import { Property } from 'src/decorators';
import { BBoxDto } from 'src/dtos/bbox.dto';
import { Optional } from 'src/validation';
type BBoxOptions = { optional?: boolean };
export const ValidateBBox = (options: BBoxOptions & ApiPropertyOptions = {}) => {
const { optional, ...apiPropertyOptions } = options;
return applyDecorators(
Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const [west, south, east, north] = value.split(',', 4).map(Number);
return Object.assign(new BBoxDto(), { west, south, east, north });
}),
Type(() => BBoxDto),
ValidateNested(),
Property({
type: 'string',
description: 'Bounding box coordinates as west,south,east,north (WGS84)',
example: '11.075683,49.416711,11.117589,49.454875',
...apiPropertyOptions,
}),
optional ? Optional({}) : IsNotEmpty(),
);
};

View File

@@ -427,25 +427,3 @@ export function IsIPRange(options: IsIPRangeOptions, validationOptions?: Validat
validationOptions,
);
}
@ValidatorConstraint({ name: 'isGreaterThanOrEqualTo' })
export class IsGreaterThanOrEqualToConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments) {
const relatedPropertyName = args.constraints?.[0] as string;
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
if (!Number.isFinite(value) || !Number.isFinite(relatedValue)) {
return true;
}
return Number(value) >= Number(relatedValue);
}
defaultMessage(args: ValidationArguments) {
const relatedPropertyName = args.constraints?.[0] as string;
return `${args.property} must be greater than or equal to ${relatedPropertyName}`;
}
}
export const IsGreaterThanOrEqualTo = (property: string, validationOptions?: ValidationOptions) => {
return Validate(IsGreaterThanOrEqualToConstraint, [property], validationOptions);
};

View File

@@ -1,5 +1,5 @@
import { Selectable } from 'kysely';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
@@ -51,7 +51,6 @@ export class AssetFactory {
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: '',
deviceId: '',
duplicateId: null,

View File

@@ -1,7 +1,7 @@
import { UserAdmin } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm, SharedLinkType } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -94,7 +94,6 @@ export const sharedLinkStub = {
type: AssetType.Video,
originalPath: 'fake_path/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
fileModifiedAt: today,
fileCreatedAt: today,
localDateTime: today,

View File

@@ -12,7 +12,6 @@ export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>)
isExternal: asset.isExternal,
checksum: asset.checksum,
timeZone: asset.exifInfo.timeZone,
visibility: asset.visibility,
fileCreatedAt: asset.fileCreatedAt,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,

View File

@@ -11,7 +11,6 @@ import {
AlbumUserRole,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
SourceType,
SyncEntityType,
@@ -536,7 +535,6 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
deviceId: '',
originalFileName: '',
checksum: randomBytes(32),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
type: AssetType.Image,
originalPath: '/path/to/something.jpg',
ownerId: 'not-a-valid-uuid',

View File

@@ -28,7 +28,6 @@ import {
AssetStatus,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
Permission,
SourceType,
@@ -250,7 +249,6 @@ const assetFactory = (
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: '',
deviceId: '',
duplicateId: null,

View File

@@ -17,9 +17,10 @@
const rateAsset = async (rating: number | null) => {
try {
const updateAssetDto = rating === null ? {} : { rating };
await updateAsset({
id: asset.id,
updateAssetDto: { rating },
updateAssetDto,
});
asset = {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import StarRating, { type Rating } from '$lib/elements/StarRating.svelte';
import StarRating from '$lib/elements/StarRating.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -14,9 +14,9 @@
let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
let rating = $derived(asset.exifInfo?.rating || 0);
const handleChangeRating = async (rating: number | null) => {
const handleChangeRating = async (rating: number) => {
try {
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
} catch (error) {
@@ -26,7 +26,7 @@
</script>
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
<section class="px-4 pt-4">
<section class="px-4 pt-2">
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
</section>
{/if}

View File

@@ -140,7 +140,7 @@
</ControlAppBar>
{/if}
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
<GalleryViewer {assets} {assetInteraction} {viewport} />
</section>
{:else if assets.length === 1}
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}

View File

@@ -45,7 +45,6 @@
pageHeaderOffset?: number;
slidingWindowOffset?: number;
arrowNavigation?: boolean;
allowDeletion?: boolean;
};
let {
@@ -61,7 +60,6 @@
slidingWindowOffset = 0,
pageHeaderOffset = 0,
arrowNavigation = true,
allowDeletion = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
@@ -275,15 +273,11 @@
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
if (allowDeletion) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
}
return shortcuts;

View File

@@ -1,186 +0,0 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import type { SelectionBBox } from '$lib/components/shared-components/map/types';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
import { ceil, floor } from 'lodash-es';
import { t } from 'svelte-i18n';
interface Props {
bbox: SelectionBBox;
selectedClusterIds: Set<string>;
assetCount: number;
onClose: () => void;
}
let { bbox, selectedClusterIds, assetCount, onClose }: Props = $props();
const assetInteraction = new AssetInteraction();
let timelineManager = $state<TimelineManager>() as TimelineManager;
let selectedAssets = $derived(assetInteraction.selectedAssets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.isVideo);
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.upsertAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
};
const handleEscape = () => {
assetInteraction.clearMultiselect();
};
const timelineBoundingBox = $derived(
`${floor(bbox.west, 6)},${floor(bbox.south, 6)},${ceil(bbox.east, 6)},${ceil(bbox.north, 6)}`,
);
const timelineOptions = $derived({
bbox: timelineBoundingBox,
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
assetFilter: selectedClusterIds,
});
$effect.pre(() => {
void timelineOptions;
assetInteraction.clearMultiselect();
});
</script>
<aside class="h-full w-full overflow-hidden bg-immich-bg dark:bg-immich-dark-bg flex flex-col contain-content">
<div class="flex items-center justify-between border-b border-gray-200 dark:border-immich-dark-gray pb-1 pe-1">
<div class="flex items-center gap-2">
<Icon icon={mdiImageMultiple} size="20" />
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
{$t('assets_count', { values: { count: assetCount } })}
</p>
</div>
<CloseButton onclick={onClose} />
</div>
<div class="min-h-0 flex-1">
<Timeline
bind:timelineManager
enableRouting={false}
options={timelineOptions}
onEscape={handleEscape}
{assetInteraction}
showArchiveIcon
/>
</div>
</aside>
{#if assetInteraction.selectionActive}
{@const Actions = getAssetBulkActions($t, assetInteraction.asControlContext())}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<Portal target="body">
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ActionButton action={Actions.AddToAlbum} />
{#if isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetInteraction.selectedAssets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
unarchive={assetInteraction.isAllArchived}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
<ActionMenuItem action={Actions.TranscodeVideoJob} />
</ButtonContextMenu>
{:else}
<DownloadAction />
{/if}
</AssetSelectControlBar>
</Portal>
{/if}

View File

@@ -49,7 +49,6 @@
Popup,
ScaleControl,
} from 'svelte-maplibre';
import type { SelectionBBox } from './types';
interface Props {
mapMarkers?: MapMarkerResponseDto[];
@@ -62,7 +61,6 @@
useLocationPin?: boolean;
onOpenInMapView?: (() => Promise<void> | void) | undefined;
onSelect?: (assetIds: string[]) => void;
onClusterSelect?: (assetIds: string[], bbox: SelectionBBox) => void;
onClickPoint?: ({ lat, lng }: { lat: number; lng: number }) => void;
popup?: import('svelte').Snippet<[{ marker: MapMarkerResponseDto }]>;
rounded?: boolean;
@@ -81,7 +79,6 @@
useLocationPin = false,
onOpenInMapView = undefined,
onSelect = () => {},
onClusterSelect,
onClickPoint = () => {},
popup,
rounded = false,
@@ -134,30 +131,9 @@
return;
}
const mapSource = map.getSource('geojson') as GeoJSONSource;
const mapSource = map?.getSource('geojson') as GeoJSONSource;
const leaves = await mapSource.getClusterLeaves(clusterId, 10_000, 0);
const ids = leaves.map((leaf) => leaf.properties?.id as string);
if (onClusterSelect && ids.length > 1) {
const [firstLongitude, firstLatitude] = (leaves[0].geometry as Point).coordinates;
let west = firstLongitude;
let south = firstLatitude;
let east = firstLongitude;
let north = firstLatitude;
for (const leaf of leaves.slice(1)) {
const [longitude, latitude] = (leaf.geometry as Point).coordinates;
west = Math.min(west, longitude);
south = Math.min(south, latitude);
east = Math.max(east, longitude);
north = Math.max(north, latitude);
}
const bbox = { west, south, east, north };
onClusterSelect(ids, bbox);
return;
}
const ids = leaves.map((leaf) => leaf.properties?.id);
onSelect(ids);
}

View File

@@ -1,6 +0,0 @@
export type SelectionBBox = {
west: number;
south: number;
east: number;
north: number;
};

View File

@@ -4,13 +4,13 @@
import Combobox from '../combobox.svelte';
interface Props {
rating?: number | null;
rating?: number;
}
let { rating = $bindable() }: Props = $props();
const options = [
{ value: 'null', label: $t('rating_count', { values: { count: 0 } }) },
{ value: '0', label: $t('rating_count', { values: { count: 0 } }) },
{ value: '1', label: $t('rating_count', { values: { count: 1 } }) },
{ value: '2', label: $t('rating_count', { values: { count: 2 } }) },
{ value: '3', label: $t('rating_count', { values: { count: 3 } }) },
@@ -26,7 +26,7 @@
placeholder={$t('search_rating')}
hideLabel
{options}
selectedOption={rating === undefined ? undefined : options[rating === null ? 0 : rating]}
selectedOption={rating === undefined ? undefined : options[rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/>
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
import { getAssetControlContext } from '$lib/utils/context';
import { type SharedLinkResponseDto } from '@immich/sdk';
@@ -24,8 +23,6 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: handleSelect }} />
<IconButton
shape="round"
color="secondary"

View File

@@ -3,28 +3,27 @@
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { Icon } from '@immich/ui';
import { mdiStar, mdiStarOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export type Rating = 1 | 2 | 3 | 4 | 5 | null;
interface Props {
count?: number;
rating: Rating;
rating: number;
readOnly?: boolean;
onRating: (rating: Rating) => void | undefined;
onRating: (rating: number) => void | undefined;
}
let { count = 5, rating, readOnly = false, onRating }: Props = $props();
let ratingSelection = $derived(rating);
let hoverRating: Rating = $state(null);
let focusRating: Rating = $state(null);
let hoverRating = $state(0);
let focusRating = $state(0);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const starIcon =
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
const id = generateId();
const handleSelect = (newRating: Rating) => {
const handleSelect = (newRating: number) => {
if (readOnly) {
return;
}
@@ -36,7 +35,7 @@
onRating(newRating);
};
const setHoverRating = (value: Rating) => {
const setHoverRating = (value: number) => {
if (readOnly) {
return;
}
@@ -44,11 +43,11 @@
};
const reset = () => {
setHoverRating(null);
focusRating = null;
setHoverRating(0);
focusRating = 0;
};
const handleSelectDebounced = (value: Rating) => {
const handleSelectDebounced = (value: number) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
handleSelect(value);
@@ -59,7 +58,7 @@
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<fieldset
class="text-primary w-fit cursor-default"
onmouseleave={() => setHoverRating(null)}
onmouseleave={() => setHoverRating(0)}
use:focusOutside={{ onFocusOut: reset }}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
@@ -70,7 +69,7 @@
<div class="flex flex-row" data-testid="star-container">
{#each { length: count } as _, index (index)}
{@const value = index + 1}
{@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value}
{@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
{@const starId = `${id}-${value}`}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
@@ -78,12 +77,19 @@
for={starId}
class:cursor-pointer={!readOnly}
class:ring-2={focusRating === value}
onmouseover={() => setHoverRating(value as Rating)}
onmouseover={() => setHoverRating(value)}
tabindex={-1}
data-testid="star"
>
<span class="sr-only">{$t('rating_count', { values: { count: value } })}</span>
<Icon icon={filled ? mdiStar : mdiStarOutline} size="1.5em" aria-hidden />
<Icon
icon={starIcon}
size="1.5em"
strokeWidth={1}
color={filled ? 'currentcolor' : 'transparent'}
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
aria-hidden
/>
</label>
<input
type="radio"
@@ -93,19 +99,19 @@
bind:group={ratingSelection}
disabled={readOnly}
onfocus={() => {
focusRating = value as Rating;
focusRating = value;
}}
onchange={() => handleSelectDebounced(value as Rating)}
onchange={() => handleSelectDebounced(value)}
class="sr-only"
/>
{/each}
</div>
</fieldset>
{#if ratingSelection !== null && !readOnly}
{#if ratingSelection > 0 && !readOnly}
<button
type="button"
onclick={() => {
ratingSelection = null;
ratingSelection = 0;
handleSelect(ratingSelection);
}}
class="cursor-pointer text-xs text-primary"

View File

@@ -196,11 +196,6 @@ export class MonthGroup {
timelineAsset.latitude = bucketAssets.latitude?.[i];
timelineAsset.longitude = bucketAssets.longitude?.[i];
}
if (this.timelineManager.isExcluded(timelineAsset)) {
continue;
}
this.addTimelineAsset(timelineAsset, addContext);
}
if (preSorted) {

View File

@@ -258,16 +258,10 @@ export class TimelineManager extends VirtualScrollManager {
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
return;
}
this.suspendTransitions = true;
try {
await this.initTask.reset();
await this.#init(options);
this.updateViewportGeometry(false);
this.#createScrubberMonths();
} finally {
this.suspendTransitions = false;
}
await this.initTask.reset();
await this.#init(options);
this.updateViewportGeometry(false);
this.#createScrubberMonths();
}
async #init(options: TimelineManagerOptions) {
@@ -595,8 +589,7 @@ export class TimelineManager extends VirtualScrollManager {
return (
isMismatched(this.#options.visibility, asset.visibility) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
isMismatched(this.#options.isTrashed, asset.isTrashed) ||
(this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id))
isMismatched(this.#options.isTrashed, asset.isTrashed)
);
}

View File

@@ -8,7 +8,6 @@ export type AssetApiGetTimeBucketsRequest = Parameters<typeof import('@immich/sd
export type TimelineManagerOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string;
deferInit?: boolean;
assetFilter?: Set<string>;
};
export type AssetDescriptor = { id: string };

View File

@@ -15,7 +15,7 @@
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
rating?: number;
};
</script>

View File

@@ -1,18 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MapTimelinePanel from '$lib/components/shared-components/map/MapTimelinePanel.svelte';
import type { SelectionBBox } from '$lib/components/shared-components/map/types';
import { timeToLoadTheMap } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { delay } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import type { PageData } from './$types';
interface Props {
@@ -23,15 +24,7 @@
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
let selectedClusterIds = $state.raw(new Set<string>());
let selectedClusterBBox = $state.raw<SelectionBBox>();
let isTimelinePanelVisible = $state(false);
function closeTimelinePanel() {
isTimelinePanelVisible = false;
selectedClusterBBox = undefined;
selectedClusterIds = new Set();
}
let viewingAssets: string[] = $state([]);
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
@@ -42,58 +35,96 @@
}
async function onViewAssets(assetIds: string[]) {
viewingAssets = assetIds;
await setAssetId(assetIds[0]);
closeTimelinePanel();
}
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
selectedClusterIds = new Set(assetIds);
selectedClusterBBox = bbox;
isTimelinePanelVisible = true;
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
async function navigateRandom() {
if (viewingAssets.length <= 0) {
return undefined;
}
const index = Math.floor(Math.random() * viewingAssets.length);
const asset = await setAssetId(viewingAssets[index]);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
return asset;
}
const getNextAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor < viewingAssets.length - 1) {
const id = viewingAssets[cursor + 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getNextAsset(asset, false);
}
return asset;
}
};
const getPreviousAsset = async (currentAsset: AssetResponseDto | undefined, preload: boolean = true) => {
if (!currentAsset) {
return;
}
const cursor = viewingAssets.indexOf(currentAsset.id);
if (cursor <= 0) {
return;
}
const id = viewingAssets[cursor - 1];
const asset = await getAssetInfo({ ...authManager.params, id });
if (preload) {
void getPreviousAsset(asset, false);
}
return asset;
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
previousAsset: undefined,
nextAsset: undefined,
});
const loadCloseAssets = async (currentAsset: AssetResponseDto) => {
const [nextAsset, previousAsset] = await Promise.all([getNextAsset(currentAsset), getPreviousAsset(currentAsset)]);
assetCursor = {
current: currentAsset,
nextAsset,
previousAsset,
};
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => void loadCloseAssets($viewingAsset));
});
</script>
{#if featureFlagsManager.value.map}
<UserPageLayout title={data.meta.title}>
<div class="isolate flex h-full w-full flex-col sm:flex-row">
<div
class={[
'min-h-0',
isTimelinePanelVisible ? 'h-1/2 w-full pb-2 sm:h-full sm:w-2/3 sm:pe-2 sm:pb-0' : 'h-full w-full',
]}
>
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
{:then { default: Map }}
<Map hash onSelect={onViewAssets} {onClusterSelect} />
<div class="isolate h-full w-full">
{#await import('$lib/components/shared-components/map/map.svelte')}
{#await delay(timeToLoadTheMap) then}
<!-- show the loading spinner only if loading the map takes too much time -->
<div class="flex items-center justify-center h-full w-full">
<LoadingSpinner />
</div>
{/await}
</div>
{#if isTimelinePanelVisible && selectedClusterBBox}
<div class="h-1/2 min-h-0 w-full pt-2 sm:h-full sm:w-1/3 sm:ps-2 sm:pt-0">
<MapTimelinePanel
bbox={selectedClusterBBox}
{selectedClusterIds}
assetCount={selectedClusterIds.size}
onClose={closeTimelinePanel}
/>
</div>
{/if}
{:then { default: Map }}
<Map hash onSelect={onViewAssets} />
{/await}
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
{#if $showAssetViewer && assetCursor.current}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={{ current: $viewingAsset }}
showNavigation={false}
cursor={assetCursor}
showNavigation={viewingAssets.length > 1}
onRandom={navigateRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));

View File

@@ -276,8 +276,6 @@
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if searchKey === 'rating'}
{$t('rating_count', { values: { count: value ?? 0 } })}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}