mirror of
https://github.com/immich-app/immich.git
synced 2026-03-13 22:06:53 -07:00
Compare commits
17 Commits
fix/map-we
...
feat/notif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d065059ca1 | ||
|
|
aa87d1b9a3 | ||
|
|
dc4da4b3d6 | ||
|
|
7dbd08a747 | ||
|
|
1d89190f96 | ||
|
|
c2d8400899 | ||
|
|
a100a4025e | ||
|
|
334fc250d3 | ||
|
|
28ca5f59fe | ||
|
|
789d82632a | ||
|
|
9f9569c152 | ||
|
|
fae05270a3 | ||
|
|
771816f601 | ||
|
|
e25ec4ec17 | ||
|
|
dd9046508d | ||
|
|
177d1c9a30 | ||
|
|
ded8d4e2b4 |
@@ -1,4 +1,4 @@
|
||||
# OpenAPI
|
||||
# API
|
||||
|
||||
Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/).
|
||||
|
||||
@@ -24,7 +24,7 @@ Immich has three main clients:
|
||||
3. CLI - Command-line utility for bulk upload
|
||||
|
||||
:::info
|
||||
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).
|
||||
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).
|
||||
:::
|
||||
|
||||
### 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](./open-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](/api.md) schemas and control the generated code used by each client.
|
||||
|
||||
### Background Jobs
|
||||
|
||||
|
||||
@@ -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](/developer/open-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](/api.md) for more details.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const prism = require('prism-react-renderer');
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: 'Immich',
|
||||
tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
|
||||
tagline: 'Self-hosted photo and video management solution',
|
||||
url: 'https://docs.immich.app',
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'throw',
|
||||
@@ -93,35 +93,15 @@ const config = {
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
to: '/overview/quick-start',
|
||||
href: 'https://immich.app/',
|
||||
position: 'right',
|
||||
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',
|
||||
label: 'Home',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/immich-app/immich',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://discord.immich.app',
|
||||
label: 'Discord',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
type: 'html',
|
||||
position: 'right',
|
||||
@@ -134,19 +114,78 @@ const config = {
|
||||
style: 'light',
|
||||
links: [
|
||||
{
|
||||
title: 'Overview',
|
||||
title: 'Download',
|
||||
items: [
|
||||
{
|
||||
label: 'Quick start',
|
||||
to: '/overview/quick-start',
|
||||
label: 'Android',
|
||||
href: 'https://get.immich.app/android',
|
||||
},
|
||||
{
|
||||
label: 'Installation',
|
||||
to: '/install/requirements',
|
||||
label: 'iOS',
|
||||
href: 'https://get.immich.app/ios',
|
||||
},
|
||||
{
|
||||
label: 'Contributing',
|
||||
to: '/overview/support-the-project',
|
||||
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: 'Privacy Policy',
|
||||
@@ -155,24 +194,7 @@ const config = {
|
||||
],
|
||||
},
|
||||
{
|
||||
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',
|
||||
title: 'Social',
|
||||
items: [
|
||||
{
|
||||
label: 'GitHub',
|
||||
|
||||
1
docs/static/_redirects
vendored
1
docs/static/_redirects
vendored
@@ -23,6 +23,7 @@
|
||||
/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
|
||||
|
||||
10
i18n/en.json
10
i18n/en.json
@@ -689,6 +689,7 @@
|
||||
"backup_settings_subtitle": "Manage upload settings",
|
||||
"backup_upload_details_page_more_details": "Tap for more details",
|
||||
"backward": "Backward",
|
||||
"battery_optimization_backup_reliability": "Disabling battery optimizations can improve the reliability of background backup",
|
||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||
"biometric_no_options": "No biometric options available",
|
||||
@@ -1050,7 +1051,6 @@
|
||||
"cant_get_number_of_comments": "Can't get number of comments",
|
||||
"cant_search_people": "Can't search people",
|
||||
"cant_search_places": "Can't search places",
|
||||
"enable_webgl_for_map": "Enable WebGL to load the map.{isAdmin, select, true { To hide this warning, disable the map feature.} other {}}",
|
||||
"error_adding_assets_to_album": "Error adding assets to album",
|
||||
"error_adding_users_to_album": "Error adding users to album",
|
||||
"error_deleting_shared_user": "Error deleting shared user",
|
||||
@@ -1246,7 +1246,6 @@
|
||||
"go_back": "Go back",
|
||||
"go_to_folder": "Go to folder",
|
||||
"go_to_search": "Go to search",
|
||||
"go_to_settings": "Go to settings",
|
||||
"gps": "GPS",
|
||||
"gps_missing": "No GPS",
|
||||
"grant_permission": "Grant permission",
|
||||
@@ -1625,6 +1624,7 @@
|
||||
"not_selected": "Not selected",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
|
||||
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
|
||||
"notification_permission_list_tile_enable_button": "Enable Notifications",
|
||||
@@ -1885,7 +1885,10 @@
|
||||
"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_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_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_success": "Successfully reset the SQLite database",
|
||||
"reset_to_default": "Reset to default",
|
||||
"resolution": "Resolution",
|
||||
@@ -1913,6 +1916,7 @@
|
||||
"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",
|
||||
|
||||
1
machine-learning/.python-version
Normal file
1
machine-learning/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -22,48 +22,7 @@ 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: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 rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS builder-rocm
|
||||
|
||||
FROM builder-${DEVICE} AS builder
|
||||
|
||||
@@ -79,9 +38,6 @@ 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
|
||||
|
||||
@@ -92,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
@@ -120,7 +76,11 @@ 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:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS prod-rocm
|
||||
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 prod-cpu AS prod-armnn
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ 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:
|
||||
|
||||
@@ -90,7 +90,7 @@ _PADDLE_MODELS = {
|
||||
|
||||
SUPPORTED_PROVIDERS = [
|
||||
"CUDAExecutionProvider",
|
||||
"ROCMExecutionProvider",
|
||||
"MIGraphXExecutionProvider",
|
||||
"OpenVINOExecutionProvider",
|
||||
"CoreMLExecutionProvider",
|
||||
"CPUExecutionProvider",
|
||||
|
||||
@@ -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 SessionNode
|
||||
from immich_ml.schemas import ModelPrecision, SessionNode
|
||||
|
||||
from ..config import log, settings
|
||||
|
||||
@@ -90,8 +90,17 @@ class OrtSession:
|
||||
match provider:
|
||||
case "CPUExecutionProvider":
|
||||
options = {"arena_extend_strategy": "kSameAsRequested"}
|
||||
case "CUDAExecutionProvider" | "ROCMExecutionProvider":
|
||||
case "CUDAExecutionProvider":
|
||||
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}"
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
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) {
|
||||
@@ -1,33 +0,0 @@
|
||||
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
|
||||
@@ -49,10 +49,10 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
|
||||
[project.optional-dependencies]
|
||||
cpu = ["onnxruntime>=1.23.2,<2"]
|
||||
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
|
||||
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
|
||||
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
|
||||
armnn = ["onnxruntime>=1.23.2,<2"]
|
||||
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
||||
rocm = []
|
||||
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
|
||||
|
||||
[tool.uv]
|
||||
compile-bytecode = true
|
||||
|
||||
@@ -179,7 +179,7 @@ class TestOrtSession:
|
||||
OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"]
|
||||
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
|
||||
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
|
||||
ROCM_EP = ["ROCMExecutionProvider", "CPUExecutionProvider"]
|
||||
ROCM_EP = ["MIGraphXExecutionProvider", "CPUExecutionProvider"]
|
||||
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
|
||||
|
||||
@pytest.mark.providers(CPU_EP)
|
||||
@@ -289,12 +289,38 @@ class TestOrtSession:
|
||||
|
||||
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
|
||||
|
||||
def test_sets_provider_options_for_rocm(self) -> None:
|
||||
def test_sets_provider_options_for_rocm(self, mocker: MockerFixture) -> None:
|
||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
||||
|
||||
session = OrtSession("ViT-B-32__openai", providers=["ROCMExecutionProvider"])
|
||||
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"])
|
||||
|
||||
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
|
||||
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)
|
||||
|
||||
def test_sets_provider_options_kwarg(self) -> None:
|
||||
session = OrtSession(
|
||||
|
||||
145
machine-learning/uv.lock
generated
145
machine-learning/uv.lock
generated
@@ -262,18 +262,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coloredlogs"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "humanfriendly" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorlog"
|
||||
version = "6.9.0"
|
||||
@@ -886,18 +874,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanfriendly"
|
||||
version = "10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -960,6 +936,9 @@ rknn = [
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "rknn-toolkit-lite2" },
|
||||
]
|
||||
rocm = [
|
||||
{ name = "onnxruntime-migraphx" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -1013,7 +992,8 @@ 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-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
|
||||
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
|
||||
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
|
||||
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
|
||||
{ name = "orjson", specifier = ">=3.9.5" },
|
||||
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
|
||||
@@ -1429,32 +1409,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "msgpack"
|
||||
version = "1.0.7"
|
||||
version = "1.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1702,11 +1705,10 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "onnxruntime-openvino"
|
||||
version = "1.23.0"
|
||||
name = "onnxruntime-migraphx"
|
||||
version = "1.24.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
@@ -1714,12 +1716,30 @@ dependencies = [
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/10/adcd4ac68ffc8dee003553125ef5c091be822e2d7c1077d0bb85690baa9c/onnxruntime_openvino-1.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:91938837e6e92e30c63d12fad68a8a4959c40d2eade2bd60f38bdd5b6392f8d3", size = 70481480, upload-time = "2025-10-14T15:19:45.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/95/25f28d6fecf300aa0af393e96af9e00cc676e5dab650ab84f2122610df50/onnxruntime_openvino-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f05d2d6a804fb70d3f4329d777ac62439773dcc2df827dd5f42644b10bf1fea", size = 13117353, upload-time = "2025-10-14T15:19:49.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/0c/8d97419dfeedf419c5fe5293f3dbc59284855a63ad22e71f46c0010c9dc4/onnxruntime_openvino-1.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b963ea19bf9856f3d6b2f719d451f2eeae482a8f69c729906465aa4f27f4d39c", size = 70483359, upload-time = "2025-10-14T15:19:52.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/30/ff6111b16ffb4187c462824aa4e95acc20fdd90f856d44a339d56c6dacd6/onnxruntime_openvino-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:937e52657f94c56990a6e5bd4c3705bd6e970834c7c94e23d300dde6848f2889", size = 13117933, upload-time = "2025-10-14T15:19:58.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/e42f618a8ec5fcf825fed4fdc8125f7105256cc6020b84567ecb88d5e2b7/onnxruntime_openvino-1.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2e93b9a8323e196b7433866054a59260f2206ab6fb0e7223dda91da71f1db8c5", size = 70483088, upload-time = "2025-10-14T15:20:02.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f9/a531dc497dc113dc14df9a9de5aacb1676cadebc3ec6cc7cd3ca65cb3db0/onnxruntime_openvino-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:0ebbf70929de4ce269371cb255536bbedef588932d744da0b40e66c38a620f35", size = 13118206, upload-time = "2025-10-14T15:20:05.587Z" },
|
||||
{ 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.24.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/16/69ca742f0b65c40d4de3ff44bb6abc23c47b23e932bc901116176ae69922/onnxruntime_openvino-1.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3007c803634cc69c6d52af1dea7ce729d9bb62b9a11070fd2f959119199007a8", size = 84430935, upload-time = "2026-02-26T13:44:32.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/73/619bb416bbfc40aebdd493fd6800d2637359294fe683d8a6bae3ff8d869a/onnxruntime_openvino-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:8042698232bf67f1f6b219c2b07728d7ae7ddff17d8524588de3675480609aef", size = 13655357, upload-time = "2026-02-26T13:44:35.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/cf/17ba72de2df0fcba349937d2788f154397bbc2d1a2d67772a97e26f6bc5f/onnxruntime_openvino-1.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d617fac2f59a6ab5ea59a788c3e1592240a129642519aaeaa774761dfe35150e", size = 84433207, upload-time = "2026-02-26T13:44:41.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/37/d301f2c68b19a9485ed5db3047e0fb52478f3e73eb08c7d2a7c61be7cc1c/onnxruntime_openvino-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:f186335a9c9b255633275290da7521d3d4d14c7773fee3127bfa040234d3fa5a", size = 13658075, upload-time = "2026-02-26T13:44:44.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2159,15 +2179,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyreadline3"
|
||||
version = "3.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465, upload-time = "2022-01-24T20:05:11.66Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203, upload-time = "2022-01-24T20:05:10.442Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
|
||||
@@ -16,6 +16,8 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -48,6 +50,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
PermissionApi.setUp(messenger, PermissionApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object PermissionApiPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class PermissionStatus(val raw: Int) {
|
||||
GRANTED(0),
|
||||
DENIED(1),
|
||||
PERMANENTLY_DENIED(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): PermissionStatus? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
129.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
PermissionStatus.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is PermissionStatus -> {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw)
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun isIgnoringBatteryOptimizations(): PermissionStatus
|
||||
|
||||
companion object {
|
||||
/** The codec used by PermissionApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
PermissionApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isIgnoringBatteryOptimizations())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
|
||||
class PermissionApiImpl(context: Context) : PermissionApi {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private val powerManager =
|
||||
ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
|
||||
|
||||
override fun isIgnoringBatteryOptimizations(): PermissionStatus {
|
||||
if (powerManager.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||
return PermissionStatus.GRANTED
|
||||
}
|
||||
return PermissionStatus.DENIED
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
@@ -49,30 +50,34 @@ import 'package:logging/logging.dart';
|
||||
import 'package:timezone/data/latest.dart';
|
||||
|
||||
void main() async {
|
||||
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();
|
||||
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();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWithValue(isar),
|
||||
isarProvider.overrideWithValue(isar),
|
||||
driftProvider.overrideWith(driftOverride(drift)),
|
||||
],
|
||||
child: const MainWidget(),
|
||||
),
|
||||
);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initApp() async {
|
||||
await EasyLocalization.ensureInitialized();
|
||||
await initializeDateFormatting();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
|
||||
@@ -8,18 +8,25 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
|
||||
import 'package:immich_mobile/providers/notification_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as pm;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -161,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
),
|
||||
),
|
||||
},
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text("view_details".t(context: context)),
|
||||
),
|
||||
const _BackupFooter(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -176,6 +179,130 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupFooter extends ConsumerStatefulWidget {
|
||||
const _BackupFooter();
|
||||
|
||||
@override
|
||||
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
|
||||
}
|
||||
|
||||
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
|
||||
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
|
||||
unawaited(ref.read(_batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
|
||||
}
|
||||
}
|
||||
|
||||
void showPermissionsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
content: Text(context.t.notification_permission_dialog_content),
|
||||
actions: [
|
||||
TextButton(child: Text(context.t.cancel), onPressed: () => ctx.pop()),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
pm.openAppSettings();
|
||||
},
|
||||
child: Text(context.t.settings),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showBatteryOptimizationInfo() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(context.t.backup_controller_page_background_battery_info_title),
|
||||
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
|
||||
child: Text(
|
||||
context.t.backup_controller_page_background_battery_info_link,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
child: Text(
|
||||
context.t.backup_controller_page_background_battery_info_ok,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
onPressed: () => ctx.pop(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
|
||||
final notificationStatus = ref.watch(notificationPermissionProvider);
|
||||
final batteryOptimizationStatus = ref.watch(_batteryOptimizationProvider).valueOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
|
||||
if (notificationStatus != pm.PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.notification_backup_reliability,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
|
||||
if (p == pm.PermissionStatus.permanentlyDenied) {
|
||||
showPermissionsDialog();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
// Show only after notification permission is granted
|
||||
if (notificationStatus == pm.PermissionStatus.granted &&
|
||||
batteryOptimizationStatus != pm.PermissionStatus.granted)
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
|
||||
label: Text(
|
||||
context.t.battery_optimization_backup_reliability,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
onPressed: showBatteryOptimizationInfo,
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.info_outline_rounded),
|
||||
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
|
||||
label: Text(context.t.view_details),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupAlbumSelectionCard extends ConsumerWidget {
|
||||
const _BackupAlbumSelectionCard();
|
||||
|
||||
@@ -521,3 +648,28 @@ class _PreparingStatusState extends ConsumerState {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
|
||||
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
|
||||
});
|
||||
|
||||
final _batteryOptimizationProvider = AsyncNotifierProvider<_BatteryOptimizationNotifier, pm.PermissionStatus>(
|
||||
_BatteryOptimizationNotifier.new,
|
||||
);
|
||||
|
||||
class _BatteryOptimizationNotifier extends AsyncNotifier<pm.PermissionStatus> {
|
||||
Future<pm.PermissionStatus> getBatteryOptimizationPermission() async {
|
||||
final pm.PermissionStatus status;
|
||||
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations();
|
||||
if (isIgnoring == PermissionStatus.granted) {
|
||||
status = pm.PermissionStatus.granted;
|
||||
} else {
|
||||
status = pm.PermissionStatus.denied;
|
||||
}
|
||||
state = AsyncValue.data(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<pm.PermissionStatus> build() => getBatteryOptimizationPermission();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
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';
|
||||
@@ -13,7 +20,254 @@ 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 {
|
||||
|
||||
87
mobile/lib/platform/permission_api.g.dart
generated
Normal file
87
mobile/lib/platform/permission_api.g.dart
generated
Normal file
@@ -0,0 +1,87 @@
|
||||
// Autogenerated from Pigeon (v26.0.2), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List;
|
||||
|
||||
import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
PlatformException _createConnectionError(String channelName) {
|
||||
return PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else if (value is PermissionStatus) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 129:
|
||||
final int? value = readValue(buffer) as int?;
|
||||
return value == null ? null : PermissionStatus.values[value];
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApi {
|
||||
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
|
||||
final String pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as PermissionStatus?)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
@@ -25,6 +26,15 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
|
||||
final confirm =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => PermanentDeleteDialog(count: count),
|
||||
) ??
|
||||
false;
|
||||
if (!confirm) return;
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
|
||||
final confirmDelete =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => TrashDeleteDialog(count: selectCount),
|
||||
builder: (context) => PermanentDeleteDialog(count: selectCount),
|
||||
) ??
|
||||
false;
|
||||
if (!confirmDelete) {
|
||||
|
||||
@@ -64,7 +64,6 @@ 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;
|
||||
@@ -94,6 +93,7 @@ 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 _onScroll() {
|
||||
void _syncShowingDetails() {
|
||||
final offset = _proxyScrollController.offset;
|
||||
if (offset > SnapScrollPhysics.minSnapDistance) {
|
||||
_viewer.setShowingDetails(true);
|
||||
@@ -149,6 +149,8 @@ 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);
|
||||
}
|
||||
@@ -167,9 +169,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
case _DragIntent.none:
|
||||
case _DragIntent.scroll:
|
||||
final scrollVelocity = -(details.primaryVelocity ?? 0.0);
|
||||
if (_willClose(scrollVelocity)) {
|
||||
_viewer.setShowingDetails(false);
|
||||
}
|
||||
_viewer.setShowingDetails(!_willClose(scrollVelocity));
|
||||
|
||||
_drag?.end(details);
|
||||
_drag = null;
|
||||
case _DragIntent.dismiss:
|
||||
@@ -306,7 +307,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
final size = context.sizeData;
|
||||
return PhotoView(
|
||||
key: ValueKey(displayAsset.heroTag),
|
||||
key: Key(displayAsset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||
heroAttributes: heroAttributes,
|
||||
@@ -334,7 +335,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
key: ValueKey(displayAsset),
|
||||
key: Key(displayAsset.heroTag),
|
||||
onDragStart: _onDragStart,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
@@ -350,12 +351,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(displayAsset),
|
||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||
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,3 +459,25 @@ 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;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ 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
|
||||
@@ -226,6 +229,13 @@ 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));
|
||||
@@ -245,10 +255,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
|
||||
final (controls, details) = state;
|
||||
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||
? SystemUiMode.immersiveSticky
|
||||
: SystemUiMode.edgeToEdge;
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
|
||||
_setSystemUIMode(controls, details);
|
||||
});
|
||||
|
||||
return PopScope(
|
||||
|
||||
@@ -420,20 +420,18 @@ 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(key: ValueKey(asset.heroTag), child: image),
|
||||
if (!isVisible.value || controller.value == null) Center(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(key: ValueKey(asset), onViewReady: initController),
|
||||
child: NativeVideoPlayerView(onViewReady: initController),
|
||||
),
|
||||
),
|
||||
if (showControls) const Center(child: VideoViewerControls()),
|
||||
|
||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
@@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
final remoteImageApi = RemoteImageApi();
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class TrashDeleteDialog extends StatelessWidget {
|
||||
const TrashDeleteDialog({super.key, required this.count});
|
||||
class PermanentDeleteDialog extends StatelessWidget {
|
||||
const PermanentDeleteDialog({super.key, required this.count});
|
||||
|
||||
final int count;
|
||||
|
||||
@@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
pinned: pinned,
|
||||
snap: snap,
|
||||
expandedHeight: expandedHeight,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
|
||||
automaticallyImplyLeading: false,
|
||||
centerTitle: false,
|
||||
title: title ?? const _ImmichLogoWithText(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -5,6 +6,7 @@ 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';
|
||||
@@ -87,25 +89,27 @@ class SyncStatusAndActions extends HookConsumerWidget {
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text("reset_sqlite".t(context: context)),
|
||||
content: Text("reset_sqlite_confirmation".t(context: context)),
|
||||
title: Text(context.t.reset_sqlite),
|
||||
content: Text(context.t.reset_sqlite_confirmation),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text("cancel".t(context: context)),
|
||||
),
|
||||
TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel)),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(driftProvider).reset();
|
||||
context.pop();
|
||||
context.scaffoldMessenger.showSnackBar(
|
||||
SnackBar(content: Text("reset_sqlite_success".t(context: context))),
|
||||
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))],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
"confirm".t(context: context),
|
||||
style: TextStyle(color: context.colorScheme.error),
|
||||
),
|
||||
child: Text(context.t.confirm, style: TextStyle(color: context.colorScheme.error)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ pigeon:
|
||||
dart run pigeon --input pigeon/background_worker_lock_api.dart
|
||||
dart run pigeon --input pigeon/connectivity_api.dart
|
||||
dart run pigeon --input pigeon/network_api.dart
|
||||
dart run pigeon --input pigeon/permission_api.dart
|
||||
dart format lib/platform/native_sync_api.g.dart
|
||||
dart format lib/platform/local_image_api.g.dart
|
||||
dart format lib/platform/remote_image_api.g.dart
|
||||
@@ -20,6 +21,7 @@ pigeon:
|
||||
dart format lib/platform/background_worker_lock_api.g.dart
|
||||
dart format lib/platform/connectivity_api.g.dart
|
||||
dart format lib/platform/network_api.g.dart
|
||||
dart format lib/platform/permission_api.g.dart
|
||||
|
||||
watch:
|
||||
dart run build_runner watch --delete-conflicting-outputs
|
||||
|
||||
@@ -40,7 +40,13 @@ depends = [
|
||||
[tasks."codegen:translation"]
|
||||
alias = "translation"
|
||||
description = "Generate translations from i18n JSONs"
|
||||
run = [{ task = "//:i18n:format-fix" }, { tasks = ["i18n:loader", "i18n:keys"] }]
|
||||
run = [
|
||||
{ task = "//:i18n:format-fix" },
|
||||
{ tasks = [
|
||||
"i18n:loader",
|
||||
"i18n:keys",
|
||||
] },
|
||||
]
|
||||
|
||||
[tasks."codegen:app-icon"]
|
||||
description = "Generate app icons"
|
||||
@@ -146,6 +152,19 @@ run = [
|
||||
"dart format lib/platform/connectivity_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."pigeon:permission"]
|
||||
description = "Generate permission API pigeon code"
|
||||
hide = true
|
||||
sources = ["pigeon/permission_api.dart"]
|
||||
outputs = [
|
||||
"lib/platform/permission_api.g.dart",
|
||||
"android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt",
|
||||
]
|
||||
run = [
|
||||
"dart run pigeon --input pigeon/permission_api.dart",
|
||||
"dart format lib/platform/permission_api.g.dart",
|
||||
]
|
||||
|
||||
[tasks."i18n:loader"]
|
||||
description = "Generate i18n loader"
|
||||
hide = true
|
||||
|
||||
30
mobile/openapi/lib/api/timeline_api.dart
generated
30
mobile/openapi/lib/api/timeline_api.dart
generated
@@ -30,6 +30,9 @@ 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)
|
||||
///
|
||||
@@ -63,7 +66,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, 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, 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 {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/timeline/bucket';
|
||||
|
||||
@@ -77,6 +80,9 @@ 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));
|
||||
}
|
||||
@@ -141,6 +147,9 @@ 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)
|
||||
///
|
||||
@@ -174,8 +183,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, 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, );
|
||||
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, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -200,6 +209,9 @@ 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)
|
||||
///
|
||||
@@ -233,7 +245,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, 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, 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 {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/timeline/buckets';
|
||||
|
||||
@@ -247,6 +259,9 @@ 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));
|
||||
}
|
||||
@@ -307,6 +322,9 @@ 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)
|
||||
///
|
||||
@@ -340,8 +358,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, 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, );
|
||||
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, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
17
mobile/pigeon/permission_api.dart
Normal file
17
mobile/pigeon/permission_api.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
enum PermissionStatus { granted, denied, permanentlyDenied }
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/permission_api.g.dart',
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class PermissionApi {
|
||||
PermissionStatus isIgnoringBatteryOptimizations();
|
||||
}
|
||||
@@ -13492,6 +13492,16 @@
|
||||
"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,
|
||||
@@ -13668,6 +13678,16 @@
|
||||
"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,
|
||||
|
||||
@@ -6421,8 +6421,9 @@ export function tagAssets({ id, bulkIdsDto }: {
|
||||
/**
|
||||
* Get time bucket
|
||||
*/
|
||||
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
albumId?: string;
|
||||
bbox?: string;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
key?: string;
|
||||
@@ -6442,6 +6443,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
||||
data: TimeBucketAssetResponseDto;
|
||||
}>(`/timeline/bucket${QS.query(QS.explode({
|
||||
albumId,
|
||||
bbox,
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
key,
|
||||
@@ -6462,8 +6464,9 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
||||
/**
|
||||
* Get time buckets
|
||||
*/
|
||||
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
albumId?: string;
|
||||
bbox?: string;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
key?: string;
|
||||
@@ -6482,6 +6485,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
|
||||
data: TimeBucketsResponseDto[];
|
||||
}>(`/timeline/buckets${QS.query(QS.explode({
|
||||
albumId,
|
||||
bbox,
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
key,
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -344,8 +344,8 @@ importers:
|
||||
specifier: 2.0.0-rc13
|
||||
version: 2.0.0-rc13
|
||||
'@immich/sql-tools':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
specifier: ^0.3.2
|
||||
version: 0.3.2
|
||||
'@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,8 +3017,9 @@ packages:
|
||||
'@immich/justified-layout-wasm@0.4.3':
|
||||
resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==}
|
||||
|
||||
'@immich/sql-tools@0.2.0':
|
||||
resolution: {integrity: sha512-AH0GRIUYrckNKuid5uO33vgRbGaznhRtArdQ91K310A1oUFjaoNzOaZyZhXwEmft3WYeC1bx4fdgUeois2QH5A==}
|
||||
'@immich/sql-tools@0.3.2':
|
||||
resolution: {integrity: sha512-UWhy/+Lf8C1dJip5wPfFytI3Vq/9UyDKQE1ROjXwVhT6E/CPgBkRLwHPetjYGPJ4o1JVVpRLnEEJCXdvzqVpGw==}
|
||||
hasBin: true
|
||||
|
||||
'@immich/svelte-markdown-preprocess@0.2.1':
|
||||
resolution: {integrity: sha512-mbr/g75lO8Zh+ELCuYrZP0XB4gf2UbK8rJcGYMYxFJJzMMunV+sm9FqtV1dbwW2dpXzCZGz1XPCEZ6oo526TbA==}
|
||||
@@ -6058,6 +6059,10 @@ 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==}
|
||||
|
||||
@@ -14855,8 +14860,9 @@ snapshots:
|
||||
|
||||
'@immich/justified-layout-wasm@0.4.3': {}
|
||||
|
||||
'@immich/sql-tools@0.2.0':
|
||||
'@immich/sql-tools@0.3.2':
|
||||
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
|
||||
@@ -18224,6 +18230,8 @@ snapshots:
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
@@ -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": "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;'",
|
||||
"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;'",
|
||||
"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.2.0",
|
||||
"@immich/sql-tools": "^0.3.2",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
25
server/src/dtos/bbox.dto.ts
Normal file
25
server/src/dtos/bbox.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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 {
|
||||
@@ -59,6 +60,9 @@ export class TimeBucketDto {
|
||||
description: 'Include location data in the response',
|
||||
})
|
||||
withCoordinates?: boolean;
|
||||
|
||||
@ValidateBBox({ optional: true })
|
||||
bbox?: BBoxDto;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
|
||||
@@ -562,6 +562,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."originalPath",
|
||||
"asset"."isExternal",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."fileCreatedAt",
|
||||
@@ -593,6 +594,7 @@ from
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."id" = $2
|
||||
and "asset"."visibility" != $3
|
||||
|
||||
-- AssetJobRepository.streamForStorageTemplateJob
|
||||
select
|
||||
@@ -602,6 +604,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."originalPath",
|
||||
"asset"."isExternal",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."fileCreatedAt",
|
||||
@@ -632,6 +635,7 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $2
|
||||
|
||||
-- AssetJobRepository.streamForDeletedJob
|
||||
select
|
||||
|
||||
@@ -123,13 +123,13 @@ with
|
||||
) as "year"
|
||||
)
|
||||
select
|
||||
"a".*,
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
"a".*
|
||||
from
|
||||
"today"
|
||||
inner join lateral (
|
||||
select
|
||||
"asset".*
|
||||
"asset"."id",
|
||||
"asset"."localDateTime"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
|
||||
@@ -151,7 +151,6 @@ with
|
||||
limit
|
||||
$7
|
||||
) as "a" on true
|
||||
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
|
||||
)
|
||||
select
|
||||
date_part(
|
||||
|
||||
@@ -353,6 +353,7 @@ export class AssetJobRepository {
|
||||
'asset.checksum',
|
||||
'asset.originalPath',
|
||||
'asset.isExternal',
|
||||
'asset.visibility',
|
||||
'asset.originalFileName',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.fileCreatedAt',
|
||||
@@ -367,13 +368,16 @@ export class AssetJobRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForStorageTemplateJob(id: string) {
|
||||
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
|
||||
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
|
||||
return this.storageTemplateAssetQuery()
|
||||
.where('asset.id', '=', id)
|
||||
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamForStorageTemplateJob() {
|
||||
return this.storageTemplateAssetQuery().stream();
|
||||
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import {
|
||||
ExpressionBuilder,
|
||||
Insertable,
|
||||
Kysely,
|
||||
NotNull,
|
||||
Selectable,
|
||||
SelectQueryBuilder,
|
||||
sql,
|
||||
Updateable,
|
||||
UpdateResult,
|
||||
} from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -36,6 +46,13 @@ 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;
|
||||
@@ -64,6 +81,7 @@ interface AssetBuilderOptions {
|
||||
assetType?: AssetType;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
bbox?: BoundingBox;
|
||||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
@@ -120,6 +138,34 @@ 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>) {}
|
||||
@@ -358,7 +404,7 @@ export class AssetRepository {
|
||||
(qb) =>
|
||||
qb
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.select(['asset.id', 'asset.localDateTime'])
|
||||
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
|
||||
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
|
||||
.where('asset.ownerId', '=', anyUuid(ownerIds))
|
||||
@@ -377,9 +423,7 @@ export class AssetRepository {
|
||||
.as('a'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.innerJoin('asset_exif', 'a.id', 'asset_exif.assetId')
|
||||
.selectAll('a')
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')),
|
||||
.selectAll('a'),
|
||||
)
|
||||
.selectFrom('res')
|
||||
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
|
||||
@@ -651,6 +695,20 @@ 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) =>
|
||||
@@ -725,6 +783,18 @@ 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) =>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -288,7 +289,11 @@ export class DatabaseRepository {
|
||||
}
|
||||
|
||||
async getSchemaDrift() {
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const source = schemaFromCode({
|
||||
overrides: true,
|
||||
namingStrategy: 'default',
|
||||
uuidFunction: (version) => (version === 7 ? `${immich_uuid_v7.name}()` : 'uuid_generate_v4()'),
|
||||
});
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const target = await schemaFromDatabase({ connection: database.config });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
|
||||
import {
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
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 })
|
||||
|
||||
@@ -9,6 +9,9 @@ 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;
|
||||
@@ -153,6 +156,58 @@ 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();
|
||||
@@ -709,12 +764,18 @@ describe(StorageTemplateService.name, () => {
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
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}`;
|
||||
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}`;
|
||||
|
||||
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',
|
||||
@@ -735,11 +796,53 @@ 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', () => {
|
||||
|
||||
@@ -158,12 +158,14 @@ export class StorageTemplateService extends BaseService {
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
|
||||
includeHidden: true,
|
||||
});
|
||||
if (!livePhotoVideo) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
|
||||
}
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -191,10 +193,12 @@ export class StorageTemplateService extends BaseService {
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
|
||||
includeHidden: true,
|
||||
});
|
||||
if (livePhotoVideo) {
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +218,7 @@ export class StorageTemplateService extends BaseService {
|
||||
await this.moveRepository.cleanMoveHistorySingle(assetId);
|
||||
}
|
||||
|
||||
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) {
|
||||
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) {
|
||||
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
|
||||
// External assets are not affected by storage template
|
||||
// TODO: shouldn't this only apply to external assets?
|
||||
@@ -224,7 +228,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);
|
||||
const newPath = await this.getTemplatePath(asset, metadata, stillPhoto);
|
||||
|
||||
if (!fileSizeInByte) {
|
||||
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
|
||||
@@ -255,7 +259,11 @@ export class StorageTemplateService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise<string> {
|
||||
private async getTemplatePath(
|
||||
asset: StorageAsset,
|
||||
metadata: MoveAssetMetadata,
|
||||
stillPhoto?: StorageAsset,
|
||||
): Promise<string> {
|
||||
const { storageLabel, filename } = metadata;
|
||||
|
||||
try {
|
||||
@@ -296,8 +304,12 @@ export class StorageTemplateService extends BaseService {
|
||||
let albumName = null;
|
||||
let albumStartDate = null;
|
||||
let albumEndDate = null;
|
||||
const assetForMetadata = stillPhoto || asset;
|
||||
|
||||
if (this.template.needsAlbum) {
|
||||
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
|
||||
// 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 album = albums?.[0];
|
||||
if (album) {
|
||||
albumName = album.albumName || null;
|
||||
@@ -310,16 +322,18 @@ 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,
|
||||
asset: assetForMetadata,
|
||||
filename: sanitized,
|
||||
extension,
|
||||
albumName,
|
||||
albumStartDate,
|
||||
albumEndDate,
|
||||
make: asset.make,
|
||||
model: asset.model,
|
||||
lensModel: asset.lensModel,
|
||||
make: assetForMetadata.make,
|
||||
model: assetForMetadata.model,
|
||||
lensModel: assetForMetadata.lensModel,
|
||||
});
|
||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||
let destination = `${fullPath}.${extension}`;
|
||||
|
||||
@@ -23,6 +23,24 @@ 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', () => {
|
||||
|
||||
32
server/src/utils/bbox.ts
Normal file
32
server/src/utils/bbox.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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(),
|
||||
);
|
||||
};
|
||||
@@ -427,3 +427,25 @@ 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);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ 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,
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
|
||||
<GalleryViewer {assets} {assetInteraction} {viewport} />
|
||||
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
|
||||
</section>
|
||||
{:else if assets.length === 1}
|
||||
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
pageHeaderOffset?: number;
|
||||
slidingWindowOffset?: number;
|
||||
arrowNavigation?: boolean;
|
||||
allowDeletion?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -60,6 +61,7 @@
|
||||
slidingWindowOffset = 0,
|
||||
pageHeaderOffset = 0,
|
||||
arrowNavigation = true,
|
||||
allowDeletion = true,
|
||||
}: Props = $props();
|
||||
|
||||
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
||||
@@ -273,11 +275,15 @@
|
||||
if (assetInteraction.selectionActive) {
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Escape' }, 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 },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: deselectAllAssets },
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<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}
|
||||
@@ -11,17 +11,15 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { OpenQueryParam, Theme } from '$lib/constants';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
|
||||
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { Icon, Link, modalManager, Text } from '@immich/ui';
|
||||
import { mdiCog, mdiInformationOutline, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
@@ -51,6 +49,7 @@
|
||||
Popup,
|
||||
ScaleControl,
|
||||
} from 'svelte-maplibre';
|
||||
import type { SelectionBBox } from './types';
|
||||
|
||||
interface Props {
|
||||
mapMarkers?: MapMarkerResponseDto[];
|
||||
@@ -63,6 +62,7 @@
|
||||
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,6 +81,7 @@
|
||||
useLocationPin = false,
|
||||
onOpenInMapView = undefined,
|
||||
onSelect = () => {},
|
||||
onClusterSelect,
|
||||
onClickPoint = () => {},
|
||||
popup,
|
||||
rounded = false,
|
||||
@@ -133,9 +134,30 @@
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
onSelect(ids);
|
||||
}
|
||||
|
||||
@@ -303,133 +325,109 @@
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<!-- Use svelte:boundary instead of MapLibre onerror until https://github.com/dimfeld/svelte-maplibre/issues/279 is fixed -->
|
||||
<svelte:boundary>
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{zoom}
|
||||
{center}
|
||||
bounds={initialBounds}
|
||||
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
onload={(event: Map) => {
|
||||
event.setMaxZoom(18);
|
||||
event.on('click', handleMapClick);
|
||||
if (!simplified) {
|
||||
event.addControl(new GlobeControl(), 'top-left');
|
||||
}
|
||||
}}
|
||||
bind:map
|
||||
>
|
||||
{#snippet children({ map }: { map: Map })}
|
||||
{#if showSimpleControls}
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{zoom}
|
||||
{center}
|
||||
bounds={initialBounds}
|
||||
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
onload={(event: Map) => {
|
||||
event.setMaxZoom(18);
|
||||
event.on('click', handleMapClick);
|
||||
if (!simplified) {
|
||||
event.addControl(new GlobeControl(), 'top-left');
|
||||
}
|
||||
}}
|
||||
bind:map
|
||||
>
|
||||
{#snippet children({ map }: { map: Map })}
|
||||
{#if showSimpleControls}
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={handleSettingsClick}>
|
||||
<Icon icon={mdiCog} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
{#if showSettings}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={handleSettingsClick}>
|
||||
<Icon icon={mdiCog} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
{#if onOpenInMapView && showSimpleControls}
|
||||
<Control position="top-right">
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={() => onOpenInMapView()}>
|
||||
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
{#if onOpenInMapView && showSimpleControls}
|
||||
<Control position="top-right">
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={() => onOpenInMapView()}>
|
||||
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
<GeoJSON
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
{/snippet}
|
||||
</MapLibre>
|
||||
|
||||
{#snippet failed(_)}
|
||||
<div
|
||||
class={[
|
||||
'flex place-content-center place-items-center text-warning',
|
||||
simplified ? 'gap-4 px-6 text-sm' : 'h-full mx-auto gap-6',
|
||||
]}
|
||||
<GeoJSON
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
>
|
||||
<div>
|
||||
<Icon icon={mdiInformationOutline} size={simplified ? '18' : '24'} />
|
||||
</div>
|
||||
<div>
|
||||
<Text>
|
||||
{$t('errors.enable_webgl_for_map', { values: { isAdmin: $user.isAdmin } })}
|
||||
</Text>
|
||||
{#if $user.isAdmin}
|
||||
<Link href={Route.systemSettings({ isOpen: OpenQueryParam.LOCATION })}>{$t('go_to_settings')}</Link>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
</MapLibre>
|
||||
|
||||
6
web/src/lib/components/shared-components/map/types.ts
Normal file
6
web/src/lib/components/shared-components/map/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type SelectionBBox = {
|
||||
west: number;
|
||||
south: number;
|
||||
east: number;
|
||||
north: number;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
<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';
|
||||
@@ -23,6 +24,8 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: handleSelect }} />
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
|
||||
@@ -65,7 +65,6 @@ export enum OpenQueryParam {
|
||||
OAUTH = 'oauth',
|
||||
JOB = 'job',
|
||||
STORAGE_TEMPLATE = 'storage-template',
|
||||
LOCATION = 'location',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
PURCHASE_SETTINGS = 'user-purchase-settings',
|
||||
}
|
||||
|
||||
@@ -196,6 +196,11 @@ 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) {
|
||||
|
||||
@@ -258,10 +258,16 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
|
||||
return;
|
||||
}
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.updateViewportGeometry(false);
|
||||
this.#createScrubberMonths();
|
||||
|
||||
this.suspendTransitions = true;
|
||||
try {
|
||||
await this.initTask.reset();
|
||||
await this.#init(options);
|
||||
this.updateViewportGeometry(false);
|
||||
this.#createScrubberMonths();
|
||||
} finally {
|
||||
this.suspendTransitions = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #init(options: TimelineManagerOptions) {
|
||||
@@ -589,7 +595,8 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return (
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed) ||
|
||||
(this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 };
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<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, untrack } from 'svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +23,15 @@
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, setAssetId } = assetViewingStore;
|
||||
|
||||
let viewingAssets: string[] = $state([]);
|
||||
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();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
@@ -35,96 +42,58 @@
|
||||
}
|
||||
|
||||
async function onViewAssets(assetIds: string[]) {
|
||||
viewingAssets = assetIds;
|
||||
await setAssetId(assetIds[0]);
|
||||
closeTimelinePanel();
|
||||
}
|
||||
|
||||
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;
|
||||
function onClusterSelect(assetIds: string[], bbox: SelectionBBox) {
|
||||
selectedClusterIds = new Set(assetIds);
|
||||
selectedClusterBBox = bbox;
|
||||
isTimelinePanelVisible = true;
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
}
|
||||
|
||||
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 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>
|
||||
<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} />
|
||||
{/await}
|
||||
{:then { default: Map }}
|
||||
<Map hash onSelect={onViewAssets} />
|
||||
{/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}
|
||||
</div>
|
||||
</UserPageLayout>
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer && assetCursor.current}
|
||||
{#if $showAssetViewer}
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={viewingAssets.length > 1}
|
||||
onRandom={navigateRandom}
|
||||
cursor={{ current: $viewingAsset }}
|
||||
showNavigation={false}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
|
||||
Reference in New Issue
Block a user