Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Way 532567ab2a fix(mobile): search results 2026-02-24 21:32:10 +00:00
156 changed files with 1905 additions and 4031 deletions
+2 -2
View File
@@ -24,7 +24,7 @@ Immich has three main clients:
3. CLI - Command-line utility for bulk upload 3. CLI - Command-line utility for bulk upload
:::info :::info
All three clients use [OpenAPI](/api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](/api.md). All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for easy integration. For more information about this process, see [OpenAPI](./open-api.md).
::: :::
### Mobile App ### Mobile App
@@ -71,7 +71,7 @@ An incoming HTTP request is mapped to a controller (`src/controllers`). Controll
### Domain Transfer Objects (DTOs) ### Domain Transfer Objects (DTOs)
The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](/api.md) schemas and control the generated code used by each client. The server uses [Domain Transfer Objects](https://en.wikipedia.org/wiki/Data_transfer_object) as public interfaces for the inputs (query, params, and body) and outputs (response) for each endpoint. DTOs translate to [OpenAPI](./open-api.md) schemas and control the generated code used by each client.
### Background Jobs ### Background Jobs
@@ -1,4 +1,4 @@
# API # OpenAPI
Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/). Immich uses the [OpenAPI](https://swagger.io/specification/) standard to generate API documentation. To view the published docs see [here](https://api.immich.app/).
+1 -1
View File
@@ -53,7 +53,7 @@ You can use `dart fix --apply` and `dcm fix lib` to potentially correct some iss
## OpenAPI ## OpenAPI
The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/api.md) for more details. The OpenAPI client libraries need to be regenerated whenever there are changes to the `immich-openapi-specs.json` file. Note that you should not modify this file directly as it is auto-generated. See [OpenAPI](/developer/open-api.md) for more details.
## Database Migrations ## Database Migrations
@@ -50,7 +50,6 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- The GPU must be supported by ROCm. If it isn't officially supported, you can attempt to use the `HSA_OVERRIDE_GFX_VERSION` environmental variable: `HSA_OVERRIDE_GFX_VERSION=<a supported version, e.g. 10.3.0>`. If this doesn't work, you might need to also set `HSA_USE_SVM=0`. - The 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. - 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). - 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 #### OpenVINO
+48 -70
View File
@@ -6,7 +6,7 @@ const prism = require('prism-react-renderer');
/** @type {import('@docusaurus/types').Config} */ /** @type {import('@docusaurus/types').Config} */
const config = { const config = {
title: 'Immich', title: 'Immich',
tagline: 'Self-hosted photo and video management solution', tagline: 'High performance self-hosted photo and video backup solution directly from your mobile phone',
url: 'https://docs.immich.app', url: 'https://docs.immich.app',
baseUrl: '/', baseUrl: '/',
onBrokenLinks: 'throw', onBrokenLinks: 'throw',
@@ -93,15 +93,35 @@ const config = {
position: 'right', position: 'right',
}, },
{ {
href: 'https://immich.app/', to: '/overview/quick-start',
position: 'right', position: 'right',
label: 'Home', label: 'Docs',
},
{
href: 'https://immich.app/roadmap',
position: 'right',
label: 'Roadmap',
},
{
href: 'https://api.immich.app/',
position: 'right',
label: 'API',
},
{
href: 'https://immich.store',
position: 'right',
label: 'Merch',
}, },
{ {
href: 'https://github.com/immich-app/immich', href: 'https://github.com/immich-app/immich',
label: 'GitHub', label: 'GitHub',
position: 'right', position: 'right',
}, },
{
href: 'https://discord.immich.app',
label: 'Discord',
position: 'right',
},
{ {
type: 'html', type: 'html',
position: 'right', position: 'right',
@@ -114,78 +134,19 @@ const config = {
style: 'light', style: 'light',
links: [ links: [
{ {
title: 'Download', title: 'Overview',
items: [ items: [
{ {
label: 'Android', label: 'Quick start',
href: 'https://get.immich.app/android', to: '/overview/quick-start',
}, },
{ {
label: 'iOS', label: 'Installation',
href: 'https://get.immich.app/ios', to: '/install/requirements',
}, },
{ {
label: 'Server', label: 'Contributing',
href: 'https://immich.app/download', to: '/overview/support-the-project',
},
],
},
{
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', label: 'Privacy Policy',
@@ -194,7 +155,24 @@ const config = {
], ],
}, },
{ {
title: 'Social', title: 'Documentation',
items: [
{
label: 'Roadmap',
href: 'https://immich.app/roadmap',
},
{
label: 'API',
href: 'https://api.immich.app/',
},
{
label: 'Cursed Knowledge',
href: 'https://immich.app/cursed-knowledge',
},
],
},
{
title: 'Links',
items: [ items: [
{ {
label: 'GitHub', label: 'GitHub',
-1
View File
@@ -23,7 +23,6 @@
/features/storage-template /administration/storage-template 307 /features/storage-template /administration/storage-template 307
/features/user-management /administration/user-management 307 /features/user-management /administration/user-management 307
/developer/contributing /developer/pr-checklist 307 /developer/contributing /developer/pr-checklist 307
/developer/open-api /api 307
/guides/machine-learning /guides/remote-machine-learning 307 /guides/machine-learning /guides/remote-machine-learning 307
/administration/password-login /administration/system-settings 307 /administration/password-login /administration/system-settings 307
/features/search /features/searching 307 /features/search /features/searching 307
+3 -8
View File
@@ -1074,7 +1074,6 @@
"failed_to_update_notification_status": "Failed to update notification status", "failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password", "incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.", "library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found :/",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size", "quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -1810,8 +1809,9 @@
"rate_asset": "Rate Asset", "rate_asset": "Rate Asset",
"rating": "Star rating", "rating": "Star rating",
"rating_clear": "Clear rating", "rating_clear": "Clear rating",
"rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}", "rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel", "rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options", "reaction_options": "Reaction options",
"read_changelog": "Read Changelog", "read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled", "readonly_mode_disabled": "Read-only mode disabled",
@@ -1883,10 +1883,7 @@
"reset_pin_code_success": "Successfully reset PIN code", "reset_pin_code_success": "Successfully reset PIN code",
"reset_pin_code_with_password": "You can always reset your PIN code with your password", "reset_pin_code_with_password": "You can always reset your PIN code with your password",
"reset_sqlite": "Reset SQLite Database", "reset_sqlite": "Reset SQLite Database",
"reset_sqlite_clear_app_data": "Clear Data", "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_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_sqlite_success": "Successfully reset the SQLite database",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"resolution": "Resolution", "resolution": "Resolution",
@@ -1914,7 +1911,6 @@
"saved_settings": "Saved settings", "saved_settings": "Saved settings",
"say_something": "Say something", "say_something": "Say something",
"scaffold_body_error_occurred": "Error occurred", "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": "Scan",
"scan_all_libraries": "Scan All Libraries", "scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan", "scan_library": "Scan",
@@ -2311,7 +2307,6 @@
"unstack_action_prompt": "{count} unstacked", "unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type", "unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged", "untagged": "Untagged",
"untitled_workflow": "Untitled workflow", "untitled_workflow": "Untitled workflow",
"up_next": "Up next", "up_next": "Up next",
-1
View File
@@ -1 +0,0 @@
3.13
+50 -10
View File
@@ -22,7 +22,48 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image # Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size # TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS builder-rocm FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git
RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.31.9/cmake-3.31.9-linux-x86_64.sh && \
chmod +x cmake-3.31.9-linux-x86_64.sh && \
mkdir -p /code/cmake-3.31.9-linux-x86_64 && \
./cmake-3.31.9-linux-x86_64.sh --skip-license --prefix=/code/cmake-3.31.9-linux-x86_64 && \
rm cmake-3.31.9-linux-x86_64.sh
RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime
WORKDIR /code/onnxruntime
# Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567
# TODO: find a way to fix this without disabling algo caching
COPY ./patches/* /tmp/
RUN git apply /tmp/*.patch
RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh
ENV PATH=/opt/rocm-venv/bin:/code/cmake-3.31.9-linux-x86_64/bin:${PATH}
ENV CCACHE_DIR="/ccache"
# Note: the `parallel` setting uses a substantial amount of RAM
RUN --mount=type=cache,target=/ccache \
./build.sh \
--allow_running_as_root \
--config Release \
--build_wheel \
--update \
--build \
--parallel 48 \
--cmake_extra_defines \
ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" \
CMAKE_HIP_ARCHITECTURES="gfx900;gfx906;gfx908;gfx90a;gfx940;gfx941;gfx942;gfx1030;gfx1100;gfx1101;gfx1102;gfx1200;gfx1201" \
--skip_tests \
--use_rocm \
--rocm_home=/opt/rocm \
--use_cache \
--compile_no_warning_as_error
RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/
FROM builder-${DEVICE} AS builder FROM builder-${DEVICE} AS builder
@@ -38,6 +79,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --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 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 FROM python:3.11-slim-bookworm@sha256:04cd27899595a99dfe77709d96f08876bf2ee99139ee2f0fe9ac948005034e5b AS prod-cpu
@@ -48,14 +92,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
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.27.10/intel-igc-core-2_2.27.10+20617_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/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.05.37020.3/intel-opencl-icd_26.05.37020.3-0_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/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-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/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 && \ 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 # 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.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
dpkg -i *.deb && \ dpkg -i *.deb && \
rm *.deb && \ rm *.deb && \
apt-get remove wget -yqq && \ apt-get remove wget -yqq && \
@@ -76,11 +120,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-24.04:7.2-complete@sha256:86e11093b4a7ec2a79b1b6701d10e840a6994f21c7e05929b51eb9be361c683a AS prod-rocm FROM rocm/dev-ubuntu-24.04:6.4.4-complete@sha256:31418ac10a3769a71eaef330c07280d1d999d7074621339b8f93c484c35f6078 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 FROM prod-cpu AS prod-armnn
-1
View File
@@ -79,7 +79,6 @@ class Settings(BaseSettings):
preload: PreloadModelData | None = None preload: PreloadModelData | None = None
max_batch_size: MaxBatchSize | None = None max_batch_size: MaxBatchSize | None = None
openvino_precision: ModelPrecision = ModelPrecision.FP32 openvino_precision: ModelPrecision = ModelPrecision.FP32
rocm_precision: ModelPrecision = ModelPrecision.FP32
@property @property
def device_id(self) -> str: def device_id(self) -> str:
@@ -90,7 +90,7 @@ _PADDLE_MODELS = {
SUPPORTED_PROVIDERS = [ SUPPORTED_PROVIDERS = [
"CUDAExecutionProvider", "CUDAExecutionProvider",
"MIGraphXExecutionProvider", "ROCMExecutionProvider",
"OpenVINOExecutionProvider", "OpenVINOExecutionProvider",
"CoreMLExecutionProvider", "CoreMLExecutionProvider",
"CPUExecutionProvider", "CPUExecutionProvider",
+2 -11
View File
@@ -8,7 +8,7 @@ import onnxruntime as ort
from numpy.typing import NDArray from numpy.typing import NDArray
from immich_ml.models.constants import SUPPORTED_PROVIDERS from immich_ml.models.constants import SUPPORTED_PROVIDERS
from immich_ml.schemas import ModelPrecision, SessionNode from immich_ml.schemas import SessionNode
from ..config import log, settings from ..config import log, settings
@@ -90,17 +90,8 @@ class OrtSession:
match provider: match provider:
case "CPUExecutionProvider": case "CPUExecutionProvider":
options = {"arena_extend_strategy": "kSameAsRequested"} options = {"arena_extend_strategy": "kSameAsRequested"}
case "CUDAExecutionProvider": case "CUDAExecutionProvider" | "ROCMExecutionProvider":
options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id} 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": case "OpenVINOExecutionProvider":
openvino_dir = self.model_path.parent / "openvino" openvino_dir = self.model_path.parent / "openvino"
device = f"GPU.{settings.device_id}" device = f"GPU.{settings.device_id}"
@@ -0,0 +1,179 @@
commit 16839b58d9b3c3162a67ce5d776b36d4d24e801f
Author: mertalev <101130780+mertalev@users.noreply.github.com>
Date: Wed Mar 5 11:25:38 2025 -0500
disable algo caching (attributed to @dmnieto in https://github.com/microsoft/onnxruntime/pull/19567)
diff --git a/onnxruntime/core/providers/rocm/nn/conv.cc b/onnxruntime/core/providers/rocm/nn/conv.cc
index d7f47d07a8..4060a2af52 100644
--- a/onnxruntime/core/providers/rocm/nn/conv.cc
+++ b/onnxruntime/core/providers/rocm/nn/conv.cc
@@ -127,7 +127,6 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
if (w_dims_changed) {
s_.last_w_dims = gsl::make_span(w_dims);
- s_.cached_benchmark_fwd_results.clear();
}
ORT_RETURN_IF_ERROR(conv_attrs_.ValidateInputShape(X->Shape(), W->Shape(), channels_last, channels_last));
@@ -277,35 +276,6 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
HIP_CALL_THROW(hipMalloc(&s_.b_zero, malloc_size));
HIP_CALL_THROW(hipMemsetAsync(s_.b_zero, 0, malloc_size, Stream(context)));
}
-
- if (!s_.cached_benchmark_fwd_results.contains(x_dims_miopen)) {
- miopenConvAlgoPerf_t perf;
- int algo_count = 1;
- const ROCMExecutionProvider* rocm_ep = static_cast<const ROCMExecutionProvider*>(this->Info().GetExecutionProvider());
- static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT;
- size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId())
- : AlgoSearchWorkspaceSize;
- IAllocatorUniquePtr<void> algo_search_workspace = GetTransientScratchBuffer<void>(max_ws_size);
- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm(
- GetMiopenHandle(context),
- s_.x_tensor,
- s_.x_data,
- s_.w_desc,
- s_.w_data,
- s_.conv_desc,
- s_.y_tensor,
- s_.y_data,
- 1, // requestedAlgoCount
- &algo_count, // returnedAlgoCount
- &perf,
- algo_search_workspace.get(),
- max_ws_size,
- false)); // Do not do exhaustive algo search.
- s_.cached_benchmark_fwd_results.insert(x_dims_miopen, {perf.fwd_algo, perf.memory});
- }
- const auto& perf = s_.cached_benchmark_fwd_results.at(x_dims_miopen);
- s_.fwd_algo = perf.fwd_algo;
- s_.workspace_bytes = perf.memory;
} else {
// set Y
s_.Y = context->Output(0, TensorShape(s_.y_dims));
@@ -319,6 +289,31 @@ Status Conv<T, NHWC>::UpdateState(OpKernelContext* context, bool bias_expected)
s_.y_data = reinterpret_cast<HipT*>(s_.Y->MutableData<T>());
}
}
+
+ miopenConvAlgoPerf_t perf;
+ int algo_count = 1;
+ const ROCMExecutionProvider* rocm_ep = static_cast<const ROCMExecutionProvider*>(this->Info().GetExecutionProvider());
+ static constexpr int num_algos = MIOPEN_CONVOLUTION_FWD_ALGO_COUNT;
+ size_t max_ws_size = rocm_ep->GetMiopenConvUseMaxWorkspace() ? GetMaxWorkspaceSize(GetMiopenHandle(context), s_, kAllAlgos, num_algos, rocm_ep->GetDeviceId())
+ : AlgoSearchWorkspaceSize;
+ IAllocatorUniquePtr<void> algo_search_workspace = GetTransientScratchBuffer<void>(max_ws_size);
+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionForwardAlgorithm(
+ GetMiopenHandle(context),
+ s_.x_tensor,
+ s_.x_data,
+ s_.w_desc,
+ s_.w_data,
+ s_.conv_desc,
+ s_.y_tensor,
+ s_.y_data,
+ 1, // requestedAlgoCount
+ &algo_count, // returnedAlgoCount
+ &perf,
+ algo_search_workspace.get(),
+ max_ws_size,
+ false)); // Do not do exhaustive algo search.
+ s_.fwd_algo = perf.fwd_algo;
+ s_.workspace_bytes = perf.memory;
return Status::OK();
}
diff --git a/onnxruntime/core/providers/rocm/nn/conv.h b/onnxruntime/core/providers/rocm/nn/conv.h
index bc9846203e..d54218f258 100644
--- a/onnxruntime/core/providers/rocm/nn/conv.h
+++ b/onnxruntime/core/providers/rocm/nn/conv.h
@@ -108,9 +108,6 @@ class lru_unordered_map {
list_type lru_list_;
};
-// cached miopen descriptors
-constexpr size_t MAX_CACHED_ALGO_PERF_RESULTS = 10000;
-
template <typename AlgoPerfType>
struct MiopenConvState {
// if x/w dims changed, update algo and miopenTensors
@@ -148,9 +145,6 @@ struct MiopenConvState {
decltype(AlgoPerfType().memory) memory;
};
- lru_unordered_map<TensorShapeVector, PerfFwdResultParams, vector_hash> cached_benchmark_fwd_results{MAX_CACHED_ALGO_PERF_RESULTS};
- lru_unordered_map<TensorShapeVector, PerfBwdResultParams, vector_hash> cached_benchmark_bwd_results{MAX_CACHED_ALGO_PERF_RESULTS};
-
// Some properties needed to support asymmetric padded Conv nodes
bool post_slicing_required;
TensorShapeVector slice_starts;
diff --git a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
index 7447113fdf..a662e35b2e 100644
--- a/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
+++ b/onnxruntime/core/providers/rocm/nn/conv_transpose.cc
@@ -76,7 +76,6 @@ Status ConvTranspose<T, NHWC>::DoConvTranspose(OpKernelContext* context, bool dy
if (w_dims_changed) {
s_.last_w_dims = gsl::make_span(w_dims);
- s_.cached_benchmark_bwd_results.clear();
}
ConvTransposeAttributes::Prepare p;
@@ -126,35 +125,29 @@ Status ConvTranspose<T, NHWC>::DoConvTranspose(OpKernelContext* context, bool dy
}
y_data = reinterpret_cast<HipT*>(p.Y->MutableData<T>());
-
- if (!s_.cached_benchmark_bwd_results.contains(x_dims)) {
- IAllocatorUniquePtr<void> algo_search_workspace = GetScratchBuffer<void>(AlgoSearchWorkspaceSize, context->GetComputeStream());
-
- miopenConvAlgoPerf_t perf;
- int algo_count = 1;
- MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm(
- GetMiopenHandle(context),
- s_.x_tensor,
- x_data,
- s_.w_desc,
- w_data,
- s_.conv_desc,
- s_.y_tensor,
- y_data,
- 1,
- &algo_count,
- &perf,
- algo_search_workspace.get(),
- AlgoSearchWorkspaceSize,
- false));
- s_.cached_benchmark_bwd_results.insert(x_dims, {perf.bwd_data_algo, perf.memory});
- }
-
- const auto& perf = s_.cached_benchmark_bwd_results.at(x_dims);
- s_.bwd_data_algo = perf.bwd_data_algo;
- s_.workspace_bytes = perf.memory;
}
+ IAllocatorUniquePtr<void> algo_search_workspace = GetScratchBuffer<void>(AlgoSearchWorkspaceSize, context->GetComputeStream());
+ miopenConvAlgoPerf_t perf;
+ int algo_count = 1;
+ MIOPEN_RETURN_IF_ERROR(miopenFindConvolutionBackwardDataAlgorithm(
+ GetMiopenHandle(context),
+ s_.x_tensor,
+ x_data,
+ s_.w_desc,
+ w_data,
+ s_.conv_desc,
+ s_.y_tensor,
+ y_data,
+ 1,
+ &algo_count,
+ &perf,
+ algo_search_workspace.get(),
+ AlgoSearchWorkspaceSize,
+ false));
+ s_.bwd_data_algo = perf.bwd_data_algo;
+ s_.workspace_bytes = perf.memory;
+
// The following block will be executed in case there has been no change in the shapes of the
// input and the filter compared to the previous run
if (!y_data) {
@@ -0,0 +1,33 @@
diff --git a/dockerfiles/scripts/install_common_deps.sh b/dockerfiles/scripts/install_common_deps.sh
index bbb672a99e..0dc652fbda 100644
--- a/dockerfiles/scripts/install_common_deps.sh
+++ b/dockerfiles/scripts/install_common_deps.sh
@@ -8,16 +8,23 @@ apt-get update && apt-get install -y --no-install-recommends \
curl \
libcurl4-openssl-dev \
libssl-dev \
- python3-dev
+ python3-dev \
+ ccache
# Dependencies: conda
-wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh --no-check-certificate && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
+wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-py312_25.9.1-1-Linux-x86_64.sh -O ~/miniconda.sh && /bin/bash ~/miniconda.sh -b -p /opt/miniconda
rm ~/miniconda.sh
/opt/miniconda/bin/conda clean -ya
-pip install numpy
-pip install packaging
-pip install "wheel>=0.35.1"
+# Dependencies: venv and packages
+/opt/miniconda/bin/python3 -m venv /opt/rocm-venv
+/opt/rocm-venv/bin/pip install --no-cache-dir --upgrade pip
+/opt/rocm-venv/bin/pip install --no-cache-dir \
+ "numpy==2.3.4" \
+ "packaging==25.0" \
+ "wheel==0.45.1" \
+ "setuptools==80.9.0"
+
rm -rf /opt/miniconda/pkgs
# Dependencies: cmake
+2 -2
View File
@@ -49,10 +49,10 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies] [project.optional-dependencies]
cpu = ["onnxruntime>=1.23.2,<2"] cpu = ["onnxruntime>=1.23.2,<2"]
cuda = ["onnxruntime-gpu>=1.23.2,<2"] cuda = ["onnxruntime-gpu>=1.23.2,<2"]
openvino = ["onnxruntime-openvino>=1.24.1,<2"] openvino = ["onnxruntime-openvino>=1.23.0,<2"]
armnn = ["onnxruntime>=1.23.2,<2"] armnn = ["onnxruntime>=1.23.2,<2"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"] rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"] rocm = []
[tool.uv] [tool.uv]
compile-bytecode = true compile-bytecode = true
+4 -30
View File
@@ -179,7 +179,7 @@ class TestOrtSession:
OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"] OV_EP = ["OpenVINOExecutionProvider", "CPUExecutionProvider"]
CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"] CUDA_EP_OUT_OF_ORDER = ["CPUExecutionProvider", "CUDAExecutionProvider"]
TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"] TRT_EP = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
ROCM_EP = ["MIGraphXExecutionProvider", "CPUExecutionProvider"] ROCM_EP = ["ROCMExecutionProvider", "CPUExecutionProvider"]
COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"] COREML_EP = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
@pytest.mark.providers(CPU_EP) @pytest.mark.providers(CPU_EP)
@@ -289,38 +289,12 @@ class TestOrtSession:
assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}] assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
def test_sets_provider_options_for_rocm(self, mocker: MockerFixture) -> None: def test_sets_provider_options_for_rocm(self) -> None:
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
mkdir = mocker.patch("immich_ml.sessions.ort.Path.mkdir")
session = OrtSession(model_path, providers=["MIGraphXExecutionProvider"]) session = OrtSession("ViT-B-32__openai", providers=["ROCMExecutionProvider"])
assert session.provider_options == [ assert session.provider_options == [{"arena_extend_strategy": "kSameAsRequested", "device_id": "1"}]
{
"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: def test_sets_provider_options_kwarg(self) -> None:
session = OrtSession( session = OrtSession(
+66 -77
View File
@@ -262,6 +262,18 @@ 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" }, { 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]] [[package]]
name = "colorlog" name = "colorlog"
version = "6.9.0" version = "6.9.0"
@@ -874,6 +886,18 @@ 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -936,9 +960,6 @@ rknn = [
{ name = "onnxruntime" }, { name = "onnxruntime" },
{ name = "rknn-toolkit-lite2" }, { name = "rknn-toolkit-lite2" },
] ]
rocm = [
{ name = "onnxruntime-migraphx" },
]
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
@@ -992,8 +1013,7 @@ requires-dist = [
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime", marker = "extra == 'rknn'", 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-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" }, { name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" }, { name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
{ name = "orjson", specifier = ">=3.9.5" }, { name = "orjson", specifier = ">=3.9.5" },
{ name = "pillow", specifier = ">=12.1.1,<12.2" }, { name = "pillow", specifier = ">=12.1.1,<12.2" },
@@ -1409,55 +1429,32 @@ wheels = [
[[package]] [[package]]
name = "msgpack" name = "msgpack"
version = "1.1.2" version = "1.0.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } sdist = { url = "https://files.pythonhosted.org/packages/c2/d5/5662032db1571110b5b51647aed4b56dfbd01bfae789fa566a2be1f385d1/msgpack-1.0.7.tar.gz", hash = "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", size = 166311, upload-time = "2023-09-28T13:20:36.726Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, { url = "https://files.pythonhosted.org/packages/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@@ -1704,29 +1701,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/94/a3b20276261f5e64dbd72bda656af988282cff01f18c2685953600e2f810/onnxruntime_gpu-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2cee7e12b0f4813c62f9a48df83fd01d066cc970400c832252cf3c155a6957", size = 252633096, upload-time = "2026-02-05T17:24:53.248Z" }, { url = "https://files.pythonhosted.org/packages/6a/94/a3b20276261f5e64dbd72bda656af988282cff01f18c2685953600e2f810/onnxruntime_gpu-1.24.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2cee7e12b0f4813c62f9a48df83fd01d066cc970400c832252cf3c155a6957", size = 252633096, upload-time = "2026-02-05T17:24:53.248Z" },
] ]
[[package]]
name = "onnxruntime-migraphx"
version = "1.24.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/da/ca7ebc1a8d1193c97ceb9a05fad50f675eb955dc51beb7eb9ba89c8e7db0/onnxruntime_migraphx-1.24.2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:a2b434fb8880cac2b268950bdf279f33741d29c1f1c5461d27af835e8e288043", size = 20339710, upload-time = "2026-02-21T07:25:13.17Z" },
{ url = "https://files.pythonhosted.org/packages/fa/2e/8c83ec45a9365b4256495ca55eea30da7f03b02177b6da423c7da1ff5f6a/onnxruntime_migraphx-1.24.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:ec814818da952bda3062e26f56c88bb713c00491ef91f86716c8d7346f9bc31b", size = 20341883, upload-time = "2026-02-21T07:25:17.86Z" },
{ url = "https://files.pythonhosted.org/packages/9f/52/4776ac68dbc46ca02c9a14cc9e5c496017f47a18cedf606cc38f4911b96a/onnxruntime_migraphx-1.24.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:20e497538362170af639b03a40249d7ed61b873ac354f20d732b90252206e320", size = 20342422, upload-time = "2026-02-21T07:25:22.526Z" },
{ url = "https://files.pythonhosted.org/packages/76/44/db9035204a3363f9c0a4822c68e9a7520c13ef8d261f96b89b1375106dab/onnxruntime_migraphx-1.24.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:9d7f1b1a2b9651143a2080b4f42ee99eead02023de1855d1b8a02199a9c179aa", size = 20343783, upload-time = "2026-02-21T07:25:29.155Z" },
]
[[package]] [[package]]
name = "onnxruntime-openvino" name = "onnxruntime-openvino"
version = "1.24.1" version = "1.23.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" }, { name = "flatbuffers" },
{ name = "numpy" }, { name = "numpy" },
{ name = "packaging" }, { name = "packaging" },
@@ -1734,12 +1714,12 @@ dependencies = [
{ name = "sympy" }, { name = "sympy" },
] ]
wheels = [ 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/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/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/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/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/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/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/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/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/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/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" }, { 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" },
] ]
[[package]] [[package]]
@@ -2179,6 +2159,15 @@ 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" }, { 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface LocalImageApi { interface LocalImageApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
@@ -82,8 +82,7 @@ interface LocalImageApi {
val widthArg = args[2] as Long val widthArg = args[2] as Long
val heightArg = args[3] as Long val heightArg = args[3] as Long
val isVideoArg = args[4] as Boolean val isVideoArg = args[4] as Boolean
val preferEncodedArg = args[5] as Boolean api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(LocalImagesPigeonUtils.wrapError(error)) reply.reply(LocalImagesPigeonUtils.wrapError(error))
@@ -14,7 +14,6 @@ import android.util.Size
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer import app.alextran.immich.NativeBuffer
import kotlin.math.* import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.Priority import com.bumptech.glide.Priority
@@ -100,17 +99,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
width: Long, width: Long,
height: Long, height: Long,
isVideo: Boolean, isVideo: Boolean,
preferEncoded: Boolean,
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
) { ) {
val signal = CancellationSignal() val signal = CancellationSignal()
val task = threadPool.submit { val task = threadPool.submit {
try { try {
if (preferEncoded) { getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
getEncodedImageInternal(assetId, callback, signal)
} else {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
}
} catch (e: Exception) { } catch (e: Exception) {
when (e) { when (e) {
is OperationCanceledException -> callback(CANCELLED) is OperationCanceledException -> callback(CANCELLED)
@@ -139,35 +133,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
} }
} }
private fun getEncodedImageInternal(
assetId: String,
callback: (Result<Map<String, Long>?>) -> Unit,
signal: CancellationSignal
) {
signal.throwIfCanceled()
val id = assetId.toLong()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
signal.throwIfCanceled()
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IOException("Could not read image data for $assetId")
signal.throwIfCanceled()
val pointer = NativeBuffer.allocate(bytes.size)
try {
val buffer = NativeBuffer.wrap(pointer, bytes.size)
buffer.put(bytes)
signal.throwIfCanceled()
callback(Result.success(mapOf(
"pointer" to pointer,
"length" to bytes.size.toLong()
)))
} catch (e: Exception) {
NativeBuffer.free(pointer)
throw e
}
}
private fun getThumbnailBufferInternal( private fun getThumbnailBufferInternal(
assetId: String, assetId: String,
width: Long, width: Long,
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ /** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface RemoteImageApi { interface RemoteImageApi {
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit) fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
fun cancelRequest(requestId: Long) fun cancelRequest(requestId: Long)
fun clearCache(callback: (Result<Long>) -> Unit) fun clearCache(callback: (Result<Long>) -> Unit)
@@ -68,8 +68,7 @@ interface RemoteImageApi {
val urlArg = args[0] as String val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String> val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long val requestIdArg = args[2] as Long
val preferEncodedArg = args[3] as Boolean api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error)) reply.reply(RemoteImagesPigeonUtils.wrapError(error))
@@ -51,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url: String, url: String,
headers: Map<String, String>, headers: Map<String, String>,
requestId: Long, requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit callback: (Result<Map<String, Long>?>) -> Unit
) { ) {
val signal = CancellationSignal() val signal = CancellationSignal()
@@ -78,21 +78,6 @@ class FlutterError (
val details: Any? = null val details: Any? = null
) : Throwable() ) : Throwable()
enum class PlatformAssetPlaybackStyle(val raw: Int) {
UNKNOWN(0),
IMAGE(1),
VIDEO(2),
IMAGE_ANIMATED(3),
LIVE_PHOTO(4),
VIDEO_LOOPING(5);
companion object {
fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */ /** Generated class from Pigeon that represents data sent in messages. */
data class PlatformAsset ( data class PlatformAsset (
val id: String, val id: String,
@@ -107,8 +92,7 @@ data class PlatformAsset (
val isFavorite: Boolean, val isFavorite: Boolean,
val adjustmentTime: Long? = null, val adjustmentTime: Long? = null,
val latitude: Double? = null, val latitude: Double? = null,
val longitude: Double? = null, val longitude: Double? = null
val playbackStyle: PlatformAssetPlaybackStyle
) )
{ {
companion object { companion object {
@@ -126,8 +110,7 @@ data class PlatformAsset (
val adjustmentTime = pigeonVar_list[10] as Long? val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] as Double? val latitude = pigeonVar_list[11] as Double?
val longitude = pigeonVar_list[12] as Double? val longitude = pigeonVar_list[12] as Double?
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@@ -145,7 +128,6 @@ data class PlatformAsset (
adjustmentTime, adjustmentTime,
latitude, latitude,
longitude, longitude,
playbackStyle,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -308,31 +290,26 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) { return when (type) {
129.toByte() -> { 129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it) PlatformAsset.fromList(it)
} }
} }
131.toByte() -> { 130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it) PlatformAlbum.fromList(it)
} }
} }
132.toByte() -> { 131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it) SyncDelta.fromList(it)
} }
} }
133.toByte() -> { 132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it) HashResult.fromList(it)
} }
} }
134.toByte() -> { 133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let { return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it) CloudIdResult.fromList(it)
} }
@@ -342,28 +319,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
} }
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) { when (value) {
is PlatformAssetPlaybackStyle -> {
stream.write(129)
writeValue(stream, value.raw)
}
is PlatformAsset -> { is PlatformAsset -> {
stream.write(130) stream.write(129)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is PlatformAlbum -> { is PlatformAlbum -> {
stream.write(131) stream.write(130)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is SyncDelta -> { is SyncDelta -> {
stream.write(132) stream.write(131)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is HashResult -> { is HashResult -> {
stream.write(133) stream.write(132)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
is CloudIdResult -> { is CloudIdResult -> {
stream.write(134) stream.write(133)
writeValue(stream, value.toList()) writeValue(stream, value.toList())
} }
else -> super.writeValue(stream, value) else -> super.writeValue(stream, value)
@@ -4,17 +4,11 @@ import android.annotation.SuppressLint
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Base64 import android.util.Base64
import android.util.Log
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.ImmichPlugin
import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -34,8 +28,6 @@ sealed class AssetResult {
data class InvalidAsset(val assetId: String) : AssetResult() data class InvalidAsset(val assetId: String) : AssetResult()
} }
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() { open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
@@ -47,13 +39,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS) private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED" private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
private const val SPECIAL_FORMAT_COLUMN = "_special_format"
private const val SPECIAL_FORMAT_GIF = 1
private const val SPECIAL_FORMAT_MOTION_PHOTO = 2
private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3
const val MEDIA_SELECTION = const val MEDIA_SELECTION =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)" "(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
val MEDIA_SELECTION_ARGS = arrayOf( val MEDIA_SELECTION_ARGS = arrayOf(
@@ -75,15 +60,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
add(MediaStore.MediaColumns.DURATION) add(MediaStore.MediaColumns.DURATION)
add(MediaStore.MediaColumns.ORIENTATION) add(MediaStore.MediaColumns.ORIENTATION)
// IS_FAVORITE is only available on Android 11 and above // IS_FAVORITE is only available on Android 11 and above
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
add(MediaStore.MediaColumns.IS_FAVORITE) add(MediaStore.MediaColumns.IS_FAVORITE)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(SPECIAL_FORMAT_COLUMN)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Fallback: read XMP from MediaStore to detect Motion Photos
add(MediaStore.MediaColumns.XMP)
}
}.toTypedArray() }.toTypedArray()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024 const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
@@ -130,12 +109,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val orientationColumn = val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION) c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE) val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN)
val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP)
while (c.moveToNext()) { while (c.moveToNext()) {
val numericId = c.getLong(idColumn) val id = c.getLong(idColumn).toString()
val id = numericId.toString()
val name = c.getStringOrNull(nameColumn) val name = c.getStringOrNull(nameColumn)
val bucketId = c.getStringOrNull(bucketIdColumn) val bucketId = c.getStringOrNull(bucketIdColumn)
val path = c.getStringOrNull(dataColumn) val path = c.getStringOrNull(dataColumn)
@@ -149,11 +125,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
continue continue
} }
val rawMediaType = c.getInt(mediaTypeColumn) val mediaType = when (c.getInt(mediaTypeColumn)) {
val assetType: Long = when (rawMediaType) { MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L else -> 0
else -> 0L
} }
// Date taken is milliseconds since epoch, Date added is seconds since epoch // Date taken is milliseconds since epoch, Date added is seconds since epoch
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000)) val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
@@ -163,19 +138,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val width = c.getInt(widthColumn).toLong() val width = c.getInt(widthColumn).toLong()
val height = c.getInt(heightColumn).toLong() val height = c.getInt(heightColumn).toLong()
// Duration is milliseconds // Duration is milliseconds
val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
else c.getLong(durationColumn) / 1000 else c.getLong(durationColumn) / 1000
val orientation = c.getInt(orientationColumn) val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0 val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
)
val asset = PlatformAsset( val asset = PlatformAsset(
id, id,
name, name,
assetType, mediaType.toLong(),
createdAt, createdAt,
modifiedAt, modifiedAt,
width, width,
@@ -183,7 +154,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
duration, duration,
orientation.toLong(), orientation.toLong(),
isFavorite, isFavorite,
playbackStyle = playbackStyle,
) )
yield(AssetResult.ValidAsset(asset, bucketId)) yield(AssetResult.ValidAsset(asset, bucketId))
} }
@@ -191,81 +161,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
} }
} }
/**
* Detects the playback style for an asset using _special_format (API 33+)
* or XMP / MIME / RIFF header fallbacks (pre-33).
*/
@SuppressLint("NewApi")
private fun detectPlaybackStyle(
assetId: Long,
rawMediaType: Int,
specialFormatColumn: Int,
xmpColumn: Int,
cursor: Cursor
): PlatformAssetPlaybackStyle {
// video currently has no special formats, so we can short circuit and avoid unnecessary work
if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
return PlatformAssetPlaybackStyle.VIDEO
}
// API 33+: use _special_format from cursor
if (specialFormatColumn != -1) {
val specialFormat = cursor.getInt(specialFormatColumn)
return when {
specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO
specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED
rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE
else -> PlatformAssetPlaybackStyle.UNKNOWN
}
}
if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
return PlatformAssetPlaybackStyle.UNKNOWN
}
// Pre-API 33 fallback
val uri = ContentUris.withAppendedId(
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
assetId
)
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
val xmp: String? = if (xmpColumn != -1) {
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
} else {
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
null
}
}
if (xmp != null && "Camera:MotionPhoto" in xmp) {
return PlatformAssetPlaybackStyle.LIVE_PHOTO
}
try {
ctx.contentResolver.openInputStream(uri)?.use { stream ->
val glide = Glide.get(ctx)
val type = ImageHeaderParserUtils.getType(
glide.registry.imageHeaderParsers,
stream,
glide.arrayPool
)
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
}
return PlatformAssetPlaybackStyle.IMAGE
}
fun getAlbums(): List<PlatformAlbum> { fun getAlbums(): List<PlatformAlbum> {
val albums = mutableListOf<PlatformAlbum>() val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>() val albumsCount = mutableMapOf<String, Int>()
+2 -3
View File
@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol LocalImageApi { protocol LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws func cancelRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void) func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
} }
@@ -90,8 +90,7 @@ class LocalImageApiSetup {
let widthArg = args[2] as! Int64 let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64 let heightArg = args[3] as! Int64
let isVideoArg = args[4] as! Bool let isVideoArg = args[4] as! Bool
let preferEncodedArg = args[5] as! Bool api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
switch result { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))
+25 -68
View File
@@ -7,7 +7,7 @@ class LocalImageRequest {
weak var workItem: DispatchWorkItem? weak var workItem: DispatchWorkItem?
var isCancelled = false var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) { init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback self.callback = callback
} }
@@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current requestOptions.version = .current
return requestOptions return requestOptions
}() }()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated) private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static var rgbaFormat = vImage_CGImageFormat( private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8, bitsPerComponent: 8,
bitsPerPixel: 32, bitsPerPixel: 32,
@@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi {
assetCache.countLimit = 10000 assetCache.countLimit = 10000
return assetCache return assetCache
}() }()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.async { ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash) guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data) let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([ completion(.success([
"pointer": Int64(Int(bitPattern: pointer.baseAddress)), "pointer": Int64(Int(bitPattern: pointer.baseAddress)),
@@ -63,77 +63,34 @@ class LocalImageApiImpl: LocalImageApi {
])) ]))
} }
} }
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion) let request = LocalImageRequest(callback: completion)
let item = DispatchWorkItem { let item = DispatchWorkItem {
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
ImageProcessing.semaphore.wait() ImageProcessing.semaphore.wait()
defer { defer {
ImageProcessing.semaphore.signal() ImageProcessing.semaphore.signal()
} }
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
guard let asset = Self.requestAsset(assetId: assetId) guard let asset = Self.requestAsset(assetId: assetId)
else { else {
Self.remove(requestId: requestId) Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return return
} }
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
if preferEncoded {
let dataOptions = PHImageRequestOptions()
dataOptions.isNetworkAccessAllowed = true
dataOptions.isSynchronous = true
dataOptions.version = .current
var imageData: Data?
Self.imageManager.requestImageDataAndOrientation(
for: asset,
options: dataOptions,
resultHandler: { (data, _, _, _) in
imageData = data
}
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
let length = data.count
let pointer = malloc(length)!
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.remove(requestId: requestId)
return
}
var image: UIImage? var image: UIImage?
Self.imageManager.requestImage( Self.imageManager.requestImage(
for: asset, for: asset,
@@ -144,29 +101,29 @@ class LocalImageApiImpl: LocalImageApi {
image = _image image = _image
} }
) )
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
guard let image = image, guard let image = image,
let cgImage = image.cgImage else { let cgImage = image.cgImage else {
Self.remove(requestId: requestId) Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
} }
if request.isCancelled { if request.isCancelled {
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
do { do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat) let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled { if request.isCancelled {
buffer.free() buffer.free()
return completion(ImageProcessing.cancelledResult) return completion(ImageProcessing.cancelledResult)
} }
request.callback(.success([ request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)), "pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width), "width": Int64(buffer.width),
@@ -179,24 +136,24 @@ class LocalImageApiImpl: LocalImageApi {
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil))) return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
} }
} }
request.workItem = item request.workItem = item
Self.add(requestId: requestId, request: request) Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item) ImageProcessing.queue.async(execute: item)
} }
func cancelRequest(requestId: Int64) { func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId) Self.cancel(requestId: requestId)
} }
private static func add(requestId: Int64, request: LocalImageRequest) -> Void { private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request } requestQueue.sync { requests[requestId] = request }
} }
private static func remove(requestId: Int64) -> Void { private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil } requestQueue.sync { requests[requestId] = nil }
} }
private static func cancel(requestId: Int64) -> Void { private static func cancel(requestId: Int64) -> Void {
requestQueue.async { requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return } guard let request = requests.removeValue(forKey: requestId) else { return }
@@ -207,12 +164,12 @@ class LocalImageApiImpl: LocalImageApi {
} }
} }
} }
private static func requestAsset(assetId: String) -> PHAsset? { private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset? var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset } if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil } else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter. /// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol RemoteImageApi { protocol RemoteImageApi {
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void) func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
func cancelRequest(requestId: Int64) throws func cancelRequest(requestId: Int64) throws
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void) func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
} }
@@ -88,8 +88,7 @@ class RemoteImageApiSetup {
let urlArg = args[0] as! String let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String] let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64 let requestIdArg = args[2] as! Int64
let preferEncodedArg = args[3] as! Bool api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
switch result { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))
+23 -41
View File
@@ -8,7 +8,7 @@ class RemoteImageRequest {
let id: Int64 let id: Int64
var isCancelled = false var isCancelled = false
let completion: (Result<[String: Int64]?, any Error>) -> Void let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) { init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id self.id = id
self.task = task self.task = task
@@ -32,93 +32,75 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true kCGImageSourceCreateThumbnailFromImageAlways: true
] as CFDictionary ] as CFDictionary
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) { func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
var urlRequest = URLRequest(url: URL(string: url)!) var urlRequest = URLRequest(url: URL(string: url)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad urlRequest.cachePolicy = .returnCacheDataElseLoad
for (key, value) in headers { for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key) urlRequest.setValue(value, forHTTPHeaderField: key)
} }
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error) Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
} }
let request = RemoteImageRequest(id: requestId, task: task, completion: completion) let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
os_unfair_lock_lock(&Self.lock) os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock) os_unfair_lock_unlock(&Self.lock)
task.resume() task.resume()
} }
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) { private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
os_unfair_lock_lock(&Self.lock) os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else { guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock) return os_unfair_lock_unlock(&Self.lock)
} }
requests[requestId] = nil requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock) os_unfair_lock_unlock(&Self.lock)
if let error = error { if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled { if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
return request.completion(.failure(error)) return request.completion(.failure(error))
} }
if request.isCancelled { if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
guard let data = data else { guard let data = data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil))) return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
} }
ImageProcessing.queue.async { ImageProcessing.queue.async {
ImageProcessing.semaphore.wait() ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() } defer { ImageProcessing.semaphore.signal() }
if request.isCancelled { if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
// Return raw encoded bytes when requested (for animated images)
if encoded {
let length = data.count
let pointer = malloc(length)!
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
if request.isCancelled {
free(pointer)
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(
.success([
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else { let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil))) return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
} }
if request.isCancelled { if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
do { do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat) let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
if request.isCancelled { if request.isCancelled {
buffer.free() buffer.free()
return request.completion(ImageProcessing.cancelledResult) return request.completion(ImageProcessing.cancelledResult)
} }
request.completion( request.completion(
.success([ .success([
"pointer": Int64(Int(bitPattern: buffer.data)), "pointer": Int64(Int(bitPattern: buffer.data)),
@@ -131,17 +113,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
} }
} }
} }
func cancelRequest(requestId: Int64) { func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock) os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId] let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock) os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return } guard let request = request else { return }
request.isCancelled = true request.isCancelled = true
request.task?.cancel() request.task?.cancel()
} }
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) { func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task { Task {
let cache = URLSessionManager.shared.session.configuration.urlCache! let cache = URLSessionManager.shared.session.configuration.urlCache!
+10 -32
View File
@@ -128,15 +128,6 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) {
enum PlatformAssetPlaybackStyle: Int {
case unknown = 0
case image = 1
case video = 2
case imageAnimated = 3
case livePhoto = 4
case videoLooping = 5
}
/// Generated class from Pigeon that represents data sent in messages. /// Generated class from Pigeon that represents data sent in messages.
struct PlatformAsset: Hashable { struct PlatformAsset: Hashable {
var id: String var id: String
@@ -152,7 +143,6 @@ struct PlatformAsset: Hashable {
var adjustmentTime: Int64? = nil var adjustmentTime: Int64? = nil
var latitude: Double? = nil var latitude: Double? = nil
var longitude: Double? = nil var longitude: Double? = nil
var playbackStyle: PlatformAssetPlaybackStyle
// swift-format-ignore: AlwaysUseLowerCamelCase // swift-format-ignore: AlwaysUseLowerCamelCase
@@ -170,7 +160,6 @@ struct PlatformAsset: Hashable {
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10]) let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11]) let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12]) let longitude: Double? = nilOrValue(pigeonVar_list[12])
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
return PlatformAsset( return PlatformAsset(
id: id, id: id,
@@ -185,8 +174,7 @@ struct PlatformAsset: Hashable {
isFavorite: isFavorite, isFavorite: isFavorite,
adjustmentTime: adjustmentTime, adjustmentTime: adjustmentTime,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude
playbackStyle: playbackStyle
) )
} }
func toList() -> [Any?] { func toList() -> [Any?] {
@@ -204,7 +192,6 @@ struct PlatformAsset: Hashable {
adjustmentTime, adjustmentTime,
latitude, latitude,
longitude, longitude,
playbackStyle,
] ]
} }
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@@ -362,20 +349,14 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? { override func readValue(ofType type: UInt8) -> Any? {
switch type { switch type {
case 129: case 129:
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt)
}
return nil
case 130:
return PlatformAsset.fromList(self.readValue() as! [Any?]) return PlatformAsset.fromList(self.readValue() as! [Any?])
case 131: case 130:
return PlatformAlbum.fromList(self.readValue() as! [Any?]) return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 132: case 131:
return SyncDelta.fromList(self.readValue() as! [Any?]) return SyncDelta.fromList(self.readValue() as! [Any?])
case 133: case 132:
return HashResult.fromList(self.readValue() as! [Any?]) return HashResult.fromList(self.readValue() as! [Any?])
case 134: case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?]) return CloudIdResult.fromList(self.readValue() as! [Any?])
default: default:
return super.readValue(ofType: type) return super.readValue(ofType: type)
@@ -385,23 +366,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
private class MessagesPigeonCodecWriter: FlutterStandardWriter { private class MessagesPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) { override func writeValue(_ value: Any) {
if let value = value as? PlatformAssetPlaybackStyle { if let value = value as? PlatformAsset {
super.writeByte(129) super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(130)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum { } else if let value = value as? PlatformAlbum {
super.writeByte(131) super.writeByte(130)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? SyncDelta { } else if let value = value as? SyncDelta {
super.writeByte(132) super.writeByte(131)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? HashResult { } else if let value = value as? HashResult {
super.writeByte(133) super.writeByte(132)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else if let value = value as? CloudIdResult { } else if let value = value as? CloudIdResult {
super.writeByte(134) super.writeByte(133)
super.writeValue(value.toList()) super.writeValue(value.toList())
} else { } else {
super.writeValue(value) super.writeValue(value)
+1 -2
View File
@@ -173,8 +173,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
type: 0, type: 0,
durationInSeconds: 0, durationInSeconds: 0,
orientation: 0, orientation: 0,
isFavorite: false, isFavorite: false
playbackStyle: .unknown
) )
if (updatedAssets.contains(AssetWrapper(with: predicate))) { if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue continue
+2 -14
View File
@@ -1,17 +1,6 @@
import Photos import Photos
extension PHAsset { extension PHAsset {
var platformPlaybackStyle: PlatformAssetPlaybackStyle {
switch playbackStyle {
case .image: return .image
case .imageAnimated: return .imageAnimated
case .livePhoto: return .livePhoto
case .video: return .video
case .videoLooping: return .videoLooping
@unknown default: return .unknown
}
}
func toPlatformAsset() -> PlatformAsset { func toPlatformAsset() -> PlatformAsset {
return PlatformAsset( return PlatformAsset(
id: localIdentifier, id: localIdentifier,
@@ -26,8 +15,7 @@ extension PHAsset {
isFavorite: isFavorite, isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp, adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude, latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude, longitude: location?.coordinate.longitude
playbackStyle: platformPlaybackStyle
) )
} }
@@ -38,7 +26,7 @@ extension PHAsset {
var filename: String? { var filename: String? {
return value(forKey: "filename") as? String return value(forKey: "filename") as? String
} }
var adjustmentTimestamp: Int64? { var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date { if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970) return Int64(date.timeIntervalSince1970)
@@ -24,8 +24,6 @@ abstract class ImageRequest {
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}); Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
Future<ui.Codec?> loadCodec();
void cancel() { void cancel() {
if (_isCancelled) { if (_isCancelled) {
return; return;
@@ -36,7 +34,7 @@ abstract class ImageRequest {
void _onCancelled(); void _onCancelled();
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async { Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final pointer = Pointer<Uint8>.fromAddress(address); final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) { if (_isCancelled) {
malloc.free(pointer); malloc.free(pointer);
@@ -69,20 +67,6 @@ abstract class ImageRequest {
return null; return null;
} }
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final result = await _codecFromEncodedPlatformImage(address, length);
if (result == null) return null;
final (codec, descriptor) = result;
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame(); final frame = await codec.getNextFrame();
descriptor.dispose(); descriptor.dispose();
codec.dispose(); codec.dispose();
@@ -22,7 +22,6 @@ class LocalImageRequest extends ImageRequest {
width: width, width: width,
height: height, height: height,
isVideo: assetType == AssetType.video, isVideo: assetType == AssetType.video,
preferEncoded: false,
); );
if (info == null) { if (info == null) {
return null; return null;
@@ -32,26 +31,6 @@ class LocalImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@override
Future<ui.Codec?> loadCodec() async {
if (_isCancelled) {
return null;
}
final info = await localImageApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
preferEncoded: true,
);
if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
return codec;
}
@override @override
Future<void> _onCancelled() { Future<void> _onCancelled() {
return localImageApi.cancelRequest(requestId); return localImageApi.cancelRequest(requestId);
@@ -12,8 +12,7 @@ class RemoteImageRequest extends ImageRequest {
return null; return null;
} }
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false); final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
// Android always returns encoded data, so we need to check for both shapes of the response.
final frame = switch (info) { final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length), {'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} => {'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
@@ -23,19 +22,6 @@ class RemoteImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@override
Future<ui.Codec?> loadCodec() async {
if (_isCancelled) {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
if (info == null) return null;
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
return codec;
}
@override @override
Future<void> _onCancelled() { Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId); return remoteImageApi.cancelRequest(requestId);
@@ -16,9 +16,6 @@ class ThumbhashImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale); return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
} }
@override
Future<ui.Codec?> loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading');
@override @override
void _onCancelled() {} void _onCancelled() {}
} }
+20 -25
View File
@@ -20,7 +20,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart'; import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.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/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
@@ -50,34 +49,30 @@ import 'package:logging/logging.dart';
import 'package:timezone/data/latest.dart'; import 'package:timezone/data/latest.dart';
void main() async { void main() async {
try { ImmichWidgetsBinding();
ImmichWidgetsBinding(); unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock());
unawaited(BackgroundWorkerLockService(BackgroundWorkerLockApi()).lock()); final (isar, drift, logDb) = await Bootstrap.initDB();
await EasyLocalization.ensureInitialized(); await Bootstrap.initDomain(isar, drift, logDb);
final (isar, drift, logDb) = await Bootstrap.initDB(); await initApp();
await Bootstrap.initDomain(isar, drift, logDb); // Warm-up isolate pool for worker manager
await initApp(); await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
// Warm-up isolate pool for worker manager await migrateDatabaseIfNeeded(isar, drift);
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5)); HttpSSLOptions.apply();
await migrateDatabaseIfNeeded(isar, drift);
HttpSSLOptions.apply();
runApp( runApp(
ProviderScope( ProviderScope(
overrides: [ overrides: [
dbProvider.overrideWithValue(isar), dbProvider.overrideWithValue(isar),
isarProvider.overrideWithValue(isar), isarProvider.overrideWithValue(isar),
driftProvider.overrideWith(driftOverride(drift)), driftProvider.overrideWith(driftOverride(drift)),
], ],
child: const MainWidget(), child: const MainWidget(),
), ),
); );
} catch (error, stack) {
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
}
} }
Future<void> initApp() async { Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
await initializeDateFormatting(); await initializeDateFormatting();
if (Platform.isAndroid) { if (Platform.isAndroid) {
@@ -1,17 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.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/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart';
@@ -20,254 +13,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.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: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() @RoutePage()
class SplashScreenPage extends StatefulHookConsumerWidget { class SplashScreenPage extends StatefulHookConsumerWidget {
-3
View File
@@ -90,14 +90,11 @@ class SearchPage extends HookConsumerWidget {
} }
loadMoreSearchResult() async { loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) { if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.tr())); context.showSnackBar(searchInfoSnackBar('search_no_more_result'.tr()));
} }
isSearching.value = false;
} }
searchPrefilter() { searchPrefilter() {
-2
View File
@@ -55,7 +55,6 @@ class LocalImageApi {
required int width, required int width,
required int height, required int height,
required bool isVideo, required bool isVideo,
required bool preferEncoded,
}) async { }) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -70,7 +69,6 @@ class LocalImageApi {
width, width,
height, height,
isVideo, isVideo,
preferEncoded,
]); ]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
+9 -22
View File
@@ -29,8 +29,6 @@ bool _deepEquals(Object? a, Object? b) {
return a == b; return a == b;
} }
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
class PlatformAsset { class PlatformAsset {
PlatformAsset({ PlatformAsset({
required this.id, required this.id,
@@ -46,7 +44,6 @@ class PlatformAsset {
this.adjustmentTime, this.adjustmentTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
required this.playbackStyle,
}); });
String id; String id;
@@ -75,8 +72,6 @@ class PlatformAsset {
double? longitude; double? longitude;
PlatformAssetPlaybackStyle playbackStyle;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[ return <Object?>[
id, id,
@@ -92,7 +87,6 @@ class PlatformAsset {
adjustmentTime, adjustmentTime,
latitude, latitude,
longitude, longitude,
playbackStyle,
]; ];
} }
@@ -116,7 +110,6 @@ class PlatformAsset {
adjustmentTime: result[10] as int?, adjustmentTime: result[10] as int?,
latitude: result[11] as double?, latitude: result[11] as double?,
longitude: result[12] as double?, longitude: result[12] as double?,
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
); );
} }
@@ -323,23 +316,20 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) { if (value is int) {
buffer.putUint8(4); buffer.putUint8(4);
buffer.putInt64(value); buffer.putInt64(value);
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) { } else if (value is PlatformAsset) {
buffer.putUint8(130); buffer.putUint8(129);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) { } else if (value is PlatformAlbum) {
buffer.putUint8(131); buffer.putUint8(130);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is SyncDelta) { } else if (value is SyncDelta) {
buffer.putUint8(132); buffer.putUint8(131);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is HashResult) { } else if (value is HashResult) {
buffer.putUint8(133); buffer.putUint8(132);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else if (value is CloudIdResult) { } else if (value is CloudIdResult) {
buffer.putUint8(134); buffer.putUint8(133);
writeValue(buffer, value.encode()); writeValue(buffer, value.encode());
} else { } else {
super.writeValue(buffer, value); super.writeValue(buffer, value);
@@ -350,17 +340,14 @@ class _PigeonCodec extends StandardMessageCodec {
Object? readValueOfType(int type, ReadBuffer buffer) { Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) { switch (type) {
case 129: case 129:
final int? value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
return PlatformAsset.decode(readValue(buffer)!); return PlatformAsset.decode(readValue(buffer)!);
case 131: case 130:
return PlatformAlbum.decode(readValue(buffer)!); return PlatformAlbum.decode(readValue(buffer)!);
case 132: case 131:
return SyncDelta.decode(readValue(buffer)!); return SyncDelta.decode(readValue(buffer)!);
case 133: case 132:
return HashResult.decode(readValue(buffer)!); return HashResult.decode(readValue(buffer)!);
case 134: case 133:
return CloudIdResult.decode(readValue(buffer)!); return CloudIdResult.decode(readValue(buffer)!);
default: default:
return super.readValueOfType(type, buffer); return super.readValueOfType(type, buffer);
+1 -7
View File
@@ -53,7 +53,6 @@ class RemoteImageApi {
String url, { String url, {
required Map<String, String> headers, required Map<String, String> headers,
required int requestId, required int requestId,
required bool preferEncoded,
}) async { }) async {
final String pigeonVar_channelName = final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -62,12 +61,7 @@ class RemoteImageApi {
pigeonChannelCodec, pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[ final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
url,
headers,
requestId,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?; final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName); throw _createConnectionError(pigeonVar_channelName);
@@ -9,7 +9,6 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -116,15 +115,17 @@ class DriftSearchPage extends HookConsumerWidget {
search() => searchFilter(filter.value); search() => searchFilter(filter.value);
final isLoadingMore = useState(false);
loadMoreSearchResult() async { loadMoreSearchResult() async {
isSearching.value = true; if (isLoadingMore.value) return;
isLoadingMore.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value); final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
isLoadingMore.value = false;
if (!hasResult) { if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context))); context.showSnackBar(searchInfoSnackBar('search_no_more_result'.t(context: context)));
} }
isSearching.value = false;
} }
searchPreFilter() { searchPreFilter() {
@@ -745,7 +746,7 @@ class DriftSearchPage extends HookConsumerWidget {
if (isSearching.value) if (isSearching.value)
const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator())) const SliverFillRemaining(hasScrollBody: false, child: Center(child: CircularProgressIndicator()))
else else
_SearchResultGrid(onScrollEnd: loadMoreSearchResult), _SearchResultGrid(onScrollEnd: loadMoreSearchResult, isLoadingMore: isLoadingMore.value),
], ],
), ),
); );
@@ -754,28 +755,30 @@ class DriftSearchPage extends HookConsumerWidget {
class _SearchResultGrid extends ConsumerWidget { class _SearchResultGrid extends ConsumerWidget {
final VoidCallback onScrollEnd; final VoidCallback onScrollEnd;
final bool isLoadingMore;
const _SearchResultGrid({required this.onScrollEnd}); const _SearchResultGrid({required this.onScrollEnd, this.isLoadingMore = false});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(paginatedSearchProvider.select((s) => s.assets)); final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty));
if (assets.isEmpty) { if (!hasAssets) {
return const _SearchEmptyContent(); return const _SearchEmptyContent();
} }
return NotificationListener<ScrollEndNotification>( return NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) { onNotification: (notification) {
final isBottomSheetNotification = final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null; notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics; final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical; final isVerticalScroll = metrics.axis == Axis.vertical;
final remaining = metrics.maxScrollExtent - metrics.pixels;
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) { if (remaining < metrics.viewportDimension && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd(); onScrollEnd();
ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.maxScrollExtent); ref.read(paginatedSearchProvider.notifier).setScrollOffset(metrics.pixels);
} }
return true; return true;
@@ -784,18 +787,29 @@ class _SearchResultGrid extends ConsumerWidget {
child: ProviderScope( child: ProviderScope(
overrides: [ overrides: [
timelineServiceProvider.overrideWith((ref) { timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets, TimelineOrigin.search); return ref.watch(paginatedSearchTimelineProvider);
ref.onDispose(timelineService.dispose);
return timelineService;
}), }),
], ],
child: Timeline( child: Timeline(
key: ValueKey(assets.length),
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false, snapToMonth: false,
initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)), initialScrollOffset: ref.read(paginatedSearchProvider.select((s) => s.scrollOffset)),
bottomSliverWidget: isLoadingMore
? const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 3),
),
),
),
)
: null,
), ),
), ),
), ),
@@ -1,6 +1,11 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart'; import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
@@ -58,3 +63,49 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0); state = const SearchResult(assets: [], nextPage: 1, scrollOffset: 0.0);
} }
} }
/// A [TimelineService] for search results that stays alive across pagination.
/// Instead of recreating the service on each page load, it uses a long-lived
/// bucket stream that emits new buckets whenever the search assets change.
final paginatedSearchTimelineProvider = Provider.autoDispose<TimelineService>((ref) {
final controller = StreamController<List<Bucket>>.broadcast();
List<Bucket> generateBuckets(int count) {
if (count == 0) return [];
final buckets = List.filled(
(count / kTimelineNoneSegmentSize).ceil(),
const Bucket(assetCount: kTimelineNoneSegmentSize),
);
if (count % kTimelineNoneSegmentSize != 0) {
buckets[buckets.length - 1] = Bucket(assetCount: count % kTimelineNoneSegmentSize);
}
return buckets;
}
// Emit new buckets whenever asset count changes
ref.listen(paginatedSearchProvider.select((s) => s.assets.length), (_, count) {
controller.add(generateBuckets(count));
});
// Each subscriber gets the current bucket state then follows updates
Stream<List<Bucket>> bucketSource() async* {
yield generateBuckets(ref.read(paginatedSearchProvider).assets.length);
yield* controller.stream;
}
final service = TimelineService((
bucketSource: bucketSource,
assetSource: (offset, count) {
final assets = ref.read(paginatedSearchProvider).assets;
return Future.value(assets.skip(offset).take(count).toList(growable: false));
},
origin: TimelineOrigin.search,
));
ref.onDispose(() {
controller.close();
service.dispose();
});
return service;
});
@@ -8,7 +8,6 @@ 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/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior: /// This delete action has the following behavior:
@@ -26,15 +25,6 @@ class DeletePermanentActionButton extends ConsumerWidget {
return; 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); final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset(); 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/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.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/asset_grid/trash_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior: /// This delete action has the following behavior:
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
final confirmDelete = final confirmDelete =
await showDialog<bool>( await showDialog<bool>(
context: context, context: context,
builder: (context) => PermanentDeleteDialog(count: selectCount), builder: (context) => TrashDeleteDialog(count: selectCount),
) ?? ) ??
false; false;
if (!confirmDelete) { if (!confirmDelete) {
@@ -64,6 +64,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_proxyScrollController.addListener(_onScroll);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_proxyScrollController.hasClients) return; if (!mounted || !_proxyScrollController.hasClients) return;
@@ -93,7 +94,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
void _showDetails() { void _showDetails() {
if (!_proxyScrollController.hasClients || _snapOffset <= 0) return; if (!_proxyScrollController.hasClients || _snapOffset <= 0) return;
_viewer.setShowingDetails(true);
_proxyScrollController.animateTo(_snapOffset, duration: Durations.medium2, curve: Curves.easeOutCubic); _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; SnapScrollPhysics.target(position, scrollVelocity, _snapOffset) < SnapScrollPhysics.minSnapDistance;
} }
void _syncShowingDetails() { void _onScroll() {
final offset = _proxyScrollController.offset; final offset = _proxyScrollController.offset;
if (offset > SnapScrollPhysics.minSnapDistance) { if (offset > SnapScrollPhysics.minSnapDistance) {
_viewer.setShowingDetails(true); _viewer.setShowingDetails(true);
@@ -149,8 +149,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.scroll: case _DragIntent.scroll:
if (_drag == null) _startProxyDrag(); if (_drag == null) _startProxyDrag();
_drag?.update(details); _drag?.update(details);
_syncShowingDetails();
case _DragIntent.dismiss: case _DragIntent.dismiss:
_handleDragDown(context, details.localPosition - _dragStart!.localPosition); _handleDragDown(context, details.localPosition - _dragStart!.localPosition);
} }
@@ -169,8 +167,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
case _DragIntent.none: case _DragIntent.none:
case _DragIntent.scroll: case _DragIntent.scroll:
final scrollVelocity = -(details.primaryVelocity ?? 0.0); final scrollVelocity = -(details.primaryVelocity ?? 0.0);
_viewer.setShowingDetails(!_willClose(scrollVelocity)); if (_willClose(scrollVelocity)) {
_viewer.setShowingDetails(false);
}
_drag?.end(details); _drag?.end(details);
_drag = null; _drag = null;
case _DragIntent.dismiss: case _DragIntent.dismiss:
@@ -307,7 +306,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
if (displayAsset.isImage && !isPlayingMotionVideo) { if (displayAsset.isImage && !isPlayingMotionVideo) {
final size = context.sizeData; final size = context.sizeData;
return PhotoView( return PhotoView(
key: Key(displayAsset.heroTag), key: ValueKey(displayAsset.heroTag),
index: widget.index, index: widget.index,
imageProvider: getFullImageProvider(displayAsset, size: size), imageProvider: getFullImageProvider(displayAsset, size: size),
heroAttributes: heroAttributes, heroAttributes: heroAttributes,
@@ -335,7 +334,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
} }
return PhotoView.customChild( return PhotoView.customChild(
key: Key(displayAsset.heroTag), key: ValueKey(displayAsset),
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
@@ -351,11 +350,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
enablePanAlways: true, enablePanAlways: true,
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
child: NativeVideoViewer( child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag), key: ValueKey(displayAsset),
asset: displayAsset, asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier, scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails, disableScaleGestures: showingDetails,
image: Image( image: Image(
key: ValueKey(displayAsset.heroTag),
image: getFullImageProvider(displayAsset, size: context.sizeData), image: getFullImageProvider(displayAsset, size: context.sizeData),
height: context.height, height: context.height,
width: context.width, width: context.width,
@@ -459,25 +459,3 @@ class _AssetPageState extends ConsumerState<AssetPage> {
); );
} }
} }
// A global key is used for video viewers to prevent them from being
// unnecessarily recreated. They're quite expensive, and maintain internal
// state. This can cause videos to restart multiple times during normal usage,
// like a hero animation.
//
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
// rather than equal. Hero tags are created with string interpolation, which
// prevents Dart from interning them. As such, hero tags are not identical, even
// if they are equal.
class _NativeVideoViewerKey extends GlobalKey {
final String value;
const _NativeVideoViewerKey(this.value) : super.constructor();
@override
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
@override
int get hashCode => value.hashCode;
}
@@ -117,9 +117,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription = EventStream.shared.listen(_onEvent); _reloadSubscription = EventStream.shared.listen(_onEvent);
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
final assetViewer = ref.read(assetViewerProvider);
_setSystemUIMode(assetViewer.showingControls, assetViewer.showingDetails);
} }
@override @override
@@ -229,13 +226,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onAssetChanged(index); _onAssetChanged(index);
} }
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
@@ -255,7 +245,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) { ref.listen(assetViewerProvider.select((value) => (value.showingControls, value.showingDetails)), (_, state) {
final (controls, details) = state; final (controls, details) = state;
_setSystemUIMode(controls, details); final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}); });
return PopScope( return PopScope(
@@ -420,18 +420,20 @@ class NativeVideoViewer extends HookConsumerWidget {
child: Stack( child: Stack(
children: [ children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. // Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(child: image), if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting && isCurrent) if (aspectRatio.value != null && !isCasting && isCurrent)
Visibility.maintain( Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value, visible: isVisible.value,
child: PhotoView.customChild( child: PhotoView.customChild(
key: ValueKey(asset),
enableRotation: false, enableRotation: false,
disableScaleGestures: disableScaleGestures, disableScaleGestures: disableScaleGestures,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet. // Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
backgroundDecoration: const BoxDecoration(color: Colors.transparent), backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state, scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context), childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController), child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
), ),
), ),
if (showControls) const Center(child: VideoViewerControls()), if (showControls) const Center(child: VideoViewerControls()),
@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline), const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[ if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline), const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline), const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable isTrashEnable
? const TrashActionButton(source: ActionSource.timeline) ? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline), : const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -119,11 +119,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline), const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline), if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline), if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline), if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
], ],
if (multiselect.onlyLocal || multiselect.hasMerged) if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
const DeleteLocalActionButton(source: ActionSource.timeline), if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
], ],
slivers: multiselect.hasRemote slivers: multiselect.hasRemote
? [ ? [
@@ -1,5 +1,3 @@
import 'dart:ui' as ui;
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -50,7 +48,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
return null; return null;
} }
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* { Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
if (isCancelled) { if (isCancelled) {
this.request = null; this.request = null;
PaintingBinding.instance.imageCache.evict(this); PaintingBinding.instance.imageCache.evict(this);
@@ -59,39 +57,11 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
try { try {
final image = await request.load(decode); final image = await request.load(decode);
if ((image == null && evictOnError) || isCancelled) { if (image == null || isCancelled) {
PaintingBinding.instance.imageCache.evict(this); PaintingBinding.instance.imageCache.evict(this);
return; return;
} else if (image == null) {
return;
} }
yield image; yield image;
} catch (e, stack) {
if (evictOnError) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
}
_log.warning('Non-fatal image load error', e, stack);
} finally {
this.request = null;
}
}
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
return null;
}
try {
final codec = await request.loadCodec();
if (codec == null || isCancelled) {
codec?.dispose();
PaintingBinding.instance.imageCache.evict(this);
return null;
}
return codec;
} catch (e) { } catch (e) {
PaintingBinding.instance.imageCache.evict(this); PaintingBinding.instance.imageCache.evict(this);
rethrow; rethrow;
@@ -94,6 +94,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType, assetType: key.assetType,
); );
yield* loadRequest(request, decode); yield* loadRequest(request, decode);
if (!Store.get(StoreKey.loadOriginal, false)) { if (!Store.get(StoreKey.loadOriginal, false)) {
@@ -93,10 +93,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash), uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers, headers: headers,
); );
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal); yield* loadRequest(previewRequest, decode);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
if (!loadOriginal) { if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
return; return;
} }
@@ -25,7 +25,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedDate = widget.person.birthDate ?? DateTime(DateTime.now().year - 30, 1, 1); _selectedDate = widget.person.birthDate ?? DateTime.now();
} }
void saveBirthday() async { void saveBirthday() async {
@@ -90,7 +90,6 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
selectedDate: _selectedDate, selectedDate: _selectedDate,
locale: context.locale, locale: context.locale,
minimumDate: DateTime(1800, 1, 1), minimumDate: DateTime(1800, 1, 1),
maximumDate: DateTime.now(),
onDateTimeChanged: (DateTime value) { onDateTimeChanged: (DateTime value) {
setState(() { setState(() {
_selectedDate = value; _selectedDate = value;
@@ -34,6 +34,7 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.showStorageIndicator = false, this.showStorageIndicator = false,
this.withStack = false, this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false), this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
@@ -48,6 +49,7 @@ class Timeline extends StatelessWidget {
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final bool showStorageIndicator; final bool showStorageIndicator;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
@@ -82,6 +84,7 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
@@ -111,6 +114,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.bottomSliverWidget,
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
@@ -122,6 +126,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? bottomSliverWidget;
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
@@ -408,6 +413,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
addRepaintBoundaries: false, addRepaintBoundaries: false,
), ),
), ),
if (widget.bottomSliverWidget != null) widget.bottomSliverWidget!,
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)), SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
], ],
); );
@@ -24,12 +24,10 @@ class MultiSelectState {
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null); bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged); bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
MultiSelectState copyWith({ MultiSelectState copyWith({
Set<BaseAsset>? selectedAssets, Set<BaseAsset>? selectedAssets,
Set<BaseAsset>? lockedSelectionAssets, Set<BaseAsset>? lockedSelectionAssets,
@@ -26,6 +26,7 @@ import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart'; import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -405,10 +406,10 @@ class MultiselectGrid extends HookConsumerWidget {
bottom: false, bottom: false,
child: Stack( child: Stack(
children: [ children: [
ref ref.watch(renderListProvider).widgetWhen(
.watch(renderListProvider) onLoading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
.when( onError: (error, _) => Center(child: Text(error.toString())),
data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) onData: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null)
? (buildLoadingIndicator ?? buildEmptyIndicator)() ? (buildLoadingIndicator ?? buildEmptyIndicator)()
: ImmichAssetGrid( : ImmichAssetGrid(
renderList: data, renderList: data,
@@ -419,8 +420,6 @@ class MultiselectGrid extends HookConsumerWidget {
showStack: stackEnabled, showStack: stackEnabled,
showDragScrollLabel: dragScrollLabelEnabled, showDragScrollLabel: dragScrollLabelEnabled,
), ),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
), ),
if (selectionEnabledHook.value) if (selectionEnabledHook.value)
ControlBottomAppBar( ControlBottomAppBar(
@@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_ui/immich_ui.dart'; import 'package:immich_ui/immich_ui.dart';
class PermanentDeleteDialog extends StatelessWidget { class TrashDeleteDialog extends StatelessWidget {
const PermanentDeleteDialog({super.key, required this.count}); const TrashDeleteDialog({super.key, required this.count});
final int count; final int count;
@@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
pinned: pinned, pinned: pinned,
snap: snap, snap: snap,
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))), shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
centerTitle: false, centerTitle: false,
title: title ?? const _ImmichLogoWithText(), title: title ?? const _ImmichLogoWithText(),
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -6,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_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/app_settings.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -89,27 +87,25 @@ class SyncStatusAndActions extends HookConsumerWidget {
context: context, context: context,
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: Text(context.t.reset_sqlite), title: Text("reset_sqlite".t(context: context)),
content: Text(context.t.reset_sqlite_confirmation), content: Text("reset_sqlite_confirmation".t(context: context)),
actions: [ actions: [
TextButton(onPressed: () => context.pop(), child: Text(context.t.cancel)), TextButton(
onPressed: () => context.pop(),
child: Text("cancel".t(context: context)),
),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ref.read(driftProvider).reset(); await ref.read(driftProvider).reset();
context.pop(); context.pop();
unawaited( context.scaffoldMessenger.showSnackBar(
showDialog<void>( SnackBar(content: Text("reset_sqlite_success".t(context: context))),
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(context.t.confirm, style: TextStyle(color: context.colorScheme.error)), child: Text(
"confirm".t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
), ),
], ],
); );
-2
View File
@@ -576,8 +576,6 @@ Class | Method | HTTP request | Description
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md) - [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md) - [SyncAlbumV1](doc//SyncAlbumV1.md)
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
- [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
- [SyncAssetEditV1](doc//SyncAssetEditV1.md)
- [SyncAssetExifV1](doc//SyncAssetExifV1.md) - [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
-2
View File
@@ -315,8 +315,6 @@ part 'model/sync_album_user_delete_v1.dart';
part 'model/sync_album_user_v1.dart'; part 'model/sync_album_user_v1.dart';
part 'model/sync_album_v1.dart'; part 'model/sync_album_v1.dart';
part 'model/sync_asset_delete_v1.dart'; part 'model/sync_asset_delete_v1.dart';
part 'model/sync_asset_edit_delete_v1.dart';
part 'model/sync_asset_edit_v1.dart';
part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_exif_v1.dart';
part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_delete_v1.dart';
part 'model/sync_asset_face_v1.dart'; part 'model/sync_asset_face_v1.dart';
+2 -2
View File
@@ -410,7 +410,7 @@ class SearchApi {
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [num] rating: /// * [num] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating
/// ///
/// * [num] size: /// * [num] size:
/// Number of results to return /// Number of results to return
@@ -633,7 +633,7 @@ class SearchApi {
/// Filter by person IDs /// Filter by person IDs
/// ///
/// * [num] rating: /// * [num] rating:
/// Filter by rating [1-5], or null for unrated /// Filter by rating
/// ///
/// * [num] size: /// * [num] size:
/// Number of results to return /// Number of results to return
+6 -24
View File
@@ -30,9 +30,6 @@ class TimelineApi {
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album /// Filter assets belonging to a specific album
/// ///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only) /// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
@@ -66,7 +63,7 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket'; final apiPath = r'/timeline/bucket';
@@ -80,9 +77,6 @@ class TimelineApi {
if (albumId != null) { if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId)); queryParams.addAll(_queryParams('', 'albumId', albumId));
} }
if (bbox != null) {
queryParams.addAll(_queryParams('', 'bbox', bbox));
}
if (isFavorite != null) { if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
} }
@@ -147,9 +141,6 @@ class TimelineApi {
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album /// Filter assets belonging to a specific album
/// ///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only) /// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
@@ -183,8 +174,8 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { 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, 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, ); final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@@ -209,9 +200,6 @@ class TimelineApi {
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album /// Filter assets belonging to a specific album
/// ///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only) /// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
@@ -245,7 +233,7 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets'; final apiPath = r'/timeline/buckets';
@@ -259,9 +247,6 @@ class TimelineApi {
if (albumId != null) { if (albumId != null) {
queryParams.addAll(_queryParams('', 'albumId', albumId)); queryParams.addAll(_queryParams('', 'albumId', albumId));
} }
if (bbox != null) {
queryParams.addAll(_queryParams('', 'bbox', bbox));
}
if (isFavorite != null) { if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
} }
@@ -322,9 +307,6 @@ class TimelineApi {
/// * [String] albumId: /// * [String] albumId:
/// Filter assets belonging to a specific album /// Filter assets belonging to a specific album
/// ///
/// * [String] bbox:
/// Bounding box coordinates as west,south,east,north (WGS84)
///
/// * [bool] isFavorite: /// * [bool] isFavorite:
/// Filter by favorite status (true for favorites only, false for non-favorites only) /// Filter by favorite status (true for favorites only, false for non-favorites only)
/// ///
@@ -358,8 +340,8 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { 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, 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, ); final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
-4
View File
@@ -676,10 +676,6 @@ class ApiClient {
return SyncAlbumV1.fromJson(value); return SyncAlbumV1.fromJson(value);
case 'SyncAssetDeleteV1': case 'SyncAssetDeleteV1':
return SyncAssetDeleteV1.fromJson(value); return SyncAssetDeleteV1.fromJson(value);
case 'SyncAssetEditDeleteV1':
return SyncAssetEditDeleteV1.fromJson(value);
case 'SyncAssetEditV1':
return SyncAssetEditV1.fromJson(value);
case 'SyncAssetExifV1': case 'SyncAssetExifV1':
return SyncAssetExifV1.fromJson(value); return SyncAssetExifV1.fromJson(value);
case 'SyncAssetFaceDeleteV1': case 'SyncAssetFaceDeleteV1':
+8 -4
View File
@@ -86,10 +86,16 @@ class AssetBulkUpdateDto {
/// ///
num? longitude; num? longitude;
/// Rating in range [1-5], or null for unrated /// Rating
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating; num? rating;
/// Time zone (IANA timezone) /// Time zone (IANA timezone)
@@ -217,9 +223,7 @@ class AssetBulkUpdateDto {
isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
rating: json[r'rating'] == null rating: num.parse('${json[r'rating']}'),
? null
: num.parse('${json[r'rating']}'),
timeZone: mapValueOfType<String>(json, r'timeZone'), timeZone: mapValueOfType<String>(json, r'timeZone'),
visibility: AssetVisibility.fromJson(json[r'visibility']), visibility: AssetVisibility.fromJson(json[r'visibility']),
); );
+8 -4
View File
@@ -256,10 +256,16 @@ class MetadataSearchDto {
/// ///
String? previewPath; String? previewPath;
/// Filter by rating [1-5], or null for unrated /// Filter by rating
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating; num? rating;
/// Number of results to return /// Number of results to return
@@ -748,9 +754,7 @@ class MetadataSearchDto {
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
previewPath: mapValueOfType<String>(json, r'previewPath'), previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: json[r'rating'] == null rating: num.parse('${json[r'rating']}'),
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
+8 -4
View File
@@ -159,10 +159,16 @@ class RandomSearchDto {
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
/// Filter by rating [1-5], or null for unrated /// Filter by rating
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating; num? rating;
/// Number of results to return /// Number of results to return
@@ -559,9 +565,7 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: json[r'rating'] == null rating: num.parse('${json[r'rating']}'),
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
+8 -4
View File
@@ -199,10 +199,16 @@ class SmartSearchDto {
/// ///
String? queryAssetId; String? queryAssetId;
/// Filter by rating [1-5], or null for unrated /// Filter by rating
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating; num? rating;
/// Number of results to return /// Number of results to return
@@ -599,9 +605,7 @@ class SmartSearchDto {
: const [], : const [],
query: mapValueOfType<String>(json, r'query'), query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'), queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: json[r'rating'] == null rating: num.parse('${json[r'rating']}'),
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'), size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
+8 -4
View File
@@ -164,10 +164,16 @@ class StatisticsSearchDto {
/// Filter by person IDs /// Filter by person IDs
List<String> personIds; List<String> personIds;
/// Filter by rating [1-5], or null for unrated /// Filter by rating
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating; num? rating;
/// Filter by state/province name /// Filter by state/province name
@@ -489,9 +495,7 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
rating: json[r'rating'] == null rating: num.parse('${json[r'rating']}'),
? null
: num.parse('${json[r'rating']}'),
state: mapValueOfType<String>(json, r'state'), state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false) ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
-99
View File
@@ -1,99 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetEditDeleteV1 {
/// Returns a new [SyncAssetEditDeleteV1] instance.
SyncAssetEditDeleteV1({
required this.editId,
});
String editId;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditDeleteV1 &&
other.editId == editId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(editId.hashCode);
@override
String toString() => 'SyncAssetEditDeleteV1[editId=$editId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'editId'] = this.editId;
return json;
}
/// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetEditDeleteV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetEditDeleteV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetEditDeleteV1(
editId: mapValueOfType<String>(json, r'editId')!,
);
}
return null;
}
static List<SyncAssetEditDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditDeleteV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetEditDeleteV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetEditDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetEditDeleteV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetEditDeleteV1-objects as value to a dart map
static Map<String, List<SyncAssetEditDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'editId',
};
}
-131
View File
@@ -1,131 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetEditV1 {
/// Returns a new [SyncAssetEditV1] instance.
SyncAssetEditV1({
required this.action,
required this.assetId,
required this.id,
required this.parameters,
required this.sequence,
});
AssetEditAction action;
String assetId;
String id;
Object parameters;
int sequence;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditV1 &&
other.action == action &&
other.assetId == assetId &&
other.id == id &&
other.parameters == parameters &&
other.sequence == sequence;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(assetId.hashCode) +
(id.hashCode) +
(parameters.hashCode) +
(sequence.hashCode);
@override
String toString() => 'SyncAssetEditV1[action=$action, assetId=$assetId, id=$id, parameters=$parameters, sequence=$sequence]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'assetId'] = this.assetId;
json[r'id'] = this.id;
json[r'parameters'] = this.parameters;
json[r'sequence'] = this.sequence;
return json;
}
/// Returns a new [SyncAssetEditV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetEditV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetEditV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetEditV1(
action: AssetEditAction.fromJson(json[r'action'])!,
assetId: mapValueOfType<String>(json, r'assetId')!,
id: mapValueOfType<String>(json, r'id')!,
parameters: mapValueOfType<Object>(json, r'parameters')!,
sequence: mapValueOfType<int>(json, r'sequence')!,
);
}
return null;
}
static List<SyncAssetEditV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetEditV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetEditV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetEditV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetEditV1-objects as value to a dart map
static Map<String, List<SyncAssetEditV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetEditV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'assetId',
'id',
'parameters',
'sequence',
};
}
-6
View File
@@ -29,8 +29,6 @@ class SyncEntityType {
static const assetV1 = SyncEntityType._(r'AssetV1'); static const assetV1 = SyncEntityType._(r'AssetV1');
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1'); static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1'); static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1'); static const partnerV1 = SyncEntityType._(r'PartnerV1');
@@ -82,8 +80,6 @@ class SyncEntityType {
assetV1, assetV1,
assetDeleteV1, assetDeleteV1,
assetExifV1, assetExifV1,
assetEditV1,
assetEditDeleteV1,
assetMetadataV1, assetMetadataV1,
assetMetadataDeleteV1, assetMetadataDeleteV1,
partnerV1, partnerV1,
@@ -170,8 +166,6 @@ class SyncEntityTypeTypeTransformer {
case r'AssetV1': return SyncEntityType.assetV1; case r'AssetV1': return SyncEntityType.assetV1;
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
case r'AssetExifV1': return SyncEntityType.assetExifV1; case r'AssetExifV1': return SyncEntityType.assetExifV1;
case r'AssetEditV1': return SyncEntityType.assetEditV1;
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1; case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1; case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
case r'PartnerV1': return SyncEntityType.partnerV1; case r'PartnerV1': return SyncEntityType.partnerV1;
-3
View File
@@ -30,7 +30,6 @@ class SyncRequestType {
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
static const assetsV1 = SyncRequestType._(r'AssetsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1'); static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1'); static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1'); static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
@@ -55,7 +54,6 @@ class SyncRequestType {
albumAssetExifsV1, albumAssetExifsV1,
assetsV1, assetsV1,
assetExifsV1, assetExifsV1,
assetEditsV1,
assetMetadataV1, assetMetadataV1,
authUsersV1, authUsersV1,
memoriesV1, memoriesV1,
@@ -115,7 +113,6 @@ class SyncRequestTypeTypeTransformer {
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
case r'AssetsV1': return SyncRequestType.assetsV1; case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1; case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1; case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1; case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1; case r'MemoriesV1': return SyncRequestType.memoriesV1;
+8 -4
View File
@@ -71,10 +71,16 @@ class UpdateAssetDto {
/// ///
num? longitude; num? longitude;
/// Rating in range [1-5], or null for unrated /// Rating
/// ///
/// Minimum value: -1 /// Minimum value: -1
/// Maximum value: 5 /// Maximum value: 5
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? rating; num? rating;
/// Asset visibility /// Asset visibility
@@ -172,9 +178,7 @@ class UpdateAssetDto {
latitude: num.parse('${json[r'latitude']}'), latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'), livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'), longitude: num.parse('${json[r'longitude']}'),
rating: json[r'rating'] == null rating: num.parse('${json[r'rating']}'),
? null
: num.parse('${json[r'rating']}'),
visibility: AssetVisibility.fromJson(json[r'visibility']), visibility: AssetVisibility.fromJson(json[r'visibility']),
); );
} }
-1
View File
@@ -21,7 +21,6 @@ abstract class LocalImageApi {
required int width, required int width,
required int height, required int height,
required bool isVideo, required bool isVideo,
required bool preferEncoded,
}); });
void cancelRequest(int requestId); void cancelRequest(int requestId);
-12
View File
@@ -11,15 +11,6 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile', dartPackageName: 'immich_mobile',
), ),
) )
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset { class PlatformAsset {
final String id; final String id;
final String name; final String name;
@@ -40,8 +31,6 @@ class PlatformAsset {
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
final PlatformAssetPlaybackStyle playbackStyle;
const PlatformAsset({ const PlatformAsset({
required this.id, required this.id,
required this.name, required this.name,
@@ -56,7 +45,6 @@ class PlatformAsset {
this.adjustmentTime, this.adjustmentTime,
this.latitude, this.latitude,
this.longitude, this.longitude,
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
}); });
} }
-1
View File
@@ -19,7 +19,6 @@ abstract class RemoteImageApi {
String url, { String url, {
required Map<String, String> headers, required Map<String, String> headers,
required int requestId, required int requestId,
required bool preferEncoded,
}); });
void cancelRequest(int requestId); void cancelRequest(int requestId);
@@ -131,7 +131,6 @@ void main() {
durationInSeconds: 0, durationInSeconds: 0,
orientation: 0, orientation: 0,
isFavorite: false, isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image
); );
final assetsToRestore = [LocalAssetStub.image1]; final assetsToRestore = [LocalAssetStub.image1];
@@ -215,7 +214,6 @@ void main() {
isFavorite: false, isFavorite: false,
createdAt: 1700000000, createdAt: 1700000000,
updatedAt: 1732000000, updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image
); );
final localAsset = platformAsset.toLocalAsset(); final localAsset = platformAsset.toLocalAsset();
@@ -1,244 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import '../../medium/repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftBackupRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = DriftBackupRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getAllCounts', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns zeros when no albums exist', () async {
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('returns zeros when no selected albums exist', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('counts asset in selected album as total and remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
expect(result.processing, 0);
});
test('backed up asset reduces remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('asset with null checksum is counted as processing', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
expect(result.processing, 1);
});
test('asset in excluded album is not counted even if also in selected album', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
});
test('counts assets across multiple selected albums without duplicates', () async {
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
// Same asset in two selected albums
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
});
test('backed up asset for different user is still counted as remainder', () async {
final otherUser = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
});
test('mixed assets produce correct combined counts', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// backed up
final remote1 = await ctx.newRemoteAsset(ownerId: userId);
final local1 = await ctx.newLocalAsset(checksum: remote1.checksum);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local1.id);
// not backed up, has checksum
final local2 = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local2.id);
// processing (null checksum)
final local3 = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local3.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 3);
expect(result.remainder, 2); // local2 + local3
expect(result.processing, 1); // local3
});
});
group('getCandidates', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns empty list when no selected albums exist', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('returns asset in selected album that is not backed up', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('excludes asset already backed up for the same user', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes asset backed up for a different user', () async {
final otherUser = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, local.id);
});
test('excludes asset in excluded album even if also in selected album', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('excludes asset with null checksum when onlyHashed is true', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes asset with null checksum when onlyHashed is false', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId, onlyHashed: false);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('returns assets ordered by createdAt descending', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset1 = await ctx.newLocalAsset(createdAt: DateTime(2024, 1, 1));
final asset2 = await ctx.newLocalAsset(createdAt: DateTime(2024, 3, 1));
final asset3 = await ctx.newLocalAsset(createdAt: DateTime(2024, 2, 1));
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset1.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset2.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset3.id);
final result = await sut.getCandidates(userId);
expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]);
});
test('does not return duplicate when asset is in multiple selected albums', () async {
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
});
}
+13 -197
View File
@@ -9407,27 +9407,10 @@
"name": "rating", "name": "rating",
"required": false, "required": false,
"in": "query", "in": "query",
"description": "Filter by rating [1-5], or null for unrated", "description": "Filter by rating",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable",
"schema": { "schema": {
"minimum": -1, "minimum": -1,
"maximum": 5, "maximum": 5,
"nullable": true,
"type": "number" "type": "number"
} }
}, },
@@ -13492,16 +13475,6 @@
"type": "string" "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", "name": "isFavorite",
"required": false, "required": false,
@@ -13678,16 +13651,6 @@
"type": "string" "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", "name": "isFavorite",
"required": false, "required": false,
@@ -15909,27 +15872,10 @@
"type": "number" "type": "number"
}, },
"rating": { "rating": {
"description": "Rating in range [1-5], or null for unrated", "description": "Rating",
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "type": "number"
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
}, },
"timeZone": { "timeZone": {
"description": "Time zone (IANA timezone)", "description": "Time zone (IANA timezone)",
@@ -19042,27 +18988,10 @@
"type": "string" "type": "string"
}, },
"rating": { "rating": {
"description": "Filter by rating [1-5], or null for unrated", "description": "Filter by rating",
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "type": "number"
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
}, },
"size": { "size": {
"description": "Number of results to return", "description": "Number of results to return",
@@ -20785,27 +20714,10 @@
"type": "array" "type": "array"
}, },
"rating": { "rating": {
"description": "Filter by rating [1-5], or null for unrated", "description": "Filter by rating",
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "type": "number"
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
}, },
"size": { "size": {
"description": "Number of results to return", "description": "Number of results to return",
@@ -22176,27 +22088,10 @@
"type": "string" "type": "string"
}, },
"rating": { "rating": {
"description": "Filter by rating [1-5], or null for unrated", "description": "Filter by rating",
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "type": "number"
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
}, },
"size": { "size": {
"description": "Number of results to return", "description": "Number of results to return",
@@ -22427,27 +22322,10 @@
"type": "array" "type": "array"
}, },
"rating": { "rating": {
"description": "Filter by rating [1-5], or null for unrated", "description": "Filter by rating",
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "type": "number"
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
}, },
"state": { "state": {
"description": "Filter by state/province name", "description": "Filter by state/province name",
@@ -22736,48 +22614,6 @@
], ],
"type": "object" "type": "object"
}, },
"SyncAssetEditDeleteV1": {
"properties": {
"editId": {
"type": "string"
}
},
"required": [
"editId"
],
"type": "object"
},
"SyncAssetEditV1": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"assetId": {
"type": "string"
},
"id": {
"type": "string"
},
"parameters": {
"type": "object"
},
"sequence": {
"type": "integer"
}
},
"required": [
"action",
"assetId",
"id",
"parameters",
"sequence"
],
"type": "object"
},
"SyncAssetExifV1": { "SyncAssetExifV1": {
"properties": { "properties": {
"assetId": { "assetId": {
@@ -23327,8 +23163,6 @@
"AssetV1", "AssetV1",
"AssetDeleteV1", "AssetDeleteV1",
"AssetExifV1", "AssetExifV1",
"AssetEditV1",
"AssetEditDeleteV1",
"AssetMetadataV1", "AssetMetadataV1",
"AssetMetadataDeleteV1", "AssetMetadataDeleteV1",
"PartnerV1", "PartnerV1",
@@ -23626,7 +23460,6 @@
"AlbumAssetExifsV1", "AlbumAssetExifsV1",
"AssetsV1", "AssetsV1",
"AssetExifsV1", "AssetExifsV1",
"AssetEditsV1",
"AssetMetadataV1", "AssetMetadataV1",
"AuthUsersV1", "AuthUsersV1",
"MemoriesV1", "MemoriesV1",
@@ -25331,27 +25164,10 @@
"type": "number" "type": "number"
}, },
"rating": { "rating": {
"description": "Rating in range [1-5], or null for unrated", "description": "Rating",
"maximum": 5, "maximum": 5,
"minimum": -1, "minimum": -1,
"nullable": true, "type": "number"
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
}, },
"visibility": { "visibility": {
"allOf": [ "allOf": [
+15 -32
View File
@@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = {
latitude?: number; latitude?: number;
/** Longitude coordinate */ /** Longitude coordinate */
longitude?: number; longitude?: number;
/** Rating in range [1-5], or null for unrated */ /** Rating */
rating?: number | null; rating?: number;
/** Time zone (IANA timezone) */ /** Time zone (IANA timezone) */
timeZone?: string; timeZone?: string;
/** Asset visibility */ /** Asset visibility */
@@ -944,8 +944,8 @@ export type UpdateAssetDto = {
livePhotoVideoId?: string | null; livePhotoVideoId?: string | null;
/** Longitude coordinate */ /** Longitude coordinate */
longitude?: number; longitude?: number;
/** Rating in range [1-5], or null for unrated */ /** Rating */
rating?: number | null; rating?: number;
/** Asset visibility */ /** Asset visibility */
visibility?: AssetVisibility; visibility?: AssetVisibility;
}; };
@@ -1711,8 +1711,8 @@ export type MetadataSearchDto = {
personIds?: string[]; personIds?: string[];
/** Filter by preview file path */ /** Filter by preview file path */
previewPath?: string; previewPath?: string;
/** Filter by rating [1-5], or null for unrated */ /** Filter by rating */
rating?: number | null; rating?: number;
/** Number of results to return */ /** Number of results to return */
size?: number; size?: number;
/** Filter by state/province name */ /** Filter by state/province name */
@@ -1827,8 +1827,8 @@ export type RandomSearchDto = {
ocr?: string; ocr?: string;
/** Filter by person IDs */ /** Filter by person IDs */
personIds?: string[]; personIds?: string[];
/** Filter by rating [1-5], or null for unrated */ /** Filter by rating */
rating?: number | null; rating?: number;
/** Number of results to return */ /** Number of results to return */
size?: number; size?: number;
/** Filter by state/province name */ /** Filter by state/province name */
@@ -1903,8 +1903,8 @@ export type SmartSearchDto = {
query?: string; query?: string;
/** Asset ID to use as search reference */ /** Asset ID to use as search reference */
queryAssetId?: string; queryAssetId?: string;
/** Filter by rating [1-5], or null for unrated */ /** Filter by rating */
rating?: number | null; rating?: number;
/** Number of results to return */ /** Number of results to return */
size?: number; size?: number;
/** Filter by state/province name */ /** Filter by state/province name */
@@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = {
ocr?: string; ocr?: string;
/** Filter by person IDs */ /** Filter by person IDs */
personIds?: string[]; personIds?: string[];
/** Filter by rating [1-5], or null for unrated */ /** Filter by rating */
rating?: number | null; rating?: number;
/** Filter by state/province name */ /** Filter by state/province name */
state?: string | null; state?: string | null;
/** Filter by tag IDs */ /** Filter by tag IDs */
@@ -2967,16 +2967,6 @@ export type SyncAssetDeleteV1 = {
/** Asset ID */ /** Asset ID */
assetId: string; assetId: string;
}; };
export type SyncAssetEditDeleteV1 = {
editId: string;
};
export type SyncAssetEditV1 = {
action: AssetEditAction;
assetId: string;
id: string;
parameters: object;
sequence: number;
};
export type SyncAssetExifV1 = { export type SyncAssetExifV1 = {
/** Asset ID */ /** Asset ID */
assetId: string; assetId: string;
@@ -5454,7 +5444,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
model?: string | null; model?: string | null;
ocr?: string; ocr?: string;
personIds?: string[]; personIds?: string[];
rating?: number | null; rating?: number;
size?: number; size?: number;
state?: string | null; state?: string | null;
tagIds?: string[] | null; tagIds?: string[] | null;
@@ -6421,9 +6411,8 @@ export function tagAssets({ id, bulkIdsDto }: {
/** /**
* Get time bucket * Get time bucket
*/ */
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string; albumId?: string;
bbox?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
@@ -6443,7 +6432,6 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
data: TimeBucketAssetResponseDto; data: TimeBucketAssetResponseDto;
}>(`/timeline/bucket${QS.query(QS.explode({ }>(`/timeline/bucket${QS.query(QS.explode({
albumId, albumId,
bbox,
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
@@ -6464,9 +6452,8 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
/** /**
* Get time buckets * Get time buckets
*/ */
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string; albumId?: string;
bbox?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
@@ -6485,7 +6472,6 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde
data: TimeBucketsResponseDto[]; data: TimeBucketsResponseDto[];
}>(`/timeline/buckets${QS.query(QS.explode({ }>(`/timeline/buckets${QS.query(QS.explode({
albumId, albumId,
bbox,
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
@@ -7244,8 +7230,6 @@ export enum SyncEntityType {
AssetV1 = "AssetV1", AssetV1 = "AssetV1",
AssetDeleteV1 = "AssetDeleteV1", AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1", AssetExifV1 = "AssetExifV1",
AssetEditV1 = "AssetEditV1",
AssetEditDeleteV1 = "AssetEditDeleteV1",
AssetMetadataV1 = "AssetMetadataV1", AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
PartnerV1 = "PartnerV1", PartnerV1 = "PartnerV1",
@@ -7297,7 +7281,6 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = "AlbumAssetExifsV1", AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1", AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1", AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1", AssetMetadataV1 = "AssetMetadataV1",
AuthUsersV1 = "AuthUsersV1", AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1", MemoriesV1 = "MemoriesV1",
+298 -291
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -22,12 +22,12 @@
"test:cov": "vitest --config test/vitest.config.mjs --coverage", "test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs", "test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm", "typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug", "migrations:debug": "node ./dist/bin/migrations.js debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", "migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate", "migrations:create": "node ./dist/bin/migrations.js create",
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run", "migrations:run": "node ./dist/bin/migrations.js run",
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert", "migrations:revert": "node ./dist/bin/migrations.js revert",
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'", "schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "pnpm run schema:drop && pnpm run migrations:run", "schema:reset": "pnpm run schema:drop && pnpm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js", "sync:sql": "node ./dist/bin/sync-sql.js",
@@ -35,7 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@extism/extism": "2.0.0-rc13", "@extism/extism": "2.0.0-rc13",
"@immich/sql-tools": "^0.3.2", "@immich/sql-tools": "^0.2.0",
"@nestjs/bullmq": "^11.0.1", "@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4", "@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4", "@nestjs/core": "^11.0.4",
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env node
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Kysely, sql } from 'kysely';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
const main = async () => {
const command = process.argv[2];
const path = process.argv[3] || 'src/Migration';
switch (command) {
case 'debug': {
await debug();
return;
}
case 'run': {
await runMigrations();
return;
}
case 'revert': {
await revert();
return;
}
case 'query': {
const query = process.argv[3];
await runQuery(query);
return;
}
case 'create': {
create(path, [], []);
return;
}
case 'generate': {
await generate(path);
return;
}
default: {
console.log(`Usage:
node dist/bin/migrations.js create <name>
node dist/bin/migrations.js generate <name>
node dist/bin/migrations.js run
node dist/bin/migrations.js revert
`);
}
}
};
const getDatabaseClient = () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
return new Kysely<any>(getKyselyConfig(database.config));
};
const runQuery = async (query: string) => {
const db = getDatabaseClient();
await sql.raw(query).execute(db);
await db.destroy();
};
const runMigrations = async () => {
const configRepository = new ConfigRepository();
const logger = LoggingRepository.create();
const db = getDatabaseClient();
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
await databaseRepository.runMigrations();
await db.destroy();
};
const revert = async () => {
const configRepository = new ConfigRepository();
const logger = LoggingRepository.create();
const db = getDatabaseClient();
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
try {
const migrationName = await databaseRepository.revertLastMigration();
if (!migrationName) {
console.log('No migrations to revert');
return;
}
markMigrationAsReverted(migrationName);
} finally {
await db.destroy();
}
};
const debug = async () => {
const { up } = await compare();
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
// const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
writeFileSync('./migrations.sql', upSql + '\n\n');
console.log('Wrote migrations.sql');
};
const generate = async (path: string) => {
const { up, down } = await compare();
if (up.items.length === 0) {
console.log('No changes detected');
return;
}
create(path, up.asSql(), down.asSql());
};
const create = (path: string, up: string[], down: string[]) => {
const timestamp = Date.now();
const name = basename(path, extname(path));
const filename = `${timestamp}-${name}.ts`;
const folder = dirname(path);
const fullPath = join(folder, filename);
mkdirSync(folder, { recursive: true });
writeFileSync(fullPath, asMigration({ up, down }));
console.log(`Wrote ${fullPath}`);
};
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const target = await schemaFromDatabase({ connection: database.config });
console.log(source.warnings.join('\n'));
const up = schemaDiff(source, target, {
tables: { ignoreExtra: true },
functions: { ignoreExtra: false },
parameters: { ignoreExtra: true },
});
const down = schemaDiff(target, source, {
tables: { ignoreExtra: false, ignoreMissing: true },
functions: { ignoreExtra: false },
extensions: { ignoreMissing: true },
parameters: { ignoreMissing: true },
});
return { up, down };
};
type MigrationProps = {
up: string[];
down: string[];
};
const asMigration = ({ up, down }: MigrationProps) => {
const upSql = up.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
const downSql = down.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
return `import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
${upSql}
}
export async function down(db: Kysely<any>): Promise<void> {
${downSql}
}
`;
};
const markMigrationAsReverted = (migrationName: string) => {
// eslint-disable-next-line unicorn/prefer-module
const distRoot = join(__dirname, '..');
const projectRoot = join(distRoot, '..');
const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations');
const distFolder = join(distRoot, 'schema', 'migrations');
const sourcePath = join(sourceFolder, `${migrationName}.ts`);
const revertedFolder = join(sourceFolder, 'reverted');
const revertedPath = join(revertedFolder, `${migrationName}.ts`);
if (existsSync(revertedPath)) {
console.log(`Migration ${migrationName} is already marked as reverted`);
} else if (existsSync(sourcePath)) {
mkdirSync(revertedFolder, { recursive: true });
renameSync(sourcePath, revertedPath);
console.log(`Moved ${sourcePath} to ${revertedPath}`);
} else {
console.warn(`Source migration file not found for ${migrationName}`);
}
const distBase = join(distFolder, migrationName);
for (const extension of ['.js', '.js.map', '.d.ts']) {
const filePath = `${distBase}${extension}`;
if (existsSync(filePath)) {
rmSync(filePath, { force: true });
console.log(`Removed ${filePath}`);
}
}
};
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
console.log('Something went wrong');
process.exit(1);
});
@@ -207,28 +207,12 @@ describe(AssetController.name, () => {
}); });
it('should reject invalid rating', async () => { it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest()); expect(body).toEqual(factory.responses.badRequest());
} }
}); });
it('should convert rating 0 to null', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
expect(status).toBe(200);
});
it('should leave correct ratings as-is', async () => {
const assetId = factory.uuid();
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
expect(status).toBe(200);
}
});
}); });
describe('GET /assets/statistics', () => { describe('GET /assets/statistics', () => {
-7
View File
@@ -437,13 +437,6 @@ export const columns = {
'asset_exif.rating', 'asset_exif.rating',
'asset_exif.fps', 'asset_exif.fps',
], ],
syncAssetEdit: [
'asset_edit.id',
'asset_edit.assetId',
'asset_edit.sequence',
'asset_edit.action',
'asset_edit.parameters',
],
exif: [ exif: [
'asset_exif.assetId', 'asset_exif.assetId',
'asset_exif.autoStackId', 'asset_exif.autoStackId',
+4 -12
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
IsArray, IsArray,
IsDateString, IsDateString,
@@ -16,7 +16,6 @@ import {
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { HistoryBuilder, Property } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum'; import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository'; import { AssetStats } from 'src/repositories/asset.repository';
@@ -57,19 +56,12 @@ export class UpdateAssetBase {
@IsNotEmpty() @IsNotEmpty()
longitude?: number; longitude?: number;
@Property({ @ApiProperty({ description: 'Rating' })
description: 'Rating in range [1-5], or null for unrated', @Optional()
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@IsInt() @IsInt()
@Max(5) @Max(5)
@Min(-1) @Min(-1)
@Transform(({ value }) => (value === 0 ? null : value)) rating?: number;
rating?: number | null;
@ApiProperty({ description: 'Asset description' }) @ApiProperty({ description: 'Asset description' })
@Optional() @Optional()
-25
View File
@@ -1,25 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsLatitude, IsLongitude } from 'class-validator';
import { IsGreaterThanOrEqualTo } from 'src/validation';
export class BBoxDto {
@ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' })
@IsLongitude()
west!: number;
@ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' })
@IsLatitude()
south!: number;
@ApiProperty({
format: 'double',
description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
})
@IsLongitude()
east!: number;
@ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' })
@IsLatitude()
@IsGreaterThanOrEqualTo('south')
north!: number;
}
+5 -2
View File
@@ -1,6 +1,7 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { ExtraModel } from 'src/dtos/sync.dto';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation'; import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
export enum AssetEditAction { export enum AssetEditAction {
@@ -14,6 +15,7 @@ export enum MirrorAxis {
Vertical = 'vertical', Vertical = 'vertical',
} }
@ExtraModel()
export class CropParameters { export class CropParameters {
@IsInt() @IsInt()
@Min(0) @Min(0)
@@ -36,12 +38,14 @@ export class CropParameters {
height!: number; height!: number;
} }
@ExtraModel()
export class RotateParameters { export class RotateParameters {
@IsAxisAlignedRotation() @IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' }) @ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number; angle!: number;
} }
@ExtraModel()
export class MirrorParameters { export class MirrorParameters {
@IsEnum(MirrorAxis) @IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' }) @ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
@@ -63,7 +67,6 @@ export type AssetEditActionItem =
parameters: MirrorParameters; parameters: MirrorParameters;
}; };
@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters)
export class AssetEditActionItemDto { export class AssetEditActionItemDto {
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' }) @ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
action!: AssetEditAction; action!: AssetEditAction;
+4 -13
View File
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database'; import { Place } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators'; import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
@@ -103,21 +103,12 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' }) @ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
albumIds?: string[]; albumIds?: string[];
@Property({ @ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 })
type: 'number', @Optional()
description: 'Filter by rating [1-5], or null for unrated',
minimum: -1,
maximum: 5,
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@IsInt() @IsInt()
@Max(5) @Max(5)
@Min(-1) @Min(-1)
rating?: number | null; rating?: number;
@ApiPropertyOptional({ description: 'Filter by OCR text content' }) @ApiPropertyOptional({ description: 'Filter by OCR text content' })
@IsString() @IsString()
-21
View File
@@ -2,7 +2,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { import {
AlbumUserRole, AlbumUserRole,
AssetOrder, AssetOrder,
@@ -219,24 +218,6 @@ export class SyncAssetExifV1 {
fps!: number | null; fps!: number | null;
} }
@ExtraModel()
export class SyncAssetEditV1 {
id!: string;
assetId!: string;
@ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' })
action!: AssetEditAction;
parameters!: object;
@ApiProperty({ type: 'integer' })
sequence!: number;
}
@ExtraModel()
export class SyncAssetEditDeleteV1 {
editId!: string;
}
@ExtraModel() @ExtraModel()
export class SyncAssetMetadataV1 { export class SyncAssetMetadataV1 {
@ApiProperty({ description: 'Asset ID' }) @ApiProperty({ description: 'Asset ID' })
@@ -499,8 +480,6 @@ export type SyncItem = {
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
+1 -5
View File
@@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
import type { BBoxDto } from 'src/dtos/bbox.dto';
import { AssetOrder, AssetVisibility } from 'src/enum'; import { AssetOrder, AssetVisibility } from 'src/enum';
import { ValidateBBox } from 'src/utils/bbox';
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class TimeBucketDto { export class TimeBucketDto {
@@ -60,9 +59,6 @@ export class TimeBucketDto {
description: 'Include location data in the response', description: 'Include location data in the response',
}) })
withCoordinates?: boolean; withCoordinates?: boolean;
@ValidateBBox({ optional: true })
bbox?: BBoxDto;
} }
export class TimeBucketAssetDto extends TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto {
-3
View File
@@ -720,7 +720,6 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
AssetsV1 = 'AssetsV1', AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1', AssetExifsV1 = 'AssetExifsV1',
AssetEditsV1 = 'AssetEditsV1',
AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataV1 = 'AssetMetadataV1',
AuthUsersV1 = 'AuthUsersV1', AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1', MemoriesV1 = 'MemoriesV1',
@@ -746,8 +745,6 @@ export enum SyncEntityType {
AssetV1 = 'AssetV1', AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1', AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1', AssetExifV1 = 'AssetExifV1',
AssetEditV1 = 'AssetEditV1',
AssetEditDeleteV1 = 'AssetEditDeleteV1',
AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1', AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
@@ -18,17 +18,3 @@ where
"assetId" = $1 "assetId" = $1
order by order by
"sequence" asc "sequence" asc
-- AssetEditRepository.getWithSyncInfo
select
"asset_edit"."id",
"asset_edit"."assetId",
"asset_edit"."sequence",
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc
@@ -562,7 +562,6 @@ select
"asset"."checksum", "asset"."checksum",
"asset"."originalPath", "asset"."originalPath",
"asset"."isExternal", "asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName", "asset"."originalFileName",
"asset"."livePhotoVideoId", "asset"."livePhotoVideoId",
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
@@ -594,7 +593,6 @@ from
where where
"asset"."deletedAt" is null "asset"."deletedAt" is null
and "asset"."id" = $2 and "asset"."id" = $2
and "asset"."visibility" != $3
-- AssetJobRepository.streamForStorageTemplateJob -- AssetJobRepository.streamForStorageTemplateJob
select select
@@ -604,7 +602,6 @@ select
"asset"."checksum", "asset"."checksum",
"asset"."originalPath", "asset"."originalPath",
"asset"."isExternal", "asset"."isExternal",
"asset"."visibility",
"asset"."originalFileName", "asset"."originalFileName",
"asset"."livePhotoVideoId", "asset"."livePhotoVideoId",
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
@@ -635,7 +632,6 @@ from
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where where
"asset"."deletedAt" is null "asset"."deletedAt" is null
and "asset"."visibility" != $2
-- AssetJobRepository.streamForDeletedJob -- AssetJobRepository.streamForDeletedJob
select select
+4 -3
View File
@@ -123,13 +123,13 @@ with
) as "year" ) as "year"
) )
select select
"a".* "a".*,
to_json("asset_exif") as "exifInfo"
from from
"today" "today"
inner join lateral ( inner join lateral (
select select
"asset"."id", "asset".*
"asset"."localDateTime"
from from
"asset" "asset"
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId" inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
@@ -151,6 +151,7 @@ with
limit limit
$7 $7
) as "a" on true ) as "a" on true
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
) )
select select
date_part( date_part(
-32
View File
@@ -514,38 +514,6 @@ where
order by order by
"asset_exif"."updateId" asc "asset_exif"."updateId" asc
-- SyncRepository.assetEdit.getDeletes
select
"asset_edit_audit"."id",
"editId"
from
"asset_edit_audit" as "asset_edit_audit"
inner join "asset" on "asset"."id" = "asset_edit_audit"."assetId"
where
"asset_edit_audit"."id" < $1
and "asset_edit_audit"."id" > $2
and "asset"."ownerId" = $3
order by
"asset_edit_audit"."id" asc
-- SyncRepository.assetEdit.getUpserts
select
"asset_edit"."id",
"asset_edit"."assetId",
"asset_edit"."sequence",
"asset_edit"."action",
"asset_edit"."parameters",
"asset_edit"."updateId"
from
"asset_edit" as "asset_edit"
inner join "asset" on "asset"."id" = "asset_edit"."assetId"
where
"asset_edit"."updateId" < $1
and "asset_edit"."updateId" > $2
and "asset"."ownerId" = $3
order by
"asset_edit"."updateId" asc
-- SyncRepository.assetFace.getDeletes -- SyncRepository.assetFace.getDeletes
select select
"asset_face_audit"."id", "asset_face_audit"."id",
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto'; import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
@@ -10,7 +9,9 @@ import { DB } from 'src/schema';
export class AssetEditRepository { export class AssetEditRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({
params: [DummyValue.UUID],
})
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> { replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
return this.db.transaction().execute(async (trx) => { return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
@@ -27,7 +28,9 @@ export class AssetEditRepository {
}); });
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({
params: [DummyValue.UUID],
})
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> { getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
return this.db return this.db
.selectFrom('asset_edit') .selectFrom('asset_edit')
@@ -36,14 +39,4 @@ export class AssetEditRepository {
.orderBy('sequence', 'asc') .orderBy('sequence', 'asc')
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
getWithSyncInfo(assetId: string) {
return this.db
.selectFrom('asset_edit')
.select(columns.syncAssetEdit)
.where('assetId', '=', assetId)
.orderBy('sequence', 'asc')
.execute();
}
} }
@@ -353,7 +353,6 @@ export class AssetJobRepository {
'asset.checksum', 'asset.checksum',
'asset.originalPath', 'asset.originalPath',
'asset.isExternal', 'asset.isExternal',
'asset.visibility',
'asset.originalFileName', 'asset.originalFileName',
'asset.livePhotoVideoId', 'asset.livePhotoVideoId',
'asset.fileCreatedAt', 'asset.fileCreatedAt',
@@ -368,16 +367,13 @@ export class AssetJobRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) { getForStorageTemplateJob(id: string) {
return this.storageTemplateAssetQuery() return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
.where('asset.id', '=', id)
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
.executeTakeFirst();
} }
@GenerateSql({ params: [], stream: true }) @GenerateSql({ params: [], stream: true })
streamForStorageTemplateJob() { streamForStorageTemplateJob() {
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream(); return this.storageTemplateAssetQuery().stream();
} }
@GenerateSql({ params: [DummyValue.DATE], stream: true }) @GenerateSql({ params: [DummyValue.DATE], stream: true })

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