diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index d509b404a1..2c6d889d7b 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -94,7 +94,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile @@ -216,7 +216,7 @@ jobs: persist-credentials: false - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 7cbab78069..a38f131a71 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@4bdf89062dad92caea71236f59704b04d3ce9682 # v0.1.8 + uses: oasdiff/oasdiff-action/breaking@0ab8ad204b00d25acc5ae87106281433e288d0c1 # v0.1.10 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ github.token }} working_directory: ./mobile diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index aa2c6297b2..7cfa4bbc92 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -44,7 +44,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -83,7 +83,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9bf5abfcba..7e14227a51 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -71,7 +71,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/autobuild@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -84,6 +84,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ab1ec84136..657d61d647 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -61,7 +61,7 @@ jobs: suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn'] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -91,7 +91,7 @@ jobs: suffix: [''] steps: - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 09c340e0fe..6d7bd497d7 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -68,7 +68,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index a452e2ae51..b88e3f56dc 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -143,7 +143,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index eec66484d3..cdb3f83c96 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -31,7 +31,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 4c97f3f16c..2638c31fca 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -30,7 +30,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index abbcfaebd0..5820a8b284 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -8,7 +8,6 @@ permissions: {} jobs: validate-release-label: - if: ${{ !startsWith(github.event.pull_request.head.ref, 'mergify/merge-queue/') }} runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 5150896192..1bf9a61e8c 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -68,7 +68,7 @@ jobs: ref: main - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index a5eaf26181..9c6101e4e1 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -29,7 +29,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 8fe1e1265c..3004f0323f 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -64,7 +64,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a01b429713..acf3a8d7f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,7 +91,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -120,7 +120,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -152,7 +152,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -184,7 +184,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -229,7 +229,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -268,7 +268,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -297,7 +297,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -348,7 +348,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -382,7 +382,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -606,7 +606,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile @@ -644,7 +644,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -676,7 +676,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -729,7 +729,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} @@ -787,7 +787,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Setup Mise - uses: immich-app/devtools/actions/use-mise@4d97fb30d9edd3ff0d735f411f607a35204eea79 # use-mise-action-v3.2.0 + uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1 with: github_token: ${{ steps.token.outputs.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f2c9e62a0..5972872528 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,8 @@ We ask you not to open PRs generated with an LLM. We find that code generated li If you use an LLM to translate a PR description or title, that is fine. Please make sure however that you stick to the PR template and that the text is written concisely. +LLMs must not be used to fix GitHub issues labelled good first issue. These issues are generally not urgent, and are intended to be learning opportunities for new contributors to get familiar with the codebase. Whether you are a newcomer or not, fully automating the process of fixing this issue squanders the learning opportunity and doesn’t add much value to the project. Using LLMs to fix issues labelled as “good first issue” is forbidden, and those PRs will be closed automatically. + Misrepresenting LLM use, contribution farming (automated low-effort PRs), or repeatedly hitting auto-close rules may be grounds for a block at maintainer discretion. ## Feature freezes diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 013ff266fa..779065e327 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -85,7 +85,7 @@ services: container_name: immich_prometheus ports: - 9090:9090 - image: prom/prometheus@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893 + image: prom/prometheus@sha256:508729e0e2d18e11fd742a5a5ca70e557b940a93948c3c95fd0123a6fd538b69 volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.4.6-ubuntu@sha256:35c75489ff2e2e69c53977a180de491643d51fdb32108f9335317527978daed7 + image: grafana/grafana:12.4.6-ubuntu@sha256:124ddf024f95e61b562a02112b47bc64de75973370cf440b1356d31cd8178844 volumes: - grafana-data:/var/lib/grafana diff --git a/i18n/en.json b/i18n/en.json index fe9a773a3e..afe864fdf5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2188,7 +2188,7 @@ "upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}", "upload_details": "Upload Details", "upload_error_with_count": "Upload error for {count, plural, one {# asset} other {# assets}}", - "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", + "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}.", "upload_finished": "Upload finished", "upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}", "upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index afbd72e99e..c8191e307b 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -160,8 +160,8 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF} ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} -# store matplotlib config in /cache subfolder to enable rootless deployment with a single volume -ENV MPLCONFIGDIR=/cache/matplotlib +# store huggingface in /cache subfolder to enable rootless deployment with a single volume +ENV HF_HOME=/cache/hf-cache ENTRYPOINT ["tini", "--"] CMD ["python", "-m", "immich_ml"] diff --git a/machine-learning/conftest.py b/machine-learning/conftest.py index 57741f3807..a5acc059d1 100644 --- a/machine-learning/conftest.py +++ b/machine-learning/conftest.py @@ -1,11 +1,10 @@ import json -from typing import Any, Iterator +from types import SimpleNamespace +from typing import Any, Callable, Iterator from unittest import mock -import numpy as np import pytest from fastapi.testclient import TestClient -from numpy.typing import NDArray from PIL import Image from immich_ml.config import log @@ -17,11 +16,6 @@ def pil_image() -> Image.Image: return Image.new("RGB", (600, 800)) -@pytest.fixture -def cv_image(pil_image: Image.Image) -> NDArray[np.float32]: - return np.asarray(pil_image)[:, :, ::-1] # PIL uses RGB while cv2 uses BGR - - @pytest.fixture def mock_get_model() -> Iterator[mock.Mock]: with mock.patch("immich_ml.models.cache.from_model_type", autospec=True) as mocked: @@ -129,6 +123,18 @@ def ort_session() -> Iterator[mock.Mock]: yield mocked +@pytest.fixture(scope="function") +def stub_session() -> Callable[..., mock.Mock]: + def _make(shape: tuple[Any, ...], outputs: Any = None, name: str = "input.1") -> mock.Mock: + session = mock.Mock() + session.get_inputs.return_value = [SimpleNamespace(name=name, shape=shape)] + if outputs is not None: + session.run.return_value = outputs + return session + + return _make + + @pytest.fixture(scope="function") def ann_session() -> Iterator[mock.Mock]: with mock.patch("immich_ml.sessions.ann.Ann") as mocked: diff --git a/machine-learning/immich_ml/models/base.py b/machine-learning/immich_ml/models/base.py index 00790ce744..5c40b21f32 100644 --- a/machine-learning/immich_ml/models/base.py +++ b/machine-learning/immich_ml/models/base.py @@ -57,7 +57,7 @@ class InferenceModel(ABC): self.load() if model_kwargs: self.configure(**model_kwargs) - return self._predict(*inputs) + return self._predict(*inputs, **model_kwargs) @abstractmethod def _predict(self, *inputs: Any, **model_kwargs: Any) -> Any: ... diff --git a/machine-learning/immich_ml/models/facial_recognition/_ops.py b/machine-learning/immich_ml/models/facial_recognition/_ops.py new file mode 100644 index 0000000000..6bdf5f5c3c --- /dev/null +++ b/machine-learning/immich_ml/models/facial_recognition/_ops.py @@ -0,0 +1,74 @@ +""" +Host-side geometry for the fused face models. + +The decode, NMS and Umeyama similarity are ports of insightface (Apache-2.0, https://github.com/deepinsight/insightface) +""" + +from functools import lru_cache + +import cv2 +import numpy as np +from numpy.typing import NDArray + +from immich_ml.models.transforms import ensure_dims + +DET_SIZE = 640 +ALIGNED_SIZE = 112 + +# the FPN levels for which the fused detector emits a (scores, boxes, kps) triple, +# and the anchors each feature-map cell carries, laid out anchor-major +DET_STRIDES = (8, 16, 32) +ANCHORS_PER_CELL = 2 + +# canonical ArcFace 5-point template for a 112x112 crop +ARCFACE_DST = np.array( + [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]], + dtype=np.float32, +) + + +@lru_cache(maxsize=len(DET_STRIDES)) +def _anchor_centers(size: int, stride: int) -> NDArray[np.float32]: + ys, xs = np.mgrid[: size // stride, : size // stride] + centers = np.stack([xs, ys], axis=-1, dtype=np.float32).reshape(-1, 2) * stride + return np.repeat(centers, ANCHORS_PER_CELL, axis=0) + + +def decode_scrfd( + heads: list[NDArray[np.float32]], size: int = DET_SIZE +) -> tuple[NDArray[np.float32], NDArray[np.float32], NDArray[np.float32]]: + heads = [ensure_dims(head, 4) for head in heads] + scores, boxes, kps = [], [], [] + for level, stride in enumerate(DET_STRIDES): + centers = _anchor_centers(size, stride) + distance = heads[level + len(DET_STRIDES)][0] * stride + offsets = heads[level + 2 * len(DET_STRIDES)][0] * stride + scores.append(heads[level][0].squeeze(-1)) + boxes.append(np.concatenate([centers[None] - distance[:, :, :2], centers[None] + distance[:, :, 2:]], axis=2)) + kps.append(np.tile(centers, offsets.shape[2] // 2) + offsets) + + return np.concatenate(scores, axis=1), np.concatenate(boxes, axis=1), np.concatenate(kps, axis=1) + + +def nms(boxes: NDArray[np.float32], scores: NDArray[np.float32], threshold: float = 0.4) -> NDArray[np.intp]: + wh = np.column_stack([boxes[:, 0], boxes[:, 1], boxes[:, 2] - boxes[:, 0], boxes[:, 3] - boxes[:, 1]]) + keep = cv2.dnn.NMSBoxes(wh.tolist(), scores.tolist(), 0.0, threshold) # NMSBoxes treats the inputs as Sequences + return np.asarray(keep, dtype=np.intp).reshape(-1) + + +def umeyama(src: NDArray[np.float32], dst: NDArray[np.float32]) -> NDArray[np.float32]: + src_mean, dst_mean = src.mean(0), dst.mean(0) + src_c, dst_c = src - src_mean, dst - dst_mean + cov = dst_c.T @ src_c / len(src) + u, s, vt = np.linalg.svd(cov) + d = np.sign(np.linalg.det(u @ vt)) + diag = np.diag([1.0, d]) + rotation = u @ diag @ vt + scale = np.trace(np.diag(s) @ diag) / (src_c**2).sum() * len(src) + translation = dst_mean - scale * rotation @ src_mean + return np.hstack([scale * rotation, translation[:, None]], dtype=np.float32) + + +def align_face(image: NDArray[np.uint8], kps: NDArray[np.float32]) -> NDArray[np.float32]: + matrix = umeyama(kps, ARCFACE_DST) + return cv2.warpAffine(image, matrix, (ALIGNED_SIZE, ALIGNED_SIZE)).astype(np.float32) diff --git a/machine-learning/immich_ml/models/facial_recognition/detection.py b/machine-learning/immich_ml/models/facial_recognition/detection.py index 26c9208e48..a113755201 100644 --- a/machine-learning/immich_ml/models/facial_recognition/detection.py +++ b/machine-learning/immich_ml/models/facial_recognition/detection.py @@ -1,41 +1,31 @@ -from typing import Any - import numpy as np -from insightface.model_zoo import RetinaFace from numpy.typing import NDArray from immich_ml.models.base import InferenceModel -from immich_ml.models.transforms import decode_cv2 -from immich_ml.schemas import FaceDetectionOutput, ModelSession, ModelTask, ModelType +from immich_ml.models.transforms import decode_pil, letterbox, normalize +from immich_ml.schemas import FaceDetectionOutput, ModelTask, ModelType + +from ._ops import DET_SIZE, decode_scrfd, nms class FaceDetector(InferenceModel): depends = [] identity = (ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION) - def __init__(self, model_name: str, min_score: float = 0.7, **model_kwargs: Any) -> None: - self.min_score = model_kwargs.pop("minScore", min_score) - super().__init__(model_name, **model_kwargs) + def _predict(self, inputs: NDArray[np.uint8] | bytes, minScore: float) -> FaceDetectionOutput: + canvas, scale = letterbox(decode_pil(inputs), DET_SIZE) + blob = normalize(canvas.astype(np.float32), mean=127.5, std=128).transpose(2, 0, 1)[None] - def _load(self) -> ModelSession: - session = self._make_session(self.model_path) - self.model = RetinaFace(session=session) - self.model.prepare(ctx_id=0, det_thresh=self.min_score, input_size=(640, 640)) + input_name = self.session.get_inputs()[0].name + heads = self.session.run(None, {input_name: blob}) + scores, boxes, kps = decode_scrfd(heads, DET_SIZE) - return session + candidates = scores >= minScore + scores, boxes, kps = scores[candidates], boxes[candidates] / scale, kps[candidates] / scale + keep = nms(boxes, scores) - def _predict(self, inputs: NDArray[np.uint8] | bytes) -> FaceDetectionOutput: - inputs = decode_cv2(inputs) - - bboxes, landmarks = self._detect(inputs) return { - "boxes": bboxes[:, :4].round(), - "scores": bboxes[:, 4], - "landmarks": landmarks, + "boxes": boxes[keep].round(), + "scores": scores[keep], + "landmarks": kps[keep].reshape(-1, 5, 2), } - - def _detect(self, inputs: NDArray[np.uint8] | bytes) -> tuple[NDArray[np.float32], NDArray[np.float32]]: - return self.model.detect(inputs) # type: ignore - - def configure(self, **kwargs: Any) -> None: - self.model.det_thresh = kwargs.pop("minScore", self.model.det_thresh) diff --git a/machine-learning/immich_ml/models/facial_recognition/recognition.py b/machine-learning/immich_ml/models/facial_recognition/recognition.py index c144bd3eaa..2b0575ec38 100644 --- a/machine-learning/immich_ml/models/facial_recognition/recognition.py +++ b/machine-learning/immich_ml/models/facial_recognition/recognition.py @@ -4,15 +4,13 @@ from typing import Any import numpy as np import onnx import onnxruntime as ort -from insightface.model_zoo import ArcFaceONNX -from insightface.utils.face_align import norm_crop from numpy.typing import NDArray from onnx.tools.update_model_dims import update_inputs_outputs_dims from PIL import Image from immich_ml.config import log, settings from immich_ml.models.base import InferenceModel -from immich_ml.models.transforms import decode_cv2, serialize_np_array +from immich_ml.models.transforms import decode_pil, normalize, serialize_np_array from immich_ml.schemas import ( FaceDetectionOutput, FacialRecognitionOutput, @@ -22,6 +20,8 @@ from immich_ml.schemas import ( ModelType, ) +from ._ops import align_face + class FaceRecognizer(InferenceModel): depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)] @@ -37,10 +37,6 @@ class FaceRecognizer(InferenceModel): if (not self.batch_size or self.batch_size > 1) and str(session.get_inputs()[0].shape[0]) != "batch": self._add_batch_axis(self.model_path) session = self._make_session(self.model_path) - self.model = ArcFaceONNX( - self.model_path_for_format(ModelFormat.ONNX).as_posix(), - session=session, - ) return session def _predict( @@ -48,20 +44,21 @@ class FaceRecognizer(InferenceModel): ) -> FacialRecognitionOutput: if faces["boxes"].shape[0] == 0: return [] - inputs = decode_cv2(inputs) - cropped_faces = self._crop(inputs, faces) - embeddings = self._predict_batch(cropped_faces) + image = np.asarray(decode_pil(inputs), dtype=np.uint8) + crops = np.stack([align_face(image, kps).transpose(2, 0, 1) for kps in faces["landmarks"]]) + embeddings = self._predict_batch(normalize(crops, mean=127.5, std=127.5)) return self.postprocess(faces, embeddings) - def _predict_batch(self, cropped_faces: list[NDArray[np.uint8]]) -> NDArray[np.float32]: - if not self.batch_size or len(cropped_faces) <= self.batch_size: - embeddings: NDArray[np.float32] = self.model.get_feat(cropped_faces) - return embeddings + def _predict_batch(self, crops: NDArray[np.float32]) -> NDArray[np.float32]: + input_name = self.session.get_inputs()[0].name + if not self.batch_size or crops.shape[0] <= self.batch_size: + return self.session.run(None, {input_name: crops})[0] - batch_embeddings: list[NDArray[np.float32]] = [] - for i in range(0, len(cropped_faces), self.batch_size): - batch_embeddings.append(self.model.get_feat(cropped_faces[i : i + self.batch_size])) - return np.concatenate(batch_embeddings, axis=0) + batches = [ + self.session.run(None, {input_name: crops[i : i + self.batch_size]})[0] + for i in range(0, crops.shape[0], self.batch_size) + ] + return np.concatenate(batches, axis=0) def postprocess(self, faces: FaceDetectionOutput, embeddings: NDArray[np.float32]) -> FacialRecognitionOutput: return [ @@ -73,9 +70,6 @@ class FaceRecognizer(InferenceModel): for (x1, y1, x2, y2), embedding, score in zip(faces["boxes"], embeddings, faces["scores"]) ] - def _crop(self, image: NDArray[np.uint8], faces: FaceDetectionOutput) -> list[NDArray[np.uint8]]: - return [norm_crop(image, landmark) for landmark in faces["landmarks"]] - def _add_batch_axis(self, model_path: Path) -> None: log.debug(f"Adding batch axis to model {model_path}") proto = onnx.load(model_path) diff --git a/machine-learning/immich_ml/models/ocr/detection.py b/machine-learning/immich_ml/models/ocr/detection.py index 0a2cb8ad91..48dd87854b 100644 --- a/machine-learning/immich_ml/models/ocr/detection.py +++ b/machine-learning/immich_ml/models/ocr/detection.py @@ -63,7 +63,8 @@ class TextDetector(InferenceModel): return OrtSession(self.model_path) # partly adapted from RapidOCR - def _predict(self, inputs: Image.Image) -> TextDetectionOutput: + # TODO: take the options as parameters instead of applying them through `configure` + def _predict(self, inputs: Image.Image, **kwargs: Any) -> TextDetectionOutput: w, h = inputs.size if w < 32 or h < 32: return self._empty diff --git a/machine-learning/immich_ml/models/ocr/recognition.py b/machine-learning/immich_ml/models/ocr/recognition.py index 94f40c9285..ea7ff565d1 100644 --- a/machine-learning/immich_ml/models/ocr/recognition.py +++ b/machine-learning/immich_ml/models/ocr/recognition.py @@ -69,7 +69,8 @@ class TextRecognizer(InferenceModel): ) return session - def _predict(self, img: Image.Image, texts: TextDetectionOutput) -> TextRecognitionOutput: + # TODO: take the options as parameters instead of applying them through `configure` + def _predict(self, img: Image.Image, texts: TextDetectionOutput, **kwargs: Any) -> TextRecognitionOutput: boxes, box_scores = texts["boxes"], texts["scores"] if boxes.shape[0] == 0: return self._empty diff --git a/machine-learning/immich_ml/models/transforms.py b/machine-learning/immich_ml/models/transforms.py index 3f9d93bed3..ac220a3812 100644 --- a/machine-learning/immich_ml/models/transforms.py +++ b/machine-learning/immich_ml/models/transforms.py @@ -1,6 +1,5 @@ import string from io import BytesIO -from typing import IO import cv2 import numpy as np @@ -12,11 +11,10 @@ _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in _PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) -def resize_pil(img: Image.Image, size: int) -> Image.Image: +def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image: if img.width < img.height: - return img.resize((size, int((img.height / img.width) * size)), resample=Image.Resampling.BICUBIC) - else: - return img.resize((int((img.width / img.height) * size), size), resample=Image.Resampling.BICUBIC) + return img.resize((size, int((img.height / img.width) * size)), resample=resample) + return img.resize((int((img.width / img.height) * size), size), resample=resample) # https://stackoverflow.com/a/60883103 @@ -36,7 +34,9 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]: def normalize( img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32] ) -> NDArray[np.float32]: - return (img - mean) / std + img *= 1.0 / std + img -= mean / std + return img def get_pil_resampling(resample: str) -> Image.Resampling: @@ -47,26 +47,21 @@ def pil_to_cv2(image: Image.Image) -> NDArray[np.uint8]: return cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR) # type: ignore -def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image: - if isinstance(image_bytes, Image.Image): - return image_bytes - image: Image.Image = Image.open(BytesIO(image_bytes) if isinstance(image_bytes, bytes) else image_bytes) +def decode_pil(image_bytes: bytes | Image.Image | NDArray[np.uint8]) -> Image.Image: + image: Image.Image + match image_bytes: + case Image.Image(): + image = image_bytes + case np.ndarray(): + image = Image.fromarray(image_bytes) + case bytes(): + image = Image.open(BytesIO(image_bytes)) image.load() if not image.mode == "RGB": image = image.convert("RGB") return image -def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]: - match image_bytes: - case bytes() | memoryview() | bytearray(): - return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2 - case Image.Image(): - return pil_to_cv2(image_bytes) - case _: - return image_bytes - - def clean_text(text: str, canonicalize: bool = False) -> str: text = " ".join(text.split()) if canonicalize: @@ -78,3 +73,21 @@ def clean_text(text: str, canonicalize: bool = False) -> str: # TODO: use this in a less invasive way def serialize_np_array(arr: NDArray[np.float32]) -> str: return orjson.dumps(arr, option=orjson.OPT_SERIALIZE_NUMPY).decode() + + +def letterbox(image: NDArray[np.uint8] | Image.Image, size: int) -> tuple[NDArray[np.uint8], float]: + if isinstance(image, Image.Image): + image = np.asarray(image) + height, width = image.shape[:2] + if height > width: + new_height, new_width = size, int(size * width / height) + else: + new_width, new_height = size, int(size * height / width) + + canvas = np.zeros((size, size, 3), dtype=np.uint8) + cv2.resize(image, (new_width, new_height), dst=canvas[:new_height, :new_width]) + return canvas, new_height / height + + +def ensure_dims(array: NDArray[np.float32], ndim: int) -> NDArray[np.float32]: + return array if array.ndim >= ndim else np.expand_dims(array, axis=tuple(range(ndim - array.ndim))) diff --git a/machine-learning/immich_ml/schemas.py b/machine-learning/immich_ml/schemas.py index d5304cf763..d5695f5eb3 100644 --- a/machine-learning/immich_ml/schemas.py +++ b/machine-learning/immich_ml/schemas.py @@ -14,10 +14,7 @@ class ORJSONResponse(JSONResponse): class StrEnum(str, Enum): - value: str - - def __str__(self) -> str: - return self.value + __str__ = str.__str__ class BoundingBox(TypedDict): @@ -63,7 +60,7 @@ ModelIdentity = tuple[ModelType, ModelTask] class SessionNode(Protocol): @property - def name(self) -> str | None: ... + def name(self) -> str: ... @property def shape(self) -> tuple[int, ...]: ... diff --git a/machine-learning/immich_ml/sessions/ann/__init__.py b/machine-learning/immich_ml/sessions/ann/__init__.py index 6f36f675f6..d3279f9a17 100644 --- a/machine-learning/immich_ml/sessions/ann/__init__.py +++ b/machine-learning/immich_ml/sessions/ann/__init__.py @@ -37,11 +37,11 @@ class AnnSession: def get_inputs(self) -> list[SessionNode]: shapes = self.ann.input_shapes[self.model] - return [AnnNode(None, s) for s in shapes] + return [AnnNode(f"input.{i + 1}", s) for i, s in enumerate(shapes)] def get_outputs(self) -> list[SessionNode]: shapes = self.ann.output_shapes[self.model] - return [AnnNode(None, s) for s in shapes] + return [AnnNode(f"output.{i + 1}", s) for i, s in enumerate(shapes)] def run( self, @@ -54,5 +54,5 @@ class AnnSession: class AnnNode(NamedTuple): - name: str | None + name: str shape: tuple[int, ...] diff --git a/machine-learning/immich_ml/sessions/rknn/__init__.py b/machine-learning/immich_ml/sessions/rknn/__init__.py index e388e4febc..90f2e63ed3 100644 --- a/machine-learning/immich_ml/sessions/rknn/__init__.py +++ b/machine-learning/immich_ml/sessions/rknn/__init__.py @@ -69,7 +69,7 @@ class RknnSession: class RknnNode(NamedTuple): - name: str | None + name: str shape: tuple[int, ...] diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index af4d8edf44..e95ef7afef 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -10,8 +10,8 @@ dependencies = [ "fastapi>=0.95.2,<1.0", "gunicorn>=21.1.0", "huggingface-hub>=1.0,<2.0", - "insightface>=0.7.3,<2.0", "numpy>=2.4.0,<3.0", + "onnx>=1.22.0", "opencv-python-headless>=4.7.0.72,<5.0", "orjson>=3.9.5", "pillow>=12.2,<13", diff --git a/machine-learning/test_main.py b/machine-learning/test_main.py index be574c6397..567c6ab176 100644 --- a/machine-learning/test_main.py +++ b/machine-learning/test_main.py @@ -7,7 +7,6 @@ from types import SimpleNamespace from typing import Any, Callable from unittest import mock -import cv2 import numpy as np import onnxruntime as ort import orjson @@ -516,7 +515,7 @@ class TestAnnSession: inputs = session.get_inputs() assert len(inputs) == 1 - assert inputs[0].name is None + assert inputs[0].name == "input.1" assert inputs[0].shape == (1, 3, 224, 224) def test_get_outputs(self, ann_session: mock.Mock) -> None: @@ -527,7 +526,7 @@ class TestAnnSession: outputs = session.get_outputs() assert len(outputs) == 1 - assert outputs[0].name is None + assert outputs[0].name == "output.1" assert outputs[0].shape == (1, 3, 224, 224) def test_run(self, ann_session: mock.Mock, mocker: MockerFixture) -> None: @@ -781,89 +780,189 @@ class TestCLIP: assert np.allclose(tokens["attention_mask"], np.array([mock_attention_mask], dtype=np.int32), atol=0) +def make_scrfd_heads(detections: list[tuple[int, int, float]]) -> list[np.ndarray]: + """Build the 9 head tensors a SCRFD keypoint model emits at 640x640. + + `detections` is a list of (cell_x, cell_y, score) placed on the stride-8 level. + Distances and keypoint offsets are fixed so the decoded geometry is known by + hand rather than derived from the code under test: + box = [cx - 1*8, cy - 2*8, cx + 3*8, cy + 4*8] + kps = [cx, cy] + [0, 1, 2, ... 9] * 8, reshaped to 5 points + """ + heads: list[np.ndarray] = [] + counts = [(640 // stride) ** 2 * 2 for stride in (8, 16, 32)] + for channels in (1, 4, 10): + for n in counts: + heads.append(np.zeros((n, channels), dtype=np.float32)) + for cell_x, cell_y, score in detections: + i = 2 * (cell_y * 80 + cell_x) # anchor-major, 2 anchors per cell + heads[0][i] = score + heads[3][i] = [1, 2, 3, 4] + heads[6][i] = np.arange(10) + return heads + + +def expected_box(cell_x: int, cell_y: int) -> list[float]: + cx, cy = cell_x * 8, cell_y * 8 + return [cx - 8, cy - 16, cx + 24, cy + 32] + + +def expected_landmarks(cell_x: int, cell_y: int) -> np.ndarray: + cx, cy = cell_x * 8, cell_y * 8 + return (np.tile([cx, cy], 5) + np.arange(10) * 8).reshape(5, 2).astype(np.float32) + + +@pytest.fixture +def batch_axis(mocker: MockerFixture) -> SimpleNamespace: + """Patches for the `_add_batch_axis` path: the onnx module it rewrites the model + through, and the download it would otherwise trigger on load.""" + mocker.patch("immich_ml.models.base.InferenceModel.download") + return SimpleNamespace( + onnx=mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True), + update_dims=mocker.patch( + "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True + ), + ) + + class TestFaceRecognition: - def test_set_min_score(self, snapshot_download: mock.Mock, ort_session: mock.Mock, path: mock.Mock) -> None: - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" - - face_detector = FaceDetector("buffalo_s", min_score=0.5, cache_dir="test_cache") - face_detector.load() - - assert face_detector.min_score == 0.5 - assert face_detector.model.det_thresh == 0.5 - - def test_detection(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None: + def test_detection(self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture) -> None: mocker.patch.object(FaceDetector, "load") - face_detector = FaceDetector("buffalo_s", min_score=0.0, cache_dir="test_cache") + face_detector = FaceDetector("buffalo_s", cache_dir="test_cache") - det_model = mock.Mock() - num_faces = 2 - bbox = np.random.rand(num_faces, 4).astype(np.float32) - scores = np.array([[0.67]] * num_faces).astype(np.float32) - kpss = np.random.rand(num_faces, 5, 2).astype(np.float32) - det_model.detect.return_value = (np.concatenate([bbox, scores], axis=-1), kpss) - face_detector.model = det_model + session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9), (50, 50, 0.8)])) + face_detector.session = session - faces = face_detector.predict(cv_image) + faces = face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7) assert isinstance(faces, dict) - assert isinstance(faces.get("boxes", None), np.ndarray) - assert isinstance(faces.get("landmarks", None), np.ndarray) - assert isinstance(faces.get("scores", None), np.ndarray) - assert np.equal(faces["boxes"], bbox.round()).all() - assert np.equal(faces["landmarks"], kpss).all() - assert np.equal(faces["scores"], scores).all() - det_model.detect.assert_called_once() + assert set(faces) == {"boxes", "scores", "landmarks"} + # NMS returns highest score first + assert faces["boxes"].tolist() == [expected_box(10, 10), expected_box(50, 50)] + assert np.allclose(faces["scores"], [0.9, 0.8]) + assert faces["landmarks"].shape == (2, 5, 2) + assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10)) + assert np.allclose(faces["landmarks"][1], expected_landmarks(50, 50)) - def test_recognition(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None: + def test_detection_applies_min_score_per_request( + self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture + ) -> None: + mocker.patch.object(FaceDetector, "load") + face_detector = FaceDetector("buffalo_s", cache_dir="test_cache") + + session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9), (50, 50, 0.5)])) + face_detector.session = session + + # the threshold is a request parameter, so the same loaded model must honour both + assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.7)["boxes"].shape[0] == 1 + assert face_detector.predict(Image.new("RGB", (640, 640)), minScore=0.4)["boxes"].shape[0] == 2 + + def test_detection_scales_boxes_back_to_the_original_image( + self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture + ) -> None: + mocker.patch.object(FaceDetector, "load") + face_detector = FaceDetector("buffalo_s", cache_dir="test_cache") + + session = stub_session((1, 3, 640, 640), outputs=make_scrfd_heads([(10, 10, 0.9)])) + face_detector.session = session + + # a 320x320 image is letterboxed up to 640, so coordinates come back halved + faces = face_detector.predict(Image.new("RGB", (320, 320)), minScore=0.7) + + assert faces["boxes"].tolist() == [[v / 2 for v in expected_box(10, 10)]] + assert np.allclose(faces["landmarks"][0], expected_landmarks(10, 10) / 2) + + def test_recognition(self, stub_session: Callable[..., mock.Mock], mocker: MockerFixture) -> None: mocker.patch.object(FaceRecognizer, "load") mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", return_value=["CPUExecutionProvider"], ) - face_recognizer = FaceRecognizer("buffalo_s", min_score=0.0, cache_dir="test_cache") + face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache") + # a uniform grey image whose crops land wholly inside it, so every sampled + # pixel is 128 and the normalised value the session receives is exact + image = Image.new("RGB", (600, 800), (128, 128, 128)) num_faces = 2 + arcface_dst = np.array( + [[38.2946, 51.6963], [73.5318, 51.5014], [56.0252, 71.7366], [41.5493, 92.3655], [70.7299, 92.2041]], + dtype=np.float32, + ) + kpss = np.stack([arcface_dst * 2 + [200, 300], arcface_dst * 2 + [220, 320]]).astype(np.float32) bbox = np.random.rand(num_faces, 4).astype(np.float32) scores = np.array([0.67] * num_faces).astype(np.float32) - kpss = np.random.rand(num_faces, 5, 2).astype(np.float32) - faces = {"boxes": bbox, "landmarks": kpss, "scores": scores} + embeddings = np.random.rand(num_faces, 512).astype(np.float32) - rec_model = mock.Mock() - embedding = np.random.rand(num_faces, 512).astype(np.float32) - rec_model.get_feat.return_value = embedding - face_recognizer.model = rec_model + session = stub_session(("batch", 3, 112, 112), outputs=[embeddings]) + face_recognizer.session = session - faces = face_recognizer.predict(cv_image, faces) + faces = face_recognizer.predict(image, {"boxes": bbox, "landmarks": kpss, "scores": scores}) assert isinstance(faces, list) assert len(faces) == num_faces for face in faces: - assert isinstance(face.get("boundingBox"), dict) assert set(face["boundingBox"]) == {"x1", "y1", "x2", "y2"} assert all(isinstance(val, np.float32) for val in face["boundingBox"].values()) - embedding_str = face.get("embedding") - assert isinstance(embedding_str, str) - embedding = orjson.loads(embedding_str) + embedding = orjson.loads(face["embedding"]) assert isinstance(embedding, list) assert len(embedding) == 512 assert isinstance(face.get("score", None), np.float32) - rec_model.get_feat.assert_called_once() - call_args = rec_model.get_feat.call_args_list[0].args - assert len(call_args) == 1 - assert isinstance(call_args[0], list) - assert isinstance(call_args[0][0], np.ndarray) - assert call_args[0][0].shape == (112, 112, 3) + session.run.assert_called_once() + crops = session.run.call_args.args[1]["input.1"] + assert crops.shape == (num_faces, 3, 112, 112) + assert crops.dtype == np.float32 + # mean/std 127.5, not raw 0-255. atol is loose enough for the float32 cancellation + # in normalize's scale-then-subtract, but still rejects a wrong mean or std + assert np.allclose(crops, (128 - 127.5) / 127.5, atol=1e-6) + + def test_recognition_returns_early_without_faces(self, pil_image: Image.Image, mocker: MockerFixture) -> None: + mocker.patch.object(FaceRecognizer, "load") + mocker.patch( + "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", + return_value=["CPUExecutionProvider"], + ) + face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache") + session = mock.Mock() + face_recognizer.session = session + + empty = { + "boxes": np.empty((0, 4), dtype=np.float32), + "landmarks": np.empty((0, 5, 2), dtype=np.float32), + "scores": np.empty(0, dtype=np.float32), + } + + assert face_recognizer.predict(pil_image, empty) == [] + session.run.assert_not_called() + + def test_recognition_batches_when_batch_size_is_set( + self, pil_image: Image.Image, stub_session: Callable[..., mock.Mock], mocker: MockerFixture + ) -> None: + mocker.patch.object(FaceRecognizer, "load") + mocker.patch( + "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", + return_value=["CPUExecutionProvider"], + ) + face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache") + face_recognizer.batch_size = 2 + + num_faces = 5 + session = stub_session((1, 3, 112, 112)) + session.run.side_effect = lambda _, feed: [np.zeros((feed["input.1"].shape[0], 512), dtype=np.float32)] + face_recognizer.session = session + + faces = { + "boxes": np.random.rand(num_faces, 4).astype(np.float32), + "landmarks": (np.random.rand(num_faces, 5, 2) * 100).astype(np.float32), + "scores": np.array([0.67] * num_faces, dtype=np.float32), + } + assert len(face_recognizer.predict(pil_image, faces)) == num_faces + assert session.run.call_count == 3 # 2 + 2 + 1 + assert [c.args[1]["input.1"].shape[0] for c in session.run.call_args_list] == [2, 2, 1] def test_recognition_adds_batch_axis_for_ort( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + self, batch_axis: SimpleNamespace, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", return_value=["CPUExecutionProvider"], @@ -884,24 +983,20 @@ class TestFaceRecognition: output_dims.type.tensor_type.shape.dim = [SimpleNamespace(dim_value=size) for size in [1, 800]] proto.graph.output = [output_dims] - onnx.load.return_value = proto + batch_axis.onnx.load.return_value = proto face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) face_recognizer.load() assert face_recognizer.batch_size is None - update_dims.assert_called_once_with(proto, {"input.1": ["batch", 3, 224, 224]}, {"output.1": ["batch", 800]}) - onnx.save.assert_called_once_with(update_dims.return_value, face_recognizer.model_path) + batch_axis.update_dims.assert_called_once_with( + proto, {"input.1": ["batch", 3, 224, 224]}, {"output.1": ["batch", 800]} + ) + batch_axis.onnx.save.assert_called_once_with(batch_axis.update_dims.return_value, face_recognizer.model_path) def test_recognition_does_not_add_batch_axis_if_exists( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + self, batch_axis: SimpleNamespace, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", return_value=["CPUExecutionProvider"], @@ -916,88 +1011,53 @@ class TestFaceRecognition: face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) face_recognizer.load() + # batching is available here, so the axis is only skipped because it already exists assert face_recognizer.batch_size is None - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() + batch_axis.update_dims.assert_not_called() + batch_axis.onnx.load.assert_not_called() + batch_axis.onnx.save.assert_not_called() - def test_recognition_does_not_add_batch_axis_for_armnn( - self, ann_session: mock.Mock, path: mock.Mock, mocker: MockerFixture + @pytest.mark.parametrize( + ("session_fixture", "suffix", "model_kwargs", "available_providers"), + [ + pytest.param("ann_session", ".armnn", {"model_format": ModelFormat.ARMNN}, None, id="armnn"), + pytest.param( + "ort_session", ".onnx", {}, ["OpenVINOExecutionProvider", "CPUExecutionProvider"], id="openvino" + ), + pytest.param( + "ort_session", ".onnx", {}, ["MIGraphXExecutionProvider", "CPUExecutionProvider"], id="migraphx" + ), + ], + ) + def test_recognition_does_not_add_batch_axis_when_batching_is_unsupported( + self, + request: pytest.FixtureRequest, + batch_axis: SimpleNamespace, + path: mock.Mock, + ort_pybind: mock.Mock, + mocker: MockerFixture, + session_fixture: str, + suffix: str, + model_kwargs: dict[str, Any], + available_providers: list[str] | None, ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".armnn" - - inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))] - outputs = [SimpleNamespace(name="output.1", shape=("batch", 800))] - ann_session.return_value.get_inputs.return_value = inputs - ann_session.return_value.get_outputs.return_value = outputs - - face_recognizer = FaceRecognizer("buffalo_s", model_format=ModelFormat.ARMNN, cache_dir=path) - face_recognizer.load() - - assert face_recognizer.batch_size == 1 - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() - - def test_recognition_does_not_add_batch_axis_for_openvino( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture - ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" - - inputs = [SimpleNamespace(name="input.1", shape=("batch", 3, 224, 224))] - outputs = [SimpleNamespace(name="output.1", shape=("batch", 800))] - ort_session.return_value.get_inputs.return_value = inputs - ort_session.return_value.get_outputs.return_value = outputs - - face_recognizer = FaceRecognizer( - "buffalo_s", model_format=ModelFormat.ARMNN, cache_dir=path, providers=["OpenVINOExecutionProvider"] - ) - face_recognizer.load() - - assert face_recognizer.batch_size == 1 - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() - - def test_recognition_does_not_add_batch_axis_for_migraphx( - self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture - ) -> None: - onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True) - update_dims = mocker.patch( - "immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True - ) - mocker.patch("immich_ml.models.base.InferenceModel.download") - mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX") + session = request.getfixturevalue(session_fixture) + ort_pybind.get_available_openvino_device_ids.return_value = ["CPU"] mocker.patch( "immich_ml.models.facial_recognition.recognition.ort.get_available_providers", - return_value=["MIGraphXExecutionProvider", "CPUExecutionProvider"], + return_value=available_providers, ) - path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx" + path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = suffix + session.return_value.get_inputs.return_value = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))] + session.return_value.get_outputs.return_value = [SimpleNamespace(name="output.1", shape=(1, 800))] - inputs = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))] - outputs = [SimpleNamespace(name="output.1", shape=(1, 800))] - ort_session.return_value.get_inputs.return_value = inputs - ort_session.return_value.get_outputs.return_value = outputs - - face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path) + face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path, **model_kwargs) face_recognizer.load() assert face_recognizer.batch_size == 1 - update_dims.assert_not_called() - onnx.load.assert_not_called() - onnx.save.assert_not_called() + batch_axis.update_dims.assert_not_called() + batch_axis.onnx.load.assert_not_called() + batch_axis.onnx.save.assert_not_called() def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None: mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2)) @@ -1045,10 +1105,10 @@ class TestOcr: rapid_recognizer.assert_called_once_with( OcrOptions( - session=ort_session.return_value, - rec_batch_num=6, - rec_img_shape=(3, 48, 320), - model_root_dir=text_recognizer.cache_dir, + session=ort_session.return_value, + rec_batch_num=6, + rec_img_shape=(3, 48, 320), + model_root_dir=text_recognizer.cache_dir, ) ) @@ -1063,10 +1123,10 @@ class TestOcr: rapid_recognizer.assert_called_once_with( OcrOptions( - session=ort_session.return_value, - rec_batch_num=4, - rec_img_shape=(3, 48, 320), - model_root_dir=text_recognizer.cache_dir, + session=ort_session.return_value, + rec_batch_num=4, + rec_img_shape=(3, 48, 320), + model_root_dir=text_recognizer.cache_dir, ) ) @@ -1083,10 +1143,10 @@ class TestOcr: rapid_recognizer.assert_called_once_with( OcrOptions( - session=ort_session.return_value, - rec_batch_num=6, - rec_img_shape=(3, 48, 320), - model_root_dir=text_recognizer.cache_dir, + session=ort_session.return_value, + rec_batch_num=6, + rec_img_shape=(3, 48, 320), + model_root_dir=text_recognizer.cache_dir, ) ) diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 8a04d34711..fa92147445 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -25,23 +25,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/d7/15d67e05b235d1ed8c3ce61688fe4d84130e72af1657acadfaac3479f4cf/aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d", size = 28199, upload-time = "2024-09-25T13:20:22.688Z" }, ] -[[package]] -name = "albumentations" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "pyyaml" }, - { name = "qudida" }, - { name = "scikit-image" }, - { name = "scipy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/d6/8dd5b690d28a332a0b2c3179a345808b5d4c7ad5ddc079b7e116098dff35/albumentations-1.3.1.tar.gz", hash = "sha256:a6a38388fe546c568071e8c82f414498e86c9ed03c08b58e7a88b31cf7a244c6", size = 176371, upload-time = "2023-06-10T07:44:32.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/f6/c486cedb4f75147232f32ec4c97026714cfef7c7e247a1f0427bc5489f66/albumentations-1.3.1-py3-none-any.whl", hash = "sha256:6b641d13733181d9ecdc29550e6ad580d1bfa9d25e2213a66940062f25e291bd", size = 125706, upload-time = "2023-06-10T07:44:30.373Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -326,88 +309,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size = 25607, upload-time = "2025-05-23T14:26:15.923Z" }, ] -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - [[package]] name = "coverage" version = "7.13.4" @@ -512,46 +413,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - -[[package]] -name = "cython" -version = "3.0.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/09/ffb61f29b8e3d207c444032b21328327d753e274ea081bc74e009827cc81/Cython-3.0.8.tar.gz", hash = "sha256:8333423d8fd5765e7cceea3a9985dd1e0a5dfeb2734629e1a2ed2d6233d39de6", size = 2744096, upload-time = "2024-01-10T11:01:02.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/a7/f4a0bc9a80e23b380daa2ebb4879bf434aaa0b3b91f7ad8a7f9762b4bd1b/Cython-3.0.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aae26f9663e50caf9657148403d9874eea41770ecdd6caf381d177c2b1bb82ba", size = 3113615, upload-time = "2024-01-10T11:34:05.899Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/e9295df74246c165b91253a473bfa179debf739c9bee961cbb3ae56c2b79/Cython-3.0.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:547eb3cdb2f8c6f48e6865d5a741d9dd051c25b3ce076fbca571727977b28ac3", size = 3436320, upload-time = "2024-01-10T11:02:08.689Z" }, - { url = "https://files.pythonhosted.org/packages/26/2c/6a887c957aa53e44f928119dea628a5dfacc8e875424034f5fecac9daba4/Cython-3.0.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a567d4b9ba70b26db89d75b243529de9e649a2f56384287533cf91512705bee", size = 3591755, upload-time = "2024-01-10T11:02:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b8/f9c97bae6281da50b3ecb1f7fef0f7f7851eae084609b364717a2b366bf1/Cython-3.0.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51d1426263b0e82fb22bda8ea60dc77a428581cc19e97741011b938445d383f1", size = 3636099, upload-time = "2024-01-10T11:02:15.191Z" }, - { url = "https://files.pythonhosted.org/packages/17/ae/cd055c2c081c67a6fcad1d8d17d82bd6395b14c6741e3a938f40318c8bc5/Cython-3.0.8-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c26daaeccda072459b48d211415fd1e5507c06bcd976fa0d5b8b9f1063467d7b", size = 3458119, upload-time = "2024-01-10T11:02:19.103Z" }, - { url = "https://files.pythonhosted.org/packages/72/ab/ac6f5548d6194f4bb2fc8c6c996aa7369f0fa1403e4d4de787d9e9309b27/Cython-3.0.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:289ce7838208211cd166e975865fd73b0649bf118170b6cebaedfbdaf4a37795", size = 3614418, upload-time = "2024-01-10T11:02:22.732Z" }, - { url = "https://files.pythonhosted.org/packages/70/e2/3e3e448b7a94887bec3235bcb71957b6681dc42b4536459f8f54d46fa936/Cython-3.0.8-cp311-cp311-win32.whl", hash = "sha256:c8aa05f5e17f8042a3be052c24f2edc013fb8af874b0bf76907d16c51b4e7871", size = 2572819, upload-time = "2024-01-10T11:02:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/85/7d/58635941dfbb5b4e197adb88080b9cbfb230dc3b75683698a530a1989bdb/Cython-3.0.8-cp311-cp311-win_amd64.whl", hash = "sha256:000dc9e135d0eec6ecb2b40a5b02d0868a2f8d2e027a41b0fe16a908a9e6de02", size = 2784167, upload-time = "2024-01-10T11:02:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8e/28f8c6109990eef7317ab7e43644092b49a88a39f9373dcd19318946df09/Cython-3.0.8-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:90d3fe31db55685d8cb97d43b0ec39ef614fcf660f83c77ed06aa670cb0e164f", size = 3135638, upload-time = "2024-01-10T11:34:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/83/1f/4720cb682b8ed1ab9749dea35351a66dd29b6a022628cce038415660c384/Cython-3.0.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e24791ddae2324e88e3c902a765595c738f19ae34ee66bfb1a6dac54b1833419", size = 3340052, upload-time = "2024-01-10T11:02:32.471Z" }, - { url = "https://files.pythonhosted.org/packages/8a/47/ec3fceb9e8f7d6fa130216b8740038e1df7c8e5f215bba363fcf1272a6c1/Cython-3.0.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f020fa1c0552052e0660790b8153b79e3fc9a15dbd8f1d0b841fe5d204a6ae6", size = 3510079, upload-time = "2024-01-10T11:02:35.312Z" }, - { url = "https://files.pythonhosted.org/packages/71/31/b458127851e248effb909e2791b55870914863cde7c60b94db5ee65d7867/Cython-3.0.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18bfa387d7a7f77d7b2526af69a65dbd0b731b8d941aaff5becff8e21f6d7717", size = 3573972, upload-time = "2024-01-10T11:02:39.044Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d5/ca6513844d0634abd05ba12304053a454bb70441a9520afa9897d4300156/Cython-3.0.8-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fe81b339cffd87c0069c6049b4d33e28bdd1874625ee515785bf42c9fdff3658", size = 3356158, upload-time = "2024-01-10T11:02:42.125Z" }, - { url = "https://files.pythonhosted.org/packages/33/59/98a87b6264f4ad45c820db13c4ec657567476efde020c49443cc842a86af/Cython-3.0.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:80fd94c076e1e1b1ee40a309be03080b75f413e8997cddcf401a118879863388", size = 3522312, upload-time = "2024-01-10T11:02:45.056Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cb/132115d07a0b9d4f075e0741db70a5416b424dcd875b2bb0dd805e818222/Cython-3.0.8-cp312-cp312-win32.whl", hash = "sha256:85077915a93e359a9b920280d214dc0cf8a62773e1f3d7d30fab8ea4daed670c", size = 2602579, upload-time = "2024-01-10T11:02:48.368Z" }, - { url = "https://files.pythonhosted.org/packages/b4/69/cb4620287cd9ef461103e122c0a2ae7f7ecf183e02510676fb5a15c95b05/Cython-3.0.8-cp312-cp312-win_amd64.whl", hash = "sha256:0cb2dcc565c7851f75d496f724a384a790fab12d1b82461b663e66605bec429a", size = 2791268, upload-time = "2024-01-10T11:02:51.483Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7f/f584f5d15323feb897d42ef0e9d910649e2150d7a30cf7e7a8cc1d236e6f/Cython-3.0.8-py2.py3-none-any.whl", hash = "sha256:171b27051253d3f9108e9759e504ba59ff06e7f7ba944457f94deaf9c21bf0b6", size = 1168213, upload-time = "2024-01-10T11:00:56.857Z" }, -] - -[[package]] -name = "easydict" -version = "1.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/d2/deb3296d08097fedd622d423c0ec8b68b78c1704b3f1545326f6ce05c75c/easydict-1.11.tar.gz", hash = "sha256:dcb1d2ed28eb300c8e46cd371340373abc62f7c14d6dea74fdfc6f1069061c78", size = 6644, upload-time = "2023-10-23T23:01:37.686Z" } - [[package]] name = "fastapi" version = "0.136.3" @@ -627,55 +488,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/d5c79ee252793ffe845d58a913197bfa02ae9a0b5c9bc3dc4b58d477b9e7/flatbuffers-23.5.26-py2.py3-none-any.whl", hash = "sha256:c0ff356da363087b915fde4b8b45bdda73432fc17cddb3c8157472eab1422ad1", size = 26744, upload-time = "2023-05-26T17:35:14.269Z" }, ] -[[package]] -name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, -] - [[package]] name = "fsspec" version = "2023.12.2" @@ -959,19 +771,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] -[[package]] -name = "imageio" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/38/f4c568318c656352d211eec6954460dc3af0b7583a6682308f8a66e4c19b/imageio-2.33.1.tar.gz", hash = "sha256:78722d40b137bd98f5ec7312119f8aea9ad2049f76f434748eb306b6937cc1ce", size = 387374, upload-time = "2023-12-11T02:26:44.715Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/69/3aaa69cb0748e33e644fda114c9abd3186ce369edd4fca11107e9f39c6a7/imageio-2.33.1-py3-none-any.whl", hash = "sha256:c5094c48ccf6b2e6da8b4061cd95e1209380afafcbeae4a4e280938cce227e1d", size = 313345, upload-time = "2023-12-11T02:26:42.724Z" }, -] - [[package]] name = "immich-ml" version = "3.1.0" @@ -981,8 +780,8 @@ dependencies = [ { name = "fastapi" }, { name = "gunicorn" }, { name = "huggingface-hub" }, - { name = "insightface" }, { name = "numpy" }, + { name = "onnx" }, { name = "opencv-python-headless" }, { name = "orjson" }, { name = "pillow" }, @@ -1062,8 +861,8 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.95.2,<1.0" }, { name = "gunicorn", specifier = ">=21.1.0" }, { name = "huggingface-hub", specifier = ">=1.0,<2.0" }, - { name = "insightface", specifier = ">=0.7.3,<2.0" }, { name = "numpy", specifier = ">=2.4.0,<3.0" }, + { name = "onnx", specifier = ">=1.22.0" }, { name = "onnxruntime", marker = "extra == 'armnn'", 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" }, @@ -1133,27 +932,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] -[[package]] -name = "insightface" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "albumentations" }, - { name = "cython" }, - { name = "easydict" }, - { name = "matplotlib" }, - { name = "numpy" }, - { name = "onnx" }, - { name = "pillow" }, - { name = "prettytable" }, - { name = "requests" }, - { name = "scikit-image" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/8d/0f4af90999ca96cf8cb846eb5ae27c5ef5b390f9c090dd19e4fa76364c13/insightface-0.7.3.tar.gz", hash = "sha256:f191f719612ebb37018f41936814500544cd0f86e6fcd676c023f354c668ddf7", size = 439490, upload-time = "2023-04-02T08:01:54.541Z" } - [[package]] name = "itsdangerous" version = "2.1.2" @@ -1175,65 +953,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271, upload-time = "2024-05-05T23:41:59.928Z" }, ] -[[package]] -name = "joblib" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/0f/d3b33b9f106dddef461f6df1872b7881321b247f3d255b87f61a7636f7fe/joblib-1.3.2.tar.gz", hash = "sha256:92f865e621e17784e7955080b6d042489e3b8e294949cc44c6eac304f59772b1", size = 1987720, upload-time = "2023-08-09T09:23:40.503Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/40/d551139c85db202f1f384ba8bcf96aca2f329440a844f924c8a0040b6d02/joblib-1.3.2-py3-none-any.whl", hash = "sha256:ef4331c65f239985f3f2220ecc87db222f08fd22097a3dd5698f693875f8cbb9", size = 302207, upload-time = "2023-08-09T09:23:34.583Z" }, -] - -[[package]] -name = "kiwisolver" -version = "1.4.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/2d/226779e405724344fc678fcc025b812587617ea1a48b9442628b688e85ea/kiwisolver-1.4.5.tar.gz", hash = "sha256:e57e563a57fb22a142da34f38acc2fc1a5c864bc29ca1517a88abc963e60d6ec", size = 97552, upload-time = "2023-08-24T09:30:39.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/eb/9e099ad7c47c279995d2d20474e1821100a5f10f847739bd65b1c1f02442/kiwisolver-1.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:11863aa14a51fd6ec28688d76f1735f8f69ab1fabf388851a595d0721af042f5", size = 127403, upload-time = "2023-08-24T09:28:39.3Z" }, - { url = "https://files.pythonhosted.org/packages/a6/94/695922e71288855fc7cace3bdb52edda9d7e50edba77abb0c9d7abb51e96/kiwisolver-1.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8ab3919a9997ab7ef2fbbed0cc99bb28d3c13e6d4b1ad36e97e482558a91be90", size = 68156, upload-time = "2023-08-24T09:28:40.301Z" }, - { url = "https://files.pythonhosted.org/packages/4a/fe/23d7fa78f7c66086d196406beb1fb2eaf629dd7adc01c3453033303d17fa/kiwisolver-1.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fcc700eadbbccbf6bc1bcb9dbe0786b4b1cb91ca0dcda336eef5c2beed37b797", size = 66166, upload-time = "2023-08-24T09:28:41.235Z" }, - { url = "https://files.pythonhosted.org/packages/f1/68/f472bf16c9141bb1bea5c0b8c66c68fc1ccb048efdbd8f0872b92125724e/kiwisolver-1.4.5-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfdd7c0b105af050eb3d64997809dc21da247cf44e63dc73ff0fd20b96be55a9", size = 1334300, upload-time = "2023-08-24T09:28:42.409Z" }, - { url = "https://files.pythonhosted.org/packages/8d/26/b4569d1f29751fca22ee915b4ebfef5974f4ef239b3335fc072882bd62d9/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76c6a5964640638cdeaa0c359382e5703e9293030fe730018ca06bc2010c4437", size = 1426579, upload-time = "2023-08-24T09:28:43.677Z" }, - { url = "https://files.pythonhosted.org/packages/f3/a3/804fc7c8bf233806ec0321c9da35971578620f2ab4fafe67d76100b3ce52/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbea0db94288e29afcc4c28afbf3a7ccaf2d7e027489c449cf7e8f83c6346eb9", size = 1541360, upload-time = "2023-08-24T09:28:45.939Z" }, - { url = "https://files.pythonhosted.org/packages/07/ef/286e1d26524854f6fbd6540e8364d67a8857d61038ac743e11edc42fe217/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ceec1a6bc6cab1d6ff5d06592a91a692f90ec7505d6463a88a52cc0eb58545da", size = 1470091, upload-time = "2023-08-24T09:28:47.959Z" }, - { url = "https://files.pythonhosted.org/packages/17/ba/17a706b232308e65f57deeccae503c268292e6a091313f6ce833a23093ea/kiwisolver-1.4.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:040c1aebeda72197ef477a906782b5ab0d387642e93bda547336b8957c61022e", size = 1426259, upload-time = "2023-08-24T09:28:49.224Z" }, - { url = "https://files.pythonhosted.org/packages/d0/f3/a0925611c9d6c2f37c5935a39203cadec6883aa914e013b46c84c4c2e641/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f91de7223d4c7b793867797bacd1ee53bfe7359bd70d27b7b58a04efbb9436c8", size = 1847516, upload-time = "2023-08-24T09:28:50.979Z" }, - { url = "https://files.pythonhosted.org/packages/da/85/82d59bb8f7c4c9bb2785138b72462cb1b161668f8230c58bbb28c0403cd5/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:faae4860798c31530dd184046a900e652c95513796ef51a12bc086710c2eec4d", size = 1946228, upload-time = "2023-08-24T09:28:52.812Z" }, - { url = "https://files.pythonhosted.org/packages/34/3c/6a37f444c0233993881e5db3a6a1775925d4d9d2f2609bb325bb1348ed94/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:b0157420efcb803e71d1b28e2c287518b8808b7cf1ab8af36718fd0a2c453eb0", size = 1901716, upload-time = "2023-08-24T09:28:54.115Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7e/180425790efc00adfd47db14e1e341cb4826516982334129012b971121a6/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:06f54715b7737c2fecdbf140d1afb11a33d59508a47bf11bb38ecf21dc9ab79f", size = 1852871, upload-time = "2023-08-24T09:28:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9a/13c68b2edb1fa74321e60893a9a5829788e135138e68060cf44e2d92d2c3/kiwisolver-1.4.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fdb7adb641a0d13bdcd4ef48e062363d8a9ad4a182ac7647ec88f695e719ae9f", size = 1870265, upload-time = "2023-08-24T09:28:56.855Z" }, - { url = "https://files.pythonhosted.org/packages/9f/0a/fa56a0fdee5da2b4c79899c0f6bd1aefb29d9438c2d66430e78793571c6b/kiwisolver-1.4.5-cp311-cp311-win32.whl", hash = "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac", size = 46649, upload-time = "2023-08-24T09:28:58.021Z" }, - { url = "https://files.pythonhosted.org/packages/1e/37/d3c2d4ba2719059a0f12730947bbe1ad5ee8bff89e8c35319dcb2c9ddb4c/kiwisolver-1.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355", size = 56116, upload-time = "2023-08-24T09:28:58.994Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7a/debbce859be1a2711eb8437818107137192007b88d17b5cfdb556f457b42/kiwisolver-1.4.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:32d5cf40c4f7c7b3ca500f8985eb3fb3a7dfc023215e876f207956b5ea26632a", size = 125484, upload-time = "2023-08-24T09:28:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e0/bf8df75ba93b9e035cc6757dd5dcaf63084fdc1c846ae134e818bd7e0f03/kiwisolver-1.4.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f846c260f483d1fd217fe5ed7c173fb109efa6b1fc8381c8b7552c5781756192", size = 67332, upload-time = "2023-08-24T09:29:01.733Z" }, - { url = "https://files.pythonhosted.org/packages/26/61/58bb691f6880588be3a4801d199bd776032ece07203faf3e4a8b377f7d9b/kiwisolver-1.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ff5cf3571589b6d13bfbfd6bcd7a3f659e42f96b5fd1c4830c4cf21d4f5ef45", size = 64987, upload-time = "2023-08-24T09:29:02.789Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a3/96ac5413068b237c006f54dd8d70114e8756d70e3da7613c5aef20627e22/kiwisolver-1.4.5-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7269d9e5f1084a653d575c7ec012ff57f0c042258bf5db0954bf551c158466e7", size = 1370613, upload-time = "2023-08-24T09:29:03.912Z" }, - { url = "https://files.pythonhosted.org/packages/4d/12/f48539e6e17068b59c7f12f4d6214b973431b8e3ac83af525cafd27cebec/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da802a19d6e15dffe4b0c24b38b3af68e6c1a68e6e1d8f30148c83864f3881db", size = 1463183, upload-time = "2023-08-24T09:29:05.244Z" }, - { url = "https://files.pythonhosted.org/packages/f3/70/26c99be8eb034cc8e3f62e0760af1fbdc97a842a7cbc252f7978507d41c2/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3aba7311af82e335dd1e36ffff68aaca609ca6290c2cb6d821a39aa075d8e3ff", size = 1581248, upload-time = "2023-08-24T09:29:06.531Z" }, - { url = "https://files.pythonhosted.org/packages/17/f6/f75f20e543639b09b2de7fc864274a5a9b96cda167a6210a1d9d19306b9d/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763773d53f07244148ccac5b084da5adb90bfaee39c197554f01b286cf869228", size = 1508815, upload-time = "2023-08-24T09:29:07.867Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/bc0f22ac108743062ab703f8d6d71c9c7b077b8839fa358700bfb81770b8/kiwisolver-1.4.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2270953c0d8cdab5d422bee7d2007f043473f9d2999631c86a223c9db56cbd16", size = 1466042, upload-time = "2023-08-24T09:29:09.403Z" }, - { url = "https://files.pythonhosted.org/packages/75/18/98142500f21d6838bcab49ec919414a1f0c6d049d21ddadf139124db6a70/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d099e745a512f7e3bbe7249ca835f4d357c586d78d79ae8f1dcd4d8adeb9bda9", size = 1885159, upload-time = "2023-08-24T09:29:10.66Z" }, - { url = "https://files.pythonhosted.org/packages/21/49/a241eff9e0ee013368c1d17957f9d345b0957493c3a43d82ebb558c90b0a/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:74db36e14a7d1ce0986fa104f7d5637aea5c82ca6326ed0ec5694280942d1162", size = 1981694, upload-time = "2023-08-24T09:29:12.469Z" }, - { url = "https://files.pythonhosted.org/packages/90/90/9490c3de4788123041b1d600d64434f1eed809a2ce9f688075a22166b289/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e5bab140c309cb3a6ce373a9e71eb7e4873c70c2dda01df6820474f9889d6d4", size = 1941579, upload-time = "2023-08-24T09:29:13.743Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bb/a0cc488ef2aa92d7d304318c8549d3ec8dfe6dd3c2c67a44e3922b77bc4f/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0f114aa76dc1b8f636d077979c0ac22e7cd8f3493abbab152f20eb8d3cda71f3", size = 1888168, upload-time = "2023-08-24T09:29:15.097Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e9/9c0de8e45fef3d63f85eed3b1757f9aa511065942866331ef8b99421f433/kiwisolver-1.4.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:88a2df29d4724b9237fc0c6eaf2a1adae0cdc0b3e9f4d8e7dc54b16812d2d81a", size = 1908464, upload-time = "2023-08-24T09:29:16.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/60/4f0fd50b08f5be536ea0cef518ac7255d9dab43ca40f3b93b60e3ddf80dd/kiwisolver-1.4.5-cp312-cp312-win32.whl", hash = "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20", size = 46473, upload-time = "2023-08-24T09:29:17.956Z" }, - { url = "https://files.pythonhosted.org/packages/63/50/2746566bdf4a6a842d117367d05c90cfb87ac04e9e2845aa1fa21f071362/kiwisolver-1.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9", size = 56004, upload-time = "2023-08-24T09:29:19.329Z" }, -] - -[[package]] -name = "lazy-loader" -version = "0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, -] - [[package]] name = "librt" version = "0.11.0" @@ -1374,70 +1093,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/44/dbaf65876e258facd65f586dde158387ab89963e7f2235551afc9c2e24c2/MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", size = 16979, upload-time = "2023-09-07T16:00:57.77Z" }, ] -[[package]] -name = "matplotlib" -version = "3.10.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/91/f2939bb60b7ebf12478b030e0d7f340247390f402b3b189616aad790c366/matplotlib-3.10.5.tar.gz", hash = "sha256:352ed6ccfb7998a00881692f38b4ca083c691d3e275b4145423704c34c909076", size = 34804044, upload-time = "2025-07-31T18:09:33.805Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/c7/1f2db90a1d43710478bb1e9b57b162852f79234d28e4f48a28cc415aa583/matplotlib-3.10.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dcfc39c452c6a9f9028d3e44d2d721484f665304857188124b505b2c95e1eecf", size = 8239216, upload-time = "2025-07-31T18:07:51.947Z" }, - { url = "https://files.pythonhosted.org/packages/82/6d/ca6844c77a4f89b1c9e4d481c412e1d1dbabf2aae2cbc5aa2da4a1d6683e/matplotlib-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:903352681b59f3efbf4546985142a9686ea1d616bb054b09a537a06e4b892ccf", size = 8102130, upload-time = "2025-07-31T18:07:53.65Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1e/5e187a30cc673a3e384f3723e5f3c416033c1d8d5da414f82e4e731128ea/matplotlib-3.10.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:080c3676a56b8ee1c762bcf8fca3fe709daa1ee23e6ef06ad9f3fc17332f2d2a", size = 8666471, upload-time = "2025-07-31T18:07:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/95540d584d7d645324db99a845ac194e915ef75011a0d5e19e1b5cee7e69/matplotlib-3.10.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b4984d5064a35b6f66d2c11d668565f4389b1119cc64db7a4c1725bc11adffc", size = 9500518, upload-time = "2025-07-31T18:07:57.199Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2e/e019352099ea58b4169adb9c6e1a2ad0c568c6377c2b677ee1f06de2adc7/matplotlib-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3967424121d3a46705c9fa9bdb0931de3228f13f73d7bb03c999c88343a89d89", size = 9552372, upload-time = "2025-07-31T18:07:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b7/81/3200b792a5e8b354f31f4101ad7834743ad07b6d620259f2059317b25e4d/matplotlib-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:33775bbeb75528555a15ac29396940128ef5613cf9a2d31fb1bfd18b3c0c0903", size = 8100634, upload-time = "2025-07-31T18:08:01.801Z" }, - { url = "https://files.pythonhosted.org/packages/52/46/a944f6f0c1f5476a0adfa501969d229ce5ae60cf9a663be0e70361381f89/matplotlib-3.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:c61333a8e5e6240e73769d5826b9a31d8b22df76c0778f8480baf1b4b01c9420", size = 7978880, upload-time = "2025-07-31T18:08:03.407Z" }, - { url = "https://files.pythonhosted.org/packages/66/1e/c6f6bcd882d589410b475ca1fc22e34e34c82adff519caf18f3e6dd9d682/matplotlib-3.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:00b6feadc28a08bd3c65b2894f56cf3c94fc8f7adcbc6ab4516ae1e8ed8f62e2", size = 8253056, upload-time = "2025-07-31T18:08:05.385Z" }, - { url = "https://files.pythonhosted.org/packages/53/e6/d6f7d1b59413f233793dda14419776f5f443bcccb2dfc84b09f09fe05dbe/matplotlib-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee98a5c5344dc7f48dc261b6ba5d9900c008fc12beb3fa6ebda81273602cc389", size = 8110131, upload-time = "2025-07-31T18:08:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/66/2b/bed8a45e74957549197a2ac2e1259671cd80b55ed9e1fe2b5c94d88a9202/matplotlib-3.10.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a17e57e33de901d221a07af32c08870ed4528db0b6059dce7d7e65c1122d4bea", size = 8669603, upload-time = "2025-07-31T18:08:09.064Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a7/315e9435b10d057f5e52dfc603cd353167ae28bb1a4e033d41540c0067a4/matplotlib-3.10.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b9d6443419085950ee4a5b1ee08c363e5c43d7176e55513479e53669e88468", size = 9508127, upload-time = "2025-07-31T18:08:10.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d9/edcbb1f02ca99165365d2768d517898c22c6040187e2ae2ce7294437c413/matplotlib-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ceefe5d40807d29a66ae916c6a3915d60ef9f028ce1927b84e727be91d884369", size = 9566926, upload-time = "2025-07-31T18:08:13.186Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/6dd924ad5616c97b7308e6320cf392c466237a82a2040381163b7500510a/matplotlib-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:c04cba0f93d40e45b3c187c6c52c17f24535b27d545f757a2fffebc06c12b98b", size = 8107599, upload-time = "2025-07-31T18:08:15.116Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f3/522dc319a50f7b0279fbe74f86f7a3506ce414bc23172098e8d2bdf21894/matplotlib-3.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:a41bcb6e2c8e79dc99c5511ae6f7787d2fb52efd3d805fff06d5d4f667db16b2", size = 7978173, upload-time = "2025-07-31T18:08:21.518Z" }, - { url = "https://files.pythonhosted.org/packages/8d/05/4f3c1f396075f108515e45cb8d334aff011a922350e502a7472e24c52d77/matplotlib-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:354204db3f7d5caaa10e5de74549ef6a05a4550fdd1c8f831ab9bca81efd39ed", size = 8253586, upload-time = "2025-07-31T18:08:23.107Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2c/e084415775aac7016c3719fe7006cdb462582c6c99ac142f27303c56e243/matplotlib-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b072aac0c3ad563a2b3318124756cb6112157017f7431626600ecbe890df57a1", size = 8110715, upload-time = "2025-07-31T18:08:24.675Z" }, - { url = "https://files.pythonhosted.org/packages/52/1b/233e3094b749df16e3e6cd5a44849fd33852e692ad009cf7de00cf58ddf6/matplotlib-3.10.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d52fd5b684d541b5a51fb276b2b97b010c75bee9aa392f96b4a07aeb491e33c7", size = 8669397, upload-time = "2025-07-31T18:08:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ec/03f9e003a798f907d9f772eed9b7c6a9775d5bd00648b643ebfb88e25414/matplotlib-3.10.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7a09ae2f4676276f5a65bd9f2bd91b4f9fbaedf49f40267ce3f9b448de501f", size = 9508646, upload-time = "2025-07-31T18:08:28.848Z" }, - { url = "https://files.pythonhosted.org/packages/91/e7/c051a7a386680c28487bca27d23b02d84f63e3d2a9b4d2fc478e6a42e37e/matplotlib-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ba6c3c9c067b83481d647af88b4e441d532acdb5ef22178a14935b0b881188f4", size = 9567424, upload-time = "2025-07-31T18:08:30.726Z" }, - { url = "https://files.pythonhosted.org/packages/36/c2/24302e93ff431b8f4173ee1dd88976c8d80483cadbc5d3d777cef47b3a1c/matplotlib-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:07442d2692c9bd1cceaa4afb4bbe5b57b98a7599de4dabfcca92d3eea70f9ebe", size = 8107809, upload-time = "2025-07-31T18:08:33.928Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/423ec6a668d375dad825197557ed8fbdb74d62b432c1ed8235465945475f/matplotlib-3.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:48fe6d47380b68a37ccfcc94f009530e84d41f71f5dae7eda7c4a5a84aa0a674", size = 7978078, upload-time = "2025-07-31T18:08:36.764Z" }, - { url = "https://files.pythonhosted.org/packages/51/17/521fc16ec766455c7bb52cc046550cf7652f6765ca8650ff120aa2d197b6/matplotlib-3.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b80eb8621331449fc519541a7461987f10afa4f9cfd91afcd2276ebe19bd56c", size = 8295590, upload-time = "2025-07-31T18:08:38.521Z" }, - { url = "https://files.pythonhosted.org/packages/f8/12/23c28b2c21114c63999bae129fce7fd34515641c517ae48ce7b7dcd33458/matplotlib-3.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47a388908e469d6ca2a6015858fa924e0e8a2345a37125948d8e93a91c47933e", size = 8158518, upload-time = "2025-07-31T18:08:40.195Z" }, - { url = "https://files.pythonhosted.org/packages/81/f8/aae4eb25e8e7190759f3cb91cbeaa344128159ac92bb6b409e24f8711f78/matplotlib-3.10.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b6b49167d208358983ce26e43aa4196073b4702858670f2eb111f9a10652b4b", size = 8691815, upload-time = "2025-07-31T18:08:42.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ba/450c39ebdd486bd33a359fc17365ade46c6a96bf637bbb0df7824de2886c/matplotlib-3.10.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a8da0453a7fd8e3da114234ba70c5ba9ef0e98f190309ddfde0f089accd46ea", size = 9522814, upload-time = "2025-07-31T18:08:44.914Z" }, - { url = "https://files.pythonhosted.org/packages/89/11/9c66f6a990e27bb9aa023f7988d2d5809cb98aa39c09cbf20fba75a542ef/matplotlib-3.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52c6573dfcb7726a9907b482cd5b92e6b5499b284ffacb04ffbfe06b3e568124", size = 9573917, upload-time = "2025-07-31T18:08:47.038Z" }, - { url = "https://files.pythonhosted.org/packages/b3/69/8b49394de92569419e5e05e82e83df9b749a0ff550d07631ea96ed2eb35a/matplotlib-3.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:a23193db2e9d64ece69cac0c8231849db7dd77ce59c7b89948cf9d0ce655a3ce", size = 8181034, upload-time = "2025-07-31T18:08:48.943Z" }, - { url = "https://files.pythonhosted.org/packages/47/23/82dc435bb98a2fc5c20dffcac8f0b083935ac28286413ed8835df40d0baa/matplotlib-3.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:56da3b102cf6da2776fef3e71cd96fcf22103a13594a18ac9a9b31314e0be154", size = 8023337, upload-time = "2025-07-31T18:08:50.791Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/26b6cfde31f5383503ee45dcb7e691d45dadf0b3f54639332b59316a97f8/matplotlib-3.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:96ef8f5a3696f20f55597ffa91c28e2e73088df25c555f8d4754931515512715", size = 8253591, upload-time = "2025-07-31T18:08:53.254Z" }, - { url = "https://files.pythonhosted.org/packages/c1/89/98488c7ef7ea20ea659af7499628c240a608b337af4be2066d644cfd0a0f/matplotlib-3.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:77fab633e94b9da60512d4fa0213daeb76d5a7b05156840c4fd0399b4b818837", size = 8112566, upload-time = "2025-07-31T18:08:55.116Z" }, - { url = "https://files.pythonhosted.org/packages/52/67/42294dfedc82aea55e1a767daf3263aacfb5a125f44ba189e685bab41b6f/matplotlib-3.10.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27f52634315e96b1debbfdc5c416592edcd9c4221bc2f520fd39c33db5d9f202", size = 9513281, upload-time = "2025-07-31T18:08:56.885Z" }, - { url = "https://files.pythonhosted.org/packages/e7/68/f258239e0cf34c2cbc816781c7ab6fca768452e6bf1119aedd2bd4a882a3/matplotlib-3.10.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:525f6e28c485c769d1f07935b660c864de41c37fd716bfa64158ea646f7084bb", size = 9780873, upload-time = "2025-07-31T18:08:59.241Z" }, - { url = "https://files.pythonhosted.org/packages/89/64/f4881554006bd12e4558bd66778bdd15d47b00a1f6c6e8b50f6208eda4b3/matplotlib-3.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1f5f3ec4c191253c5f2b7c07096a142c6a1c024d9f738247bfc8e3f9643fc975", size = 9568954, upload-time = "2025-07-31T18:09:01.244Z" }, - { url = "https://files.pythonhosted.org/packages/06/f8/42779d39c3f757e1f012f2dda3319a89fb602bd2ef98ce8faf0281f4febd/matplotlib-3.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:707f9c292c4cd4716f19ab8a1f93f26598222cd931e0cd98fbbb1c5994bf7667", size = 8237465, upload-time = "2025-07-31T18:09:03.206Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f8/153fd06b5160f0cd27c8b9dd797fcc9fb56ac6a0ebf3c1f765b6b68d3c8a/matplotlib-3.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:21a95b9bf408178d372814de7baacd61c712a62cae560b5e6f35d791776f6516", size = 8108898, upload-time = "2025-07-31T18:09:05.231Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/c4b082a382a225fe0d2a73f1f57cf6f6f132308805b493a54c8641006238/matplotlib-3.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a6b310f95e1102a8c7c817ef17b60ee5d1851b8c71b63d9286b66b177963039e", size = 8295636, upload-time = "2025-07-31T18:09:07.306Z" }, - { url = "https://files.pythonhosted.org/packages/30/73/2195fa2099718b21a20da82dfc753bf2af58d596b51aefe93e359dd5915a/matplotlib-3.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94986a242747a0605cb3ff1cb98691c736f28a59f8ffe5175acaeb7397c49a5a", size = 8158575, upload-time = "2025-07-31T18:09:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/f6/e9/a08cdb34618a91fa08f75e6738541da5cacde7c307cea18ff10f0d03fcff/matplotlib-3.10.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ff10ea43288f0c8bab608a305dc6c918cc729d429c31dcbbecde3b9f4d5b569", size = 9522815, upload-time = "2025-07-31T18:09:11.191Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/34d8b7e0d1bb6d06ef45db01dfa560d5a67b1c40c0b998ce9ccde934bb09/matplotlib-3.10.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6adb644c9d040ffb0d3434e440490a66cf73dbfa118a6f79cd7568431f7a012", size = 9783514, upload-time = "2025-07-31T18:09:13.307Z" }, - { url = "https://files.pythonhosted.org/packages/12/09/d330d1e55dcca2e11b4d304cc5227f52e2512e46828d6249b88e0694176e/matplotlib-3.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fa40a8f98428f789a9dcacd625f59b7bc4e3ef6c8c7c80187a7a709475cf592", size = 9573932, upload-time = "2025-07-31T18:09:15.335Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3b/f70258ac729aa004aca673800a53a2b0a26d49ca1df2eaa03289a1c40f81/matplotlib-3.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:95672a5d628b44207aab91ec20bf59c26da99de12b88f7e0b1fb0a84a86ff959", size = 8322003, upload-time = "2025-07-31T18:09:17.416Z" }, - { url = "https://files.pythonhosted.org/packages/5b/60/3601f8ce6d76a7c81c7f25a0e15fde0d6b66226dd187aa6d2838e6374161/matplotlib-3.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:2efaf97d72629e74252e0b5e3c46813e9eeaa94e011ecf8084a971a31a97f40b", size = 8153849, upload-time = "2025-07-31T18:09:19.673Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/e921be4e1a5f7aca5194e1f016cb67ec294548e530013251f630713e456d/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:160e125da27a749481eaddc0627962990f6029811dbeae23881833a011a0907f", size = 8233224, upload-time = "2025-07-31T18:09:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/ec/74/a2b9b04824b9c349c8f1b2d21d5af43fa7010039427f2b133a034cb09e59/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac3d50760394d78a3c9be6b28318fe22b494c4fcf6407e8fd4794b538251899b", size = 8098539, upload-time = "2025-07-31T18:09:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1449,39 +1104,43 @@ wheels = [ [[package]] name = "ml-dtypes" -version = "0.5.3" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/a7/aad060393123cfb383956dca68402aff3db1e1caffd5764887ed5153f41b/ml_dtypes-0.5.3.tar.gz", hash = "sha256:95ce33057ba4d05df50b1f3cfefab22e351868a843b3b15a46c65836283670c9", size = 692316, upload-time = "2025-07-29T18:39:19.454Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/f1/720cb1409b5d0c05cff9040c0e9fba73fa4c67897d33babf905d5d46a070/ml_dtypes-0.5.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a177b882667c69422402df6ed5c3428ce07ac2c1f844d8a1314944651439458", size = 667412, upload-time = "2025-07-29T18:38:25.275Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d5/05861ede5d299f6599f86e6bc1291714e2116d96df003cfe23cc54bcc568/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9849ce7267444c0a717c80c6900997de4f36e2815ce34ac560a3edb2d9a64cd2", size = 4964606, upload-time = "2025-07-29T18:38:27.045Z" }, - { url = "https://files.pythonhosted.org/packages/db/dc/72992b68de367741bfab8df3b3fe7c29f982b7279d341aa5bf3e7ef737ea/ml_dtypes-0.5.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f5ae0309d9f888fd825c2e9d0241102fadaca81d888f26f845bc8c13c1e4ee", size = 4938435, upload-time = "2025-07-29T18:38:29.193Z" }, - { url = "https://files.pythonhosted.org/packages/81/1c/d27a930bca31fb07d975a2d7eaf3404f9388114463b9f15032813c98f893/ml_dtypes-0.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:58e39349d820b5702bb6f94ea0cb2dc8ec62ee81c0267d9622067d8333596a46", size = 206334, upload-time = "2025-07-29T18:38:30.687Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d8/6922499effa616012cb8dc445280f66d100a7ff39b35c864cfca019b3f89/ml_dtypes-0.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:66c2756ae6cfd7f5224e355c893cfd617fa2f747b8bbd8996152cbdebad9a184", size = 157584, upload-time = "2025-07-29T18:38:32.187Z" }, - { url = "https://files.pythonhosted.org/packages/0d/eb/bc07c88a6ab002b4635e44585d80fa0b350603f11a2097c9d1bfacc03357/ml_dtypes-0.5.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:156418abeeda48ea4797db6776db3c5bdab9ac7be197c1233771e0880c304057", size = 663864, upload-time = "2025-07-29T18:38:33.777Z" }, - { url = "https://files.pythonhosted.org/packages/cf/89/11af9b0f21b99e6386b6581ab40fb38d03225f9de5f55cf52097047e2826/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1db60c154989af253f6c4a34e8a540c2c9dce4d770784d426945e09908fbb177", size = 4951313, upload-time = "2025-07-29T18:38:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a9/b98b86426c24900b0c754aad006dce2863df7ce0bb2bcc2c02f9cc7e8489/ml_dtypes-0.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b255acada256d1fa8c35ed07b5f6d18bc21d1556f842fbc2d5718aea2cd9e55", size = 4928805, upload-time = "2025-07-29T18:38:38.29Z" }, - { url = "https://files.pythonhosted.org/packages/50/c1/85e6be4fc09c6175f36fb05a45917837f30af9a5146a5151cb3a3f0f9e09/ml_dtypes-0.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:da65e5fd3eea434ccb8984c3624bc234ddcc0d9f4c81864af611aaebcc08a50e", size = 208182, upload-time = "2025-07-29T18:38:39.72Z" }, - { url = "https://files.pythonhosted.org/packages/9e/17/cf5326d6867be057f232d0610de1458f70a8ce7b6290e4b4a277ea62b4cd/ml_dtypes-0.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:8bb9cd1ce63096567f5f42851f5843b5a0ea11511e50039a7649619abfb4ba6d", size = 161560, upload-time = "2025-07-29T18:38:41.072Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/1bcc98a66de7b2455dfb292f271452cac9edc4e870796e0d87033524d790/ml_dtypes-0.5.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5103856a225465371fe119f2fef737402b705b810bd95ad5f348e6e1a6ae21af", size = 663781, upload-time = "2025-07-29T18:38:42.984Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2c/bd2a79ba7c759ee192b5601b675b180a3fd6ccf48ffa27fe1782d280f1a7/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cae435a68861660af81fa3c5af16b70ca11a17275c5b662d9c6f58294e0f113", size = 4956217, upload-time = "2025-07-29T18:38:44.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/f3/091ba84e5395d7fe5b30c081a44dec881cd84b408db1763ee50768b2ab63/ml_dtypes-0.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6936283b56d74fbec431ca57ce58a90a908fdbd14d4e2d22eea6d72bb208a7b7", size = 4933109, upload-time = "2025-07-29T18:38:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/bc/24/054036dbe32c43295382c90a1363241684c4d6aaa1ecc3df26bd0c8d5053/ml_dtypes-0.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:d0f730a17cf4f343b2c7ad50cee3bd19e969e793d2be6ed911f43086460096e4", size = 208187, upload-time = "2025-07-29T18:38:48.24Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/7dc3ec6794a4a9004c765e0c341e32355840b698f73fd2daff46f128afc1/ml_dtypes-0.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:2db74788fc01914a3c7f7da0763427280adfc9cd377e9604b6b64eb8097284bd", size = 161559, upload-time = "2025-07-29T18:38:50.493Z" }, - { url = "https://files.pythonhosted.org/packages/12/91/e6c7a0d67a152b9330445f9f0cf8ae6eee9b83f990b8c57fe74631e42a90/ml_dtypes-0.5.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93c36a08a6d158db44f2eb9ce3258e53f24a9a4a695325a689494f0fdbc71770", size = 689321, upload-time = "2025-07-29T18:38:52.03Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6c/b7b94b84a104a5be1883305b87d4c6bd6ae781504474b4cca067cb2340ec/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e44a3761f64bc009d71ddb6d6c71008ba21b53ab6ee588dadab65e2fa79eafc", size = 5274495, upload-time = "2025-07-29T18:38:53.797Z" }, - { url = "https://files.pythonhosted.org/packages/5b/38/6266604dffb43378055394ea110570cf261a49876fc48f548dfe876f34cc/ml_dtypes-0.5.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdf40d2aaabd3913dec11840f0d0ebb1b93134f99af6a0a4fd88ffe924928ab4", size = 5285422, upload-time = "2025-07-29T18:38:56.603Z" }, - { url = "https://files.pythonhosted.org/packages/7c/88/8612ff177d043a474b9408f0382605d881eeb4125ba89d4d4b3286573a83/ml_dtypes-0.5.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:aec640bd94c4c85c0d11e2733bd13cbb10438fb004852996ec0efbc6cacdaf70", size = 661182, upload-time = "2025-07-29T18:38:58.414Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2b/0569a5e88b29240d373e835107c94ae9256fb2191d3156b43b2601859eff/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bda32ce212baa724e03c68771e5c69f39e584ea426bfe1a701cb01508ffc7035", size = 4956187, upload-time = "2025-07-29T18:39:00.611Z" }, - { url = "https://files.pythonhosted.org/packages/51/66/273c2a06ae44562b104b61e6b14444da00061fd87652506579d7eb2c40b1/ml_dtypes-0.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c205cac07d24a29840c163d6469f61069ce4b065518519216297fc2f261f8db9", size = 4930911, upload-time = "2025-07-29T18:39:02.405Z" }, - { url = "https://files.pythonhosted.org/packages/93/ab/606be3e87dc0821bd360c8c1ee46108025c31a4f96942b63907bb441b87d/ml_dtypes-0.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:cd7c0bb22d4ff86d65ad61b5dd246812e8993fbc95b558553624c33e8b6903ea", size = 216664, upload-time = "2025-07-29T18:39:03.927Z" }, - { url = "https://files.pythonhosted.org/packages/30/a2/e900690ca47d01dffffd66375c5de8c4f8ced0f1ef809ccd3b25b3e6b8fa/ml_dtypes-0.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:9d55ea7f7baf2aed61bf1872116cefc9d0c3693b45cae3916897ee27ef4b835e", size = 160203, upload-time = "2025-07-29T18:39:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/53/21/783dfb51f40d2660afeb9bccf3612b99f6a803d980d2a09132b0f9d216ab/ml_dtypes-0.5.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:e12e29764a0e66a7a31e9b8bf1de5cc0423ea72979f45909acd4292de834ccd3", size = 689324, upload-time = "2025-07-29T18:39:07.567Z" }, - { url = "https://files.pythonhosted.org/packages/09/f7/a82d249c711abf411ac027b7163f285487f5e615c3e0716c61033ce996ab/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19f6c3a4f635c2fc9e2aa7d91416bd7a3d649b48350c51f7f715a09370a90d93", size = 5275917, upload-time = "2025-07-29T18:39:09.339Z" }, - { url = "https://files.pythonhosted.org/packages/7f/3c/541c4b30815ab90ebfbb51df15d0b4254f2f9f1e2b4907ab229300d5e6f2/ml_dtypes-0.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ab039ffb40f3dc0aeeeba84fd6c3452781b5e15bef72e2d10bcb33e4bbffc39", size = 5285284, upload-time = "2025-07-29T18:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5e/712092cfe7e5eb667b8ad9ca7c54442f21ed7ca8979745f1000e24cf8737/ml_dtypes-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c7ecb74c4bd71db68a6bea1edf8da8c34f3d9fe218f038814fd1d310ac76c90", size = 679734, upload-time = "2025-11-17T22:31:39.223Z" }, + { url = "https://files.pythonhosted.org/packages/4f/cf/912146dfd4b5c0eea956836c01dcd2fce6c9c844b2691f5152aca196ce4f/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc11d7e8c44a65115d05e2ab9989d1e045125d7be8e05a071a48bc76eb6d6040", size = 5056165, upload-time = "2025-11-17T22:31:41.071Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/19189ea605017473660e43762dc853d2797984b3c7bf30ce656099add30c/ml_dtypes-0.5.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b9a53598f21e453ea2fbda8aa783c20faff8e1eeb0d7ab899309a0053f1483", size = 5034975, upload-time = "2025-11-17T22:31:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/b4/24/70bd59276883fdd91600ca20040b41efd4902a923283c4d6edcb1de128d2/ml_dtypes-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:7c23c54a00ae43edf48d44066a7ec31e05fdc2eee0be2b8b50dd1903a1db94bb", size = 210742, upload-time = "2025-11-17T22:31:44.068Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/64230ef14e40aa3f1cb254ef623bf812735e6bec7772848d19131111ac0d/ml_dtypes-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:557a31a390b7e9439056644cb80ed0735a6e3e3bb09d67fd5687e4b04238d1de", size = 160709, upload-time = "2025-11-17T22:31:46.557Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/3c70881695e056f8a32f8b941126cf78775d9a4d7feba8abcb52cb7b04f2/ml_dtypes-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a174837a64f5b16cab6f368171a1a03a27936b31699d167684073ff1c4237dac", size = 676927, upload-time = "2025-11-17T22:31:48.182Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/428ef6881782e5ebb7eca459689448c0394fa0a80bea3aa9262cba5445ea/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7f7c643e8b1320fd958bf098aa7ecf70623a42ec5154e3be3be673f4c34d900", size = 5028464, upload-time = "2025-11-17T22:31:50.135Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cb/28ce52eb94390dda42599c98ea0204d74799e4d8047a0eb559b6fd648056/ml_dtypes-0.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ad459e99793fa6e13bd5b7e6792c8f9190b4e5a1b45c63aba14a4d0a7f1d5ff", size = 5009002, upload-time = "2025-11-17T22:31:52.001Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f0/0cfadd537c5470378b1b32bd859cf2824972174b51b873c9d95cfd7475a5/ml_dtypes-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:c1a953995cccb9e25a4ae19e34316671e4e2edaebe4cf538229b1fc7109087b7", size = 212222, upload-time = "2025-11-17T22:31:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/9acc86985bfad8f2c2d30291b27cd2bb4c74cea08695bd540906ed744249/ml_dtypes-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:9bad06436568442575beb2d03389aa7456c690a5b05892c471215bfd8cf39460", size = 160793, upload-time = "2025-11-17T22:31:55.358Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, + { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, ] [[package]] @@ -1606,15 +1265,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, ] -[[package]] -name = "networkx" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/80/a84676339aaae2f1cfdf9f418701dd634aef9cc76f708ef55c36ff39c3ca/networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6", size = 2073928, upload-time = "2023-10-28T08:41:39.364Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/f0/8fbc882ca80cf077f1b246c0e3c3465f7f415439bdea6b899f6b19f61f70/networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2", size = 1647772, upload-time = "2023-10-28T08:41:36.945Z" }, -] - [[package]] name = "numpy" version = "2.4.6" @@ -1709,7 +1359,7 @@ wheels = [ [[package]] name = "onnx" -version = "1.19.1" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ml-dtypes" }, @@ -1717,31 +1367,26 @@ dependencies = [ { name = "protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/2f/c619eb65769357e9b6de9212c9a821ab39cd484448e5d6b3fb5fb0a64c6d/onnx-1.19.1.tar.gz", hash = "sha256:737524d6eb3907d3499ea459c6f01c5a96278bb3a0f2ff8ae04786fb5d7f1ed5", size = 12033525, upload-time = "2025-10-10T04:01:34.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/19/8ea73a64b368b75fe339771a20a02bc61ea1f551484c9e3d9d0bfbd0450f/onnx-1.22.0.tar.gz", hash = "sha256:ef40c0aaf0b643857ea9306fc7eddce17eaf9fb0407e4801f1fc5758443a38e0", size = 12024721, upload-time = "2026-06-15T12:50:05.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/07/0019c72924909e4f64b9199770630ab7b8d7914b912b03230e68f5eda7ae/onnx-1.19.1-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:17aaf5832126de0a5197a5864e4f09a764dd7681d3035135547959b4b6b77a09", size = 18320936, upload-time = "2025-10-10T04:00:04.235Z" }, - { url = "https://files.pythonhosted.org/packages/af/2f/5c47acf740dc35f0decc640844260fbbdc0efa0565657c93fd7ff30f13f3/onnx-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01b292a4d0b197c45d8184545bbc8ae1df83466341b604187c1b05902cb9c920", size = 18044269, upload-time = "2025-10-10T04:00:07.449Z" }, - { url = "https://files.pythonhosted.org/packages/d5/61/6c457ee8c3a62a3cad0a4bfa4c5436bb3ac4df90c3551d40bee1224b5b51/onnx-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1839af08ab4a909e4af936b8149c27f8c64b96138981024e251906e0539d8bf9", size = 18218092, upload-time = "2025-10-10T04:00:11.135Z" }, - { url = "https://files.pythonhosted.org/packages/54/d5/ab832e1369505e67926a70e9a102061f89ad01f91aa296c4b1277cb81b25/onnx-1.19.1-cp311-cp311-win32.whl", hash = "sha256:0bdbb676e3722bd32f9227c465d552689f49086f986a696419d865cb4e70b989", size = 16344809, upload-time = "2025-10-10T04:00:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/8b/b5/6eb4611d24b85002f878ba8476b4cecbe6f9784c0236a3c5eff85236cc0a/onnx-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:1346853df5c1e3ebedb2e794cf2a51e0f33759affd655524864ccbcddad7035b", size = 16464319, upload-time = "2025-10-10T04:00:18.235Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ff/f0e1f06420c70e20d497fec7c94a864d069943b6312bedd4224c0ab946f8/onnx-1.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:2d69c280c0e665b7f923f499243b9bb84fe97970b7a4668afa0032045de602c8", size = 16437503, upload-time = "2025-10-10T04:00:21.247Z" }, - { url = "https://files.pythonhosted.org/packages/50/07/f6c5b2cffef8c29e739616d1415aea22f7b7ef1f19c17f02b7cff71f5498/onnx-1.19.1-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:3612193a89ddbce5c4e86150869b9258780a82fb8c4ca197723a4460178a6ce9", size = 18327840, upload-time = "2025-10-10T04:00:24.259Z" }, - { url = "https://files.pythonhosted.org/packages/93/20/0568ebd52730287ae80cac8ac893a7301c793ea1630984e2519ee92b02a9/onnx-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c2fd2f744e7a3880ad0c262efa2edf6d965d0bd02b8f327ec516ad4cb0f2f15", size = 18042539, upload-time = "2025-10-10T04:00:27.693Z" }, - { url = "https://files.pythonhosted.org/packages/14/fd/cd7a0fd10a04f8cc5ae436b63e0022e236fe51b9dbb8ee6317fd48568c72/onnx-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:485d3674d50d789e0ee72fa6f6e174ab81cb14c772d594f992141bd744729d8a", size = 18218271, upload-time = "2025-10-10T04:00:30.495Z" }, - { url = "https://files.pythonhosted.org/packages/65/68/cc8b8c05469fe08384b446304ad7e6256131ca0463bf6962366eebec98c0/onnx-1.19.1-cp312-cp312-win32.whl", hash = "sha256:638bc56ff1a5718f7441e887aeb4e450f37a81c6eac482040381b140bd9ba601", size = 16345111, upload-time = "2025-10-10T04:00:34.982Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5e/d1cb16693598a512c2cf9ffe0841d8d8fd2c83ae8e889efd554f5aa427cf/onnx-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:bc7e2e4e163e679721e547958b5a7db875bf822cad371b7c1304aa4401a7c7a4", size = 16465621, upload-time = "2025-10-10T04:00:39.107Z" }, - { url = "https://files.pythonhosted.org/packages/90/32/da116cc61fdef334782aa7f87a1738431dd1af1a5d1a44bd95d6d51ad260/onnx-1.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:17c215b1c0f20fe93b4cbe62668247c1d2294b9bc7f6be0ca9ced28e980c07b7", size = 16437505, upload-time = "2025-10-10T04:00:42.255Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b8/ab1fdfe2e8502f4dc4289fc893db35816bd20d080d8370f86e74dda5f598/onnx-1.19.1-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:4e5f938c68c4dffd3e19e4fd76eb98d298174eb5ebc09319cdd0ec5fe50050dc", size = 18327815, upload-time = "2025-10-10T04:00:45.682Z" }, - { url = "https://files.pythonhosted.org/packages/04/40/eb875745a4b92aea10e5e32aa2830f409c4d7b6f7b48ca1c4eaad96636c5/onnx-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:86e20a5984b017feeef2dbf4ceff1c7c161ab9423254968dd77d3696c38691d0", size = 18041464, upload-time = "2025-10-10T04:00:48.557Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/8586135f40dbe4989cec4d413164bc8fc5c73d37c566f33f5ea3a7f2b6f6/onnx-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d9c467f0f29993c12f330736af87972f30adb8329b515f39d63a0db929cb2c", size = 18218244, upload-time = "2025-10-10T04:00:51.891Z" }, - { url = "https://files.pythonhosted.org/packages/51/b5/4201254b8683129db5da3fb55aa1f7e56d0a8d45c66ce875dec21ca1ff25/onnx-1.19.1-cp313-cp313-win32.whl", hash = "sha256:65eee353a51b4e4ca3e797784661e5376e2b209f17557e04921eac9166a8752e", size = 16345330, upload-time = "2025-10-10T04:00:54.858Z" }, - { url = "https://files.pythonhosted.org/packages/69/67/c6d239afbcdbeb6805432969b908b5c9f700c96d332b34e3f99518d76caf/onnx-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:c3bc87e38b53554b1fc9ef7b275c81c6f5c93c90a91935bb0aa8d4d498a6d48e", size = 16465567, upload-time = "2025-10-10T04:00:57.893Z" }, - { url = "https://files.pythonhosted.org/packages/99/fe/89f1e40f5bc54595ff0dcf5391ce19e578b528973ccc74dd99800196d30d/onnx-1.19.1-cp313-cp313-win_arm64.whl", hash = "sha256:e41496f400afb980ec643d80d5164753a88a85234fa5c06afdeebc8b7d1ec252", size = 16437562, upload-time = "2025-10-10T04:01:00.703Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/b186ccbc8fe7e93643a6a6d40bbf2bb6ce4fb9469bbd3453c77e270c50ad/onnx-1.19.1-cp313-cp313t-macosx_12_0_universal2.whl", hash = "sha256:5f6274abf0fd74e80e78ecbb44bd44509409634525c89a9b38276c8af47dc0a2", size = 18355703, upload-time = "2025-10-10T04:01:03.735Z" }, - { url = "https://files.pythonhosted.org/packages/60/f1/22ee4d8b8f9fa4cb1d1b9579da3b4b5187ddab33846ec5ac744af02c0e2b/onnx-1.19.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07dcd4d83584eb4bf8f21ac04c82643712e5e93ac2a0ed10121ec123cb127e1e", size = 18047830, upload-time = "2025-10-10T04:01:06.552Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/8f3d51e3a095d42cdf2039a590cff06d024f2a10efbd0b1a2a6b3825f019/onnx-1.19.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1975860c3e720db25d37f1619976582828264bdcc64fa7511c321ac4fc01add3", size = 18221126, upload-time = "2025-10-10T04:01:09.77Z" }, - { url = "https://files.pythonhosted.org/packages/4f/0d/f9d6c2237083f1aac14b37f0b03b0d81f1147a8e2af0c3828165e0a6a67b/onnx-1.19.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9807d0e181f6070ee3a6276166acdc571575d1bd522fc7e89dba16fd6e7ffed9", size = 16465560, upload-time = "2025-10-10T04:01:13.212Z" }, - { url = "https://files.pythonhosted.org/packages/36/70/8418a58faa7d606d6a92cab69ae8d361b3b3969bf7e7e9a65a86d5d1b674/onnx-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6ee83e6929d75005482d9f304c502ac7c9b8d6db153aa6b484dae74d0f28570", size = 18042812, upload-time = "2025-10-10T04:01:15.919Z" }, + { url = "https://files.pythonhosted.org/packages/0c/55/30825c02c92a0380ce84c3feeeec95d329fa77548ba58cb10ad4bbfd83c6/onnx-1.22.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:2d8f229a553fa440fe623ed7b36fca5e7762da3af871c3f8f8ce451df73e2914", size = 20167891, upload-time = "2026-06-15T12:49:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/4b/24/cd4ab52ecaf41c3fbed674772ccbfe39041cb257b8471a47a37e48bff3f8/onnx-1.22.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a89a7cb9ba13d78f009bdec448ec82a98972589734f157022a2bff7a5973a6", size = 18892720, upload-time = "2026-06-15T12:49:16.904Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/c9d9d56ceadb1c0a90a7cbec5a0510520ab6538938944fa84548e4b5b054/onnx-1.22.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d0a2bdb15eb2b3cb65c438f3423d9620d14fdce32f92380e6bb1b2e09568ef5", size = 19110720, upload-time = "2026-06-15T12:49:19.812Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/e43e5a68d9cadde55df75310027f87127333a77e5ddcea14c73e96a10cac/onnx-1.22.0-cp311-cp311-win32.whl", hash = "sha256:239958534464612fbcb6ed23d5228aaa925b39b8773f58726809ffdccb4edd1c", size = 17083746, upload-time = "2026-06-15T12:49:22.935Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/cc0a9f2cf4522e42829d089927b4b75924d32f50dca237482e7b741df003/onnx-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:8561a2c00041c07e08db0c228593b5b4694100398685f348532af7dbb84189da", size = 17215684, upload-time = "2026-06-15T12:49:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/c9/99/0f049f9eaa06c8383060c5f0a338e3a6caac8822e6e326c9162f05abf95a/onnx-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:8907b9b9389893bc0dc6314cc00ee1e3a69844e48d689eacc6a0340411a7da58", size = 17210398, upload-time = "2026-06-15T12:49:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6a/481561f1093834376ed493e4ca42a73e5be0d50031f2969c86593bdc7c96/onnx-1.22.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:596fbf0490947533c1c1045ba860851dc9fb77471023dac9a71ba5b42ceab103", size = 20167081, upload-time = "2026-06-15T12:49:32.078Z" }, + { url = "https://files.pythonhosted.org/packages/84/55/b34fc2aa30aa54b4a775402d24c4082242c720283a274fe976ac8eb94480/onnx-1.22.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae5a563f281cd9d2845622cecf6c092a57e4ee1b138f66fdbbdd4200567a5e16", size = 18889249, upload-time = "2026-06-15T12:49:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/09/a6/bd32357e6cc1ecb473afd78193d7231724f284435d2db25696ecfaaa1503/onnx-1.22.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:955e02e1f6d385b53d52f9cd7b9cdf5caf417c300bcfe3c64c6d542be763845b", size = 19106514, upload-time = "2026-06-15T12:49:37.424Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9d/3af461ac6c714b8b369cb71499659932f4f12cfb066250b62f7567c3d530/onnx-1.22.0-cp312-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:82e9f27fc1223cb06d68a56bed6f9d3caf3d0dad1b61bce45006d529b15bd94c", size = 16966387, upload-time = "2026-06-15T12:49:40.918Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/68195b5e5a53e333faf2660f5352ee43738d0e42fc5216cc6b1871a9fbfb/onnx-1.22.0-cp312-abi3-win32.whl", hash = "sha256:cc8b66b312f8f03a53e268afb67180a2d97dd12cc79e2b61361c6c0073448016", size = 17081568, upload-time = "2026-06-15T12:49:43.398Z" }, + { url = "https://files.pythonhosted.org/packages/13/a8/734725bb703c5fabb687f79c79e51249475212b3eb37771ac4a4ac9b487f/onnx-1.22.0-cp312-abi3-win_amd64.whl", hash = "sha256:72ccebab3bac07215c204ce8848d42e78eaaa666badbf72d25cd359b9f269e3a", size = 17213290, upload-time = "2026-06-15T12:49:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/8ce48d8ae26a8761ad4e5dc771961b155c5c3c7c8540ec7f2f2d71b69af0/onnx-1.22.0-cp312-abi3-win_arm64.whl", hash = "sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f", size = 17207030, upload-time = "2026-06-15T12:49:48.635Z" }, + { url = "https://files.pythonhosted.org/packages/f3/13/47323b97846387848efb1044ded11bb94b83526f3d1fbdb37c6480d4520f/onnx-1.22.0-cp314-cp314t-macosx_12_0_universal2.whl", hash = "sha256:19e45e4af88e3fe3261458d4b8cc461957ae2782a358a3560503569bf3b23b72", size = 20176465, upload-time = "2026-06-15T12:49:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/13/0c/d3b8a7e7eee123938586c608bb9894b5723f2342b9450c0eec59fbec7099/onnx-1.22.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c21a0e59fd967a95b358e4a6e756d1f1eec2d304a83480f329f66e30d2bf0223", size = 18894028, upload-time = "2026-06-15T12:49:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8a/da2a97ab46fe6e0cd9beb3ac14603a22f5be492f9ca347faf8233a07bb33/onnx-1.22.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2632406b8f523ef2e2873c363f90b20a3d88c0fbcfac757d3addffccf8f452c2", size = 19110420, upload-time = "2026-06-15T12:49:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/ce984063017518307ebfaa545782fc400e593dc2d7fdf4f23ce4be1ed197/onnx-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a3a39fc4643867aecb33417fdddb11e308ee79d2d4a584b9d50cc7aec2091b13", size = 17237547, upload-time = "2026-06-15T12:50:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/257a880384a1dd502d543b0067945074d63cd17d0840e958355bc8197da8/onnx-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e268cdc0547e3949799ffd4a44451dc2b9080b57d0824a2db680b6ec65506f0", size = 17231391, upload-time = "2026-06-15T12:50:03.047Z" }, ] [[package]] @@ -2058,18 +1703,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, ] -[[package]] -name = "prettytable" -version = "3.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c0/5e9c4d2a643a00a6f67578ef35485173de273a4567279e4f0c200c01386b/prettytable-3.9.0.tar.gz", hash = "sha256:f4ed94803c23073a90620b201965e5dc0bccf1760b7a7eaf3158cab8aaffdf34", size = 47874, upload-time = "2023-09-11T14:04:14.548Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/81/316b6a55a0d1f327d04cc7b0ba9d04058cb62de6c3a4d4b0df280cbe3b0b/prettytable-3.9.0-py3-none-any.whl", hash = "sha256:a71292ab7769a5de274b146b276ce938786f56c31cf7cea88b6f3775d82fe8c8", size = 27772, upload-time = "2023-09-11T14:03:45.582Z" }, -] - [[package]] name = "protobuf" version = "6.33.2" @@ -2274,15 +1907,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/9c/372fef8377a6e340b1704768d20daaded98bf13282b5327beb2e2fe2c7ef/pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", size = 1179756, upload-time = "2023-11-21T20:43:49.423Z" }, ] -[[package]] -name = "pyparsing" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/fe/65c989f70bd630b589adfbbcd6ed238af22319e90f059946c26b4835e44b/pyparsing-3.1.1.tar.gz", hash = "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db", size = 884814, upload-time = "2023-07-30T15:07:02.617Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -2338,18 +1962,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/13b4776ea2d76c115c1d1b84579f3764ee6d57204f6be27119f13a61d0a9/python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", size = 357324, upload-time = "2021-07-14T08:19:19.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/7a/87837f39d0296e723bb9b62bbb257d0355c7f6128853c78955f57342a56d/python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9", size = 247702, upload-time = "2021-07-14T08:19:18.161Z" }, -] - [[package]] name = "python-dotenv" version = "1.0.0" @@ -2511,21 +2123,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] -[[package]] -name = "qudida" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "opencv-python-headless" }, - { name = "scikit-learn" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3e/2d/bab8babd9dc9a9e4df6eb115540cee4322c1a74078fb6f3b3ebc452a22b3/qudida-0.0.4.tar.gz", hash = "sha256:db198e2887ab0c9aa0023e565afbff41dfb76b361f85fd5e13f780d75ba18cc8", size = 3100, upload-time = "2021-08-09T16:47:55.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/a1/a5f4bebaa31d109003909809d88aeb0d4b201463a9ea29308d9e4f9e7655/qudida-0.0.4-py3-none-any.whl", hash = "sha256:4519714c40cd0f2e6c51e1735edae8f8b19f4efe1f33be13e9d644ca5f736dd6", size = 3478, upload-time = "2021-08-09T16:47:54.637Z" }, -] - [[package]] name = "rapidocr" version = "3.8.1" @@ -2661,139 +2258,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] -[[package]] -name = "scikit-image" -version = "0.25.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "imageio" }, - { name = "lazy-loader" }, - { name = "networkx" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "scipy" }, - { name = "tifffile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/a8/3c0f256012b93dd2cb6fda9245e9f4bff7dc0486880b248005f15ea2255e/scikit_image-0.25.2.tar.gz", hash = "sha256:e5a37e6cd4d0c018a7a55b9d601357e3382826d3888c10d0213fc63bff977dde", size = 22693594, upload-time = "2025-02-18T18:05:24.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/97/3051c68b782ee3f1fb7f8f5bb7d535cf8cb92e8aae18fa9c1cdf7e15150d/scikit_image-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f4bac9196fb80d37567316581c6060763b0f4893d3aca34a9ede3825bc035b17", size = 14003057, upload-time = "2025-02-18T18:04:30.395Z" }, - { url = "https://files.pythonhosted.org/packages/19/23/257fc696c562639826065514d551b7b9b969520bd902c3a8e2fcff5b9e17/scikit_image-0.25.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:d989d64ff92e0c6c0f2018c7495a5b20e2451839299a018e0e5108b2680f71e0", size = 13180335, upload-time = "2025-02-18T18:04:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/ef/14/0c4a02cb27ca8b1e836886b9ec7c9149de03053650e9e2ed0625f248dd92/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2cfc96b27afe9a05bc92f8c6235321d3a66499995675b27415e0d0c76625173", size = 14144783, upload-time = "2025-02-18T18:04:36.594Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9b/9fb556463a34d9842491d72a421942c8baff4281025859c84fcdb5e7e602/scikit_image-0.25.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24cc986e1f4187a12aa319f777b36008764e856e5013666a4a83f8df083c2641", size = 14785376, upload-time = "2025-02-18T18:04:39.856Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/b57c500ee85885df5f2188f8bb70398481393a69de44a00d6f1d055f103c/scikit_image-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:b4f6b61fc2db6340696afe3db6b26e0356911529f5f6aee8c322aa5157490c9b", size = 12791698, upload-time = "2025-02-18T18:04:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/35/8c/5df82881284459f6eec796a5ac2a0a304bb3384eec2e73f35cfdfcfbf20c/scikit_image-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8db8dd03663112783221bf01ccfc9512d1cc50ac9b5b0fe8f4023967564719fb", size = 13986000, upload-time = "2025-02-18T18:04:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/ce/e6/93bebe1abcdce9513ffec01d8af02528b4c41fb3c1e46336d70b9ed4ef0d/scikit_image-0.25.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:483bd8cc10c3d8a7a37fae36dfa5b21e239bd4ee121d91cad1f81bba10cfb0ed", size = 13235893, upload-time = "2025-02-18T18:04:51.049Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/eda616e33f67129e5979a9eb33c710013caa3aa8a921991e6cc0b22cea33/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d1e80107bcf2bf1291acfc0bf0425dceb8890abe9f38d8e94e23497cbf7ee0d", size = 14178389, upload-time = "2025-02-18T18:04:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824", size = 15003435, upload-time = "2025-02-18T18:04:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/49beb08ebccda3c21e871b607c1cb2f258c3fa0d2f609fed0a5ba741b92d/scikit_image-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:bdd2b8c1de0849964dbc54037f36b4e9420157e67e45a8709a80d727f52c7da2", size = 12899474, upload-time = "2025-02-18T18:05:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/e6/7c/9814dd1c637f7a0e44342985a76f95a55dd04be60154247679fd96c7169f/scikit_image-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7efa888130f6c548ec0439b1a7ed7295bc10105458a421e9bf739b457730b6da", size = 13921841, upload-time = "2025-02-18T18:05:03.963Z" }, - { url = "https://files.pythonhosted.org/packages/84/06/66a2e7661d6f526740c309e9717d3bd07b473661d5cdddef4dd978edab25/scikit_image-0.25.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:dd8011efe69c3641920614d550f5505f83658fe33581e49bed86feab43a180fc", size = 13196862, upload-time = "2025-02-18T18:05:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/4e/63/3368902ed79305f74c2ca8c297dfeb4307269cbe6402412668e322837143/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28182a9d3e2ce3c2e251383bdda68f8d88d9fff1a3ebe1eb61206595c9773341", size = 14117785, upload-time = "2025-02-18T18:05:10.69Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/c3da56a145f52cd61a68b8465d6a29d9503bc45bc993bb45e84371c97d94/scikit_image-0.25.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8abd3c805ce6944b941cfed0406d88faeb19bab3ed3d4b50187af55cf24d147", size = 14977119, upload-time = "2025-02-18T18:05:13.871Z" }, - { url = "https://files.pythonhosted.org/packages/8a/97/5fcf332e1753831abb99a2525180d3fb0d70918d461ebda9873f66dcc12f/scikit_image-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:64785a8acefee460ec49a354706db0b09d1f325674107d7fa3eadb663fb56d6f", size = 12885116, upload-time = "2025-02-18T18:05:17.844Z" }, - { url = "https://files.pythonhosted.org/packages/10/cc/75e9f17e3670b5ed93c32456fda823333c6279b144cd93e2c03aa06aa472/scikit_image-0.25.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:330d061bd107d12f8d68f1d611ae27b3b813b8cdb0300a71d07b1379178dd4cd", size = 13862801, upload-time = "2025-02-18T18:05:20.783Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/84/5f4af978fff619706b8961accac84780a6d298d82a8873446f72edb4ead0/scikit_learn-1.7.1.tar.gz", hash = "sha256:24b3f1e976a4665aa74ee0fcaac2b8fccc6ae77c8e07ab25da3ba6d3292b9802", size = 7190445, upload-time = "2025-07-18T08:01:54.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/bd/a23177930abd81b96daffa30ef9c54ddbf544d3226b8788ce4c3ef1067b4/scikit_learn-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90c8494ea23e24c0fb371afc474618c1019dc152ce4a10e4607e62196113851b", size = 9334838, upload-time = "2025-07-18T08:01:11.239Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a1/d3a7628630a711e2ac0d1a482910da174b629f44e7dd8cfcd6924a4ef81a/scikit_learn-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:bb870c0daf3bf3be145ec51df8ac84720d9972170786601039f024bf6d61a518", size = 8651241, upload-time = "2025-07-18T08:01:13.234Z" }, - { url = "https://files.pythonhosted.org/packages/26/92/85ec172418f39474c1cd0221d611345d4f433fc4ee2fc68e01f524ccc4e4/scikit_learn-1.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40daccd1b5623f39e8943ab39735cadf0bdce80e67cdca2adcb5426e987320a8", size = 9718677, upload-time = "2025-07-18T08:01:15.649Z" }, - { url = "https://files.pythonhosted.org/packages/df/ce/abdb1dcbb1d2b66168ec43b23ee0cee356b4cc4100ddee3943934ebf1480/scikit_learn-1.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30d1f413cfc0aa5a99132a554f1d80517563c34a9d3e7c118fde2d273c6fe0f7", size = 9511189, upload-time = "2025-07-18T08:01:18.013Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3b/47b5eaee01ef2b5a80ba3f7f6ecf79587cb458690857d4777bfd77371c6f/scikit_learn-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:c711d652829a1805a95d7fe96654604a8f16eab5a9e9ad87b3e60173415cb650", size = 8914794, upload-time = "2025-07-18T08:01:20.357Z" }, - { url = "https://files.pythonhosted.org/packages/cb/16/57f176585b35ed865f51b04117947fe20f130f78940c6477b6d66279c9c2/scikit_learn-1.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3cee419b49b5bbae8796ecd690f97aa412ef1674410c23fc3257c6b8b85b8087", size = 9260431, upload-time = "2025-07-18T08:01:22.77Z" }, - { url = "https://files.pythonhosted.org/packages/67/4e/899317092f5efcab0e9bc929e3391341cec8fb0e816c4789686770024580/scikit_learn-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2fd8b8d35817b0d9ebf0b576f7d5ffbbabdb55536b0655a8aaae629d7ffd2e1f", size = 8637191, upload-time = "2025-07-18T08:01:24.731Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1b/998312db6d361ded1dd56b457ada371a8d8d77ca2195a7d18fd8a1736f21/scikit_learn-1.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:588410fa19a96a69763202f1d6b7b91d5d7a5d73be36e189bc6396bfb355bd87", size = 9486346, upload-time = "2025-07-18T08:01:26.713Z" }, - { url = "https://files.pythonhosted.org/packages/ad/09/a2aa0b4e644e5c4ede7006748f24e72863ba2ae71897fecfd832afea01b4/scikit_learn-1.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3142f0abe1ad1d1c31a2ae987621e41f6b578144a911ff4ac94781a583adad7", size = 9290988, upload-time = "2025-07-18T08:01:28.938Z" }, - { url = "https://files.pythonhosted.org/packages/15/fa/c61a787e35f05f17fc10523f567677ec4eeee5f95aa4798dbbbcd9625617/scikit_learn-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ddd9092c1bd469acab337d87930067c87eac6bd544f8d5027430983f1e1ae88", size = 8735568, upload-time = "2025-07-18T08:01:30.936Z" }, - { url = "https://files.pythonhosted.org/packages/52/f8/e0533303f318a0f37b88300d21f79b6ac067188d4824f1047a37214ab718/scikit_learn-1.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7839687fa46d02e01035ad775982f2470be2668e13ddd151f0f55a5bf123bae", size = 9213143, upload-time = "2025-07-18T08:01:32.942Z" }, - { url = "https://files.pythonhosted.org/packages/71/f3/f1df377d1bdfc3e3e2adc9c119c238b182293e6740df4cbeac6de2cc3e23/scikit_learn-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a10f276639195a96c86aa572ee0698ad64ee939a7b042060b98bd1930c261d10", size = 8591977, upload-time = "2025-07-18T08:01:34.967Z" }, - { url = "https://files.pythonhosted.org/packages/99/72/c86a4cd867816350fe8dee13f30222340b9cd6b96173955819a5561810c5/scikit_learn-1.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13679981fdaebc10cc4c13c43344416a86fcbc61449cb3e6517e1df9d12c8309", size = 9436142, upload-time = "2025-07-18T08:01:37.397Z" }, - { url = "https://files.pythonhosted.org/packages/e8/66/277967b29bd297538dc7a6ecfb1a7dce751beabd0d7f7a2233be7a4f7832/scikit_learn-1.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1262883c6a63f067a980a8cdd2d2e7f2513dddcef6a9eaada6416a7a7cbe43", size = 9282996, upload-time = "2025-07-18T08:01:39.721Z" }, - { url = "https://files.pythonhosted.org/packages/e2/47/9291cfa1db1dae9880420d1e07dbc7e8dd4a7cdbc42eaba22512e6bde958/scikit_learn-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca6d31fb10e04d50bfd2b50d66744729dbb512d4efd0223b864e2fdbfc4cee11", size = 8707418, upload-time = "2025-07-18T08:01:42.124Z" }, - { url = "https://files.pythonhosted.org/packages/61/95/45726819beccdaa34d3362ea9b2ff9f2b5d3b8bf721bd632675870308ceb/scikit_learn-1.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:781674d096303cfe3d351ae6963ff7c958db61cde3421cd490e3a5a58f2a94ae", size = 9561466, upload-time = "2025-07-18T08:01:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1c/6f4b3344805de783d20a51eb24d4c9ad4b11a7f75c1801e6ec6d777361fd/scikit_learn-1.7.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:10679f7f125fe7ecd5fad37dd1aa2daae7e3ad8df7f3eefa08901b8254b3e12c", size = 9040467, upload-time = "2025-07-18T08:01:46.671Z" }, - { url = "https://files.pythonhosted.org/packages/6f/80/abe18fe471af9f1d181904203d62697998b27d9b62124cd281d740ded2f9/scikit_learn-1.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f812729e38c8cb37f760dce71a9b83ccfb04f59b3dca7c6079dcdc60544fa9e", size = 9532052, upload-time = "2025-07-18T08:01:48.676Z" }, - { url = "https://files.pythonhosted.org/packages/14/82/b21aa1e0c4cee7e74864d3a5a721ab8fcae5ca55033cb6263dca297ed35b/scikit_learn-1.7.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88e1a20131cf741b84b89567e1717f27a2ced228e0f29103426102bc2e3b8ef7", size = 9361575, upload-time = "2025-07-18T08:01:50.639Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/f4777fcd5627dc6695fa6b92179d0edb7a3ac1b91bcd9a1c7f64fa7ade23/scikit_learn-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b1bd1d919210b6a10b7554b717c9000b5485aa95a1d0f177ae0d7ee8ec750da5", size = 9277310, upload-time = "2025-07-18T08:01:52.547Z" }, -] - -[[package]] -name = "scipy" -version = "1.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/4a/b927028464795439faec8eaf0b03b011005c487bb2d07409f28bf30879c4/scipy-1.16.1.tar.gz", hash = "sha256:44c76f9e8b6e8e488a586190ab38016e4ed2f8a038af7cd3defa903c0a2238b3", size = 30580861, upload-time = "2025-07-27T16:33:30.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/91/812adc6f74409b461e3a5fa97f4f74c769016919203138a3bf6fc24ba4c5/scipy-1.16.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:c033fa32bab91dc98ca59d0cf23bb876454e2bb02cbe592d5023138778f70030", size = 36552519, upload-time = "2025-07-27T16:26:29.658Z" }, - { url = "https://files.pythonhosted.org/packages/47/18/8e355edcf3b71418d9e9f9acd2708cc3a6c27e8f98fde0ac34b8a0b45407/scipy-1.16.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6e5c2f74e5df33479b5cd4e97a9104c511518fbd979aa9b8f6aec18b2e9ecae7", size = 28638010, upload-time = "2025-07-27T16:26:38.196Z" }, - { url = "https://files.pythonhosted.org/packages/d9/eb/e931853058607bdfbc11b86df19ae7a08686121c203483f62f1ecae5989c/scipy-1.16.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0a55ffe0ba0f59666e90951971a884d1ff6f4ec3275a48f472cfb64175570f77", size = 20909790, upload-time = "2025-07-27T16:26:43.93Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/be83a271d6e96750cd0be2e000f35ff18880a46f05ce8b5d3465dc0f7a2a/scipy-1.16.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f8a5d6cd147acecc2603fbd382fed6c46f474cccfcf69ea32582e033fb54dcfe", size = 23513352, upload-time = "2025-07-27T16:26:50.017Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bf/fe6eb47e74f762f933cca962db7f2c7183acfdc4483bd1c3813cfe83e538/scipy-1.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb18899127278058bcc09e7b9966d41a5a43740b5bb8dcba401bd983f82e885b", size = 33534643, upload-time = "2025-07-27T16:26:57.503Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ba/63f402e74875486b87ec6506a4f93f6d8a0d94d10467280f3d9d7837ce3a/scipy-1.16.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adccd93a2fa937a27aae826d33e3bfa5edf9aa672376a4852d23a7cd67a2e5b7", size = 35376776, upload-time = "2025-07-27T16:27:06.639Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b4/04eb9d39ec26a1b939689102da23d505ea16cdae3dbb18ffc53d1f831044/scipy-1.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:18aca1646a29ee9a0625a1be5637fa798d4d81fdf426481f06d69af828f16958", size = 35698906, upload-time = "2025-07-27T16:27:14.943Z" }, - { url = "https://files.pythonhosted.org/packages/04/d6/bb5468da53321baeb001f6e4e0d9049eadd175a4a497709939128556e3ec/scipy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d85495cef541729a70cdddbbf3e6b903421bc1af3e8e3a9a72a06751f33b7c39", size = 38129275, upload-time = "2025-07-27T16:27:23.873Z" }, - { url = "https://files.pythonhosted.org/packages/c4/94/994369978509f227cba7dfb9e623254d0d5559506fe994aef4bea3ed469c/scipy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:226652fca853008119c03a8ce71ffe1b3f6d2844cc1686e8f9806edafae68596", size = 38644572, upload-time = "2025-07-27T16:27:32.637Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ec4864f5896232133f51382b54a08de91a9d1af7a76dfa372894026dfee2/scipy-1.16.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81b433bbeaf35728dad619afc002db9b189e45eebe2cd676effe1fb93fef2b9c", size = 36575194, upload-time = "2025-07-27T16:27:41.321Z" }, - { url = "https://files.pythonhosted.org/packages/5c/6d/40e81ecfb688e9d25d34a847dca361982a6addf8e31f0957b1a54fbfa994/scipy-1.16.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:886cc81fdb4c6903a3bb0464047c25a6d1016fef77bb97949817d0c0d79f9e04", size = 28594590, upload-time = "2025-07-27T16:27:49.204Z" }, - { url = "https://files.pythonhosted.org/packages/0e/37/9f65178edfcc629377ce9a64fc09baebea18c80a9e57ae09a52edf84880b/scipy-1.16.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:15240c3aac087a522b4eaedb09f0ad061753c5eebf1ea430859e5bf8640d5919", size = 20866458, upload-time = "2025-07-27T16:27:54.98Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7b/749a66766871ea4cb1d1ea10f27004db63023074c22abed51f22f09770e0/scipy-1.16.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f81a25805f3659b48126b5053d9e823d3215e4a63730b5e1671852a1705921", size = 23539318, upload-time = "2025-07-27T16:28:01.604Z" }, - { url = "https://files.pythonhosted.org/packages/c4/db/8d4afec60eb833a666434d4541a3151eedbf2494ea6d4d468cbe877f00cd/scipy-1.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c62eea7f607f122069b9bad3f99489ddca1a5173bef8a0c75555d7488b6f725", size = 33292899, upload-time = "2025-07-27T16:28:09.147Z" }, - { url = "https://files.pythonhosted.org/packages/51/1e/79023ca3bbb13a015d7d2757ecca3b81293c663694c35d6541b4dca53e98/scipy-1.16.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f965bbf3235b01c776115ab18f092a95aa74c271a52577bcb0563e85738fd618", size = 35162637, upload-time = "2025-07-27T16:28:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/b6/49/0648665f9c29fdaca4c679182eb972935b3b4f5ace41d323c32352f29816/scipy-1.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f006e323874ffd0b0b816d8c6a8e7f9a73d55ab3b8c3f72b752b226d0e3ac83d", size = 35490507, upload-time = "2025-07-27T16:28:25.705Z" }, - { url = "https://files.pythonhosted.org/packages/62/8f/66cbb9d6bbb18d8c658f774904f42a92078707a7c71e5347e8bf2f52bb89/scipy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8fd15fc5085ab4cca74cb91fe0a4263b1f32e4420761ddae531ad60934c2119", size = 37923998, upload-time = "2025-07-27T16:28:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/14/c3/61f273ae550fbf1667675701112e380881905e28448c080b23b5a181df7c/scipy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7b8013c6c066609577d910d1a2a077021727af07b6fab0ee22c2f901f22352a", size = 38508060, upload-time = "2025-07-27T16:28:43.242Z" }, - { url = "https://files.pythonhosted.org/packages/93/0b/b5c99382b839854a71ca9482c684e3472badc62620287cbbdab499b75ce6/scipy-1.16.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5451606823a5e73dfa621a89948096c6528e2896e40b39248295d3a0138d594f", size = 36533717, upload-time = "2025-07-27T16:28:51.706Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e5/69ab2771062c91e23e07c12e7d5033a6b9b80b0903ee709c3c36b3eb520c/scipy-1.16.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:89728678c5ca5abd610aee148c199ac1afb16e19844401ca97d43dc548a354eb", size = 28570009, upload-time = "2025-07-27T16:28:57.017Z" }, - { url = "https://files.pythonhosted.org/packages/f4/69/bd75dbfdd3cf524f4d753484d723594aed62cfaac510123e91a6686d520b/scipy-1.16.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e756d688cb03fd07de0fffad475649b03cb89bee696c98ce508b17c11a03f95c", size = 20841942, upload-time = "2025-07-27T16:29:01.152Z" }, - { url = "https://files.pythonhosted.org/packages/ea/74/add181c87663f178ba7d6144b370243a87af8476664d5435e57d599e6874/scipy-1.16.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5aa2687b9935da3ed89c5dbed5234576589dd28d0bf7cd237501ccfbdf1ad608", size = 23498507, upload-time = "2025-07-27T16:29:05.202Z" }, - { url = "https://files.pythonhosted.org/packages/1d/74/ece2e582a0d9550cee33e2e416cc96737dce423a994d12bbe59716f47ff1/scipy-1.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0851f6a1e537fe9399f35986897e395a1aa61c574b178c0d456be5b1a0f5ca1f", size = 33286040, upload-time = "2025-07-27T16:29:10.201Z" }, - { url = "https://files.pythonhosted.org/packages/e4/82/08e4076df538fb56caa1d489588d880ec7c52d8273a606bb54d660528f7c/scipy-1.16.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fedc2cbd1baed37474b1924c331b97bdff611d762c196fac1a9b71e67b813b1b", size = 35176096, upload-time = "2025-07-27T16:29:17.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/79/cd710aab8c921375711a8321c6be696e705a120e3011a643efbbcdeeabcc/scipy-1.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ef500e72f9623a6735769e4b93e9dcb158d40752cdbb077f305487e3e2d1f45", size = 35490328, upload-time = "2025-07-27T16:29:22.928Z" }, - { url = "https://files.pythonhosted.org/packages/71/73/e9cc3d35ee4526d784520d4494a3e1ca969b071fb5ae5910c036a375ceec/scipy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:978d8311674b05a8f7ff2ea6c6bce5d8b45a0cb09d4c5793e0318f448613ea65", size = 37939921, upload-time = "2025-07-27T16:29:29.108Z" }, - { url = "https://files.pythonhosted.org/packages/21/12/c0efd2941f01940119b5305c375ae5c0fcb7ec193f806bd8f158b73a1782/scipy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:81929ed0fa7a5713fcdd8b2e6f73697d3b4c4816d090dd34ff937c20fa90e8ab", size = 38479462, upload-time = "2025-07-27T16:30:24.078Z" }, - { url = "https://files.pythonhosted.org/packages/7a/19/c3d08b675260046a991040e1ea5d65f91f40c7df1045fffff412dcfc6765/scipy-1.16.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:bcc12db731858abda693cecdb3bdc9e6d4bd200213f49d224fe22df82687bdd6", size = 36938832, upload-time = "2025-07-27T16:29:35.057Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/ce53db652c033a414a5b34598dba6b95f3d38153a2417c5a3883da429029/scipy-1.16.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:744d977daa4becb9fc59135e75c069f8d301a87d64f88f1e602a9ecf51e77b27", size = 29093084, upload-time = "2025-07-27T16:29:40.201Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ae/7a10ff04a7dc15f9057d05b33737ade244e4bd195caa3f7cc04d77b9e214/scipy-1.16.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:dc54f76ac18073bcecffb98d93f03ed6b81a92ef91b5d3b135dcc81d55a724c7", size = 21365098, upload-time = "2025-07-27T16:29:44.295Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/029ff710959932ad3c2a98721b20b405f05f752f07344622fd61a47c5197/scipy-1.16.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:367d567ee9fc1e9e2047d31f39d9d6a7a04e0710c86e701e053f237d14a9b4f6", size = 23896858, upload-time = "2025-07-27T16:29:48.784Z" }, - { url = "https://files.pythonhosted.org/packages/71/13/d1ef77b6bd7898720e1f0b6b3743cb945f6c3cafa7718eaac8841035ab60/scipy-1.16.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cf5785e44e19dcd32a0e4807555e1e9a9b8d475c6afff3d21c3c543a6aa84f4", size = 33438311, upload-time = "2025-07-27T16:29:54.164Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e0/e64a6821ffbb00b4c5b05169f1c1fddb4800e9307efe3db3788995a82a2c/scipy-1.16.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3d0b80fb26d3e13a794c71d4b837e2a589d839fd574a6bbb4ee1288c213ad4a3", size = 35279542, upload-time = "2025-07-27T16:30:00.249Z" }, - { url = "https://files.pythonhosted.org/packages/57/59/0dc3c8b43e118f1e4ee2b798dcc96ac21bb20014e5f1f7a8e85cc0653bdb/scipy-1.16.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8503517c44c18d1030d666cb70aaac1cc8913608816e06742498833b128488b7", size = 35667665, upload-time = "2025-07-27T16:30:05.916Z" }, - { url = "https://files.pythonhosted.org/packages/45/5f/844ee26e34e2f3f9f8febb9343748e72daeaec64fe0c70e9bf1ff84ec955/scipy-1.16.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:30cc4bb81c41831ecfd6dc450baf48ffd80ef5aed0f5cf3ea775740e80f16ecc", size = 38045210, upload-time = "2025-07-27T16:30:11.655Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d7/210f2b45290f444f1de64bc7353aa598ece9f0e90c384b4a156f9b1a5063/scipy-1.16.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c24fa02f7ed23ae514460a22c57eca8f530dbfa50b1cfdbf4f37c05b5309cc39", size = 38593661, upload-time = "2025-07-27T16:30:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/81/ea/84d481a5237ed223bd3d32d6e82d7a6a96e34756492666c260cef16011d1/scipy-1.16.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:796a5a9ad36fa3a782375db8f4241ab02a091308eb079746bc0f874c9b998318", size = 36525921, upload-time = "2025-07-27T16:30:30.081Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9f/d9edbdeff9f3a664807ae3aea383e10afaa247e8e6255e6d2aa4515e8863/scipy-1.16.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:3ea0733a2ff73fd6fdc5fecca54ee9b459f4d74f00b99aced7d9a3adb43fb1cc", size = 28564152, upload-time = "2025-07-27T16:30:35.336Z" }, - { url = "https://files.pythonhosted.org/packages/3b/95/8125bcb1fe04bc267d103e76516243e8d5e11229e6b306bda1024a5423d1/scipy-1.16.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:85764fb15a2ad994e708258bb4ed8290d1305c62a4e1ef07c414356a24fcfbf8", size = 20836028, upload-time = "2025-07-27T16:30:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/77/9c/bf92e215701fc70bbcd3d14d86337cf56a9b912a804b9c776a269524a9e9/scipy-1.16.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ca66d980469cb623b1759bdd6e9fd97d4e33a9fad5b33771ced24d0cb24df67e", size = 23489666, upload-time = "2025-07-27T16:30:43.663Z" }, - { url = "https://files.pythonhosted.org/packages/5e/00/5e941d397d9adac41b02839011594620d54d99488d1be5be755c00cde9ee/scipy-1.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7cc1ffcc230f568549fc56670bcf3df1884c30bd652c5da8138199c8c76dae0", size = 33358318, upload-time = "2025-07-27T16:30:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/0e/87/8db3aa10dde6e3e8e7eb0133f24baa011377d543f5b19c71469cf2648026/scipy-1.16.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ddfb1e8d0b540cb4ee9c53fc3dea3186f97711248fb94b4142a1b27178d8b4b", size = 35185724, upload-time = "2025-07-27T16:30:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/89/b4/6ab9ae443216807622bcff02690262d8184078ea467efee2f8c93288a3b1/scipy-1.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4dc0e7be79e95d8ba3435d193e0d8ce372f47f774cffd882f88ea4e1e1ddc731", size = 35554335, upload-time = "2025-07-27T16:30:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/9c/9a/d0e9dc03c5269a1afb60661118296a32ed5d2c24298af61b676c11e05e56/scipy-1.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f23634f9e5adb51b2a77766dac217063e764337fbc816aa8ad9aaebcd4397fd3", size = 37960310, upload-time = "2025-07-27T16:31:06.151Z" }, - { url = "https://files.pythonhosted.org/packages/5e/00/c8f3130a50521a7977874817ca89e0599b1b4ee8e938bad8ae798a0e1f0d/scipy-1.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:57d75524cb1c5a374958a2eae3d84e1929bb971204cc9d52213fb8589183fc19", size = 39319239, upload-time = "2025-07-27T16:31:59.942Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f2/1ca3eda54c3a7e4c92f6acef7db7b3a057deb135540d23aa6343ef8ad333/scipy-1.16.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:d8da7c3dd67bcd93f15618938f43ed0995982eb38973023d46d4646c4283ad65", size = 36939460, upload-time = "2025-07-27T16:31:11.865Z" }, - { url = "https://files.pythonhosted.org/packages/80/30/98c2840b293a132400c0940bb9e140171dcb8189588619048f42b2ce7b4f/scipy-1.16.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:cc1d2f2fd48ba1e0620554fe5bc44d3e8f5d4185c8c109c7fbdf5af2792cfad2", size = 29093322, upload-time = "2025-07-27T16:31:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/c1/e6/1e6e006e850622cf2a039b62d1a6ddc4497d4851e58b68008526f04a9a00/scipy-1.16.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:21a611ced9275cb861bacadbada0b8c0623bc00b05b09eb97f23b370fc2ae56d", size = 21365329, upload-time = "2025-07-27T16:31:21.188Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/72a5aa5b820589dda9a25e329ca752842bfbbaf635e36bc7065a9b42216e/scipy-1.16.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dfbb25dffc4c3dd9371d8ab456ca81beeaf6f9e1c2119f179392f0dc1ab7695", size = 23897544, upload-time = "2025-07-27T16:31:25.408Z" }, - { url = "https://files.pythonhosted.org/packages/2b/dc/7122d806a6f9eb8a33532982234bed91f90272e990f414f2830cfe656e0b/scipy-1.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0ebb7204f063fad87fc0a0e4ff4a2ff40b2a226e4ba1b7e34bf4b79bf97cd86", size = 33442112, upload-time = "2025-07-27T16:31:30.62Z" }, - { url = "https://files.pythonhosted.org/packages/24/39/e383af23564daa1021a5b3afbe0d8d6a68ec639b943661841f44ac92de85/scipy-1.16.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1b9e5962656f2734c2b285a8745358ecb4e4efbadd00208c80a389227ec61ff", size = 35286594, upload-time = "2025-07-27T16:31:36.112Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/1a0b0aff40c3056d955f38b0df5d178350c3d74734ec54f9c68d23910be5/scipy-1.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e1a106f8c023d57a2a903e771228bf5c5b27b5d692088f457acacd3b54511e4", size = 35665080, upload-time = "2025-07-27T16:31:42.025Z" }, - { url = "https://files.pythonhosted.org/packages/64/df/ce88803e9ed6e27fe9b9abefa157cf2c80e4fa527cf17ee14be41f790ad4/scipy-1.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:709559a1db68a9abc3b2c8672c4badf1614f3b440b3ab326d86a5c0491eafae3", size = 38050306, upload-time = "2025-07-27T16:31:48.109Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6c/a76329897a7cae4937d403e623aa6aaea616a0bb5b36588f0b9d1c9a3739/scipy-1.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c0c804d60492a0aad7f5b2bb1862f4548b990049e27e828391ff2bf6f7199998", size = 39427705, upload-time = "2025-07-27T16:31:53.96Z" }, -] - [[package]] name = "setuptools" version = "80.9.0" @@ -2910,27 +2374,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/05/e6600db80270777c4a64238a98d442f0fd07cc8915be2a1c16da7f2b9e74/sympy-1.12-py3-none-any.whl", hash = "sha256:c3588cd4295d0c0f603d0f2ae780587e64e2efeedb3521e46b9bb1d08d184fa5", size = 5742435, upload-time = "2023-05-10T18:22:14.76Z" }, ] -[[package]] -name = "threadpoolctl" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/8a/c05f7831beb32aff70f808766224f11c650f7edfd49b27a8fc6666107006/threadpoolctl-3.2.0.tar.gz", hash = "sha256:c96a0ba3bdddeaca37dc4cc7344aafad41cdb8c313f74fdfe387a867bba93355", size = 36266, upload-time = "2023-07-13T14:53:53.299Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/12/fd4dea011af9d69e1cad05c75f3f7202cdcbeac9b712eea58ca779a72865/threadpoolctl-3.2.0-py3-none-any.whl", hash = "sha256:2b7818516e423bdaebb97c723f86a7c6b0a83d3f3b0970328d66f4d9104dc032", size = 15539, upload-time = "2023-07-13T14:53:39.336Z" }, -] - -[[package]] -name = "tifffile" -version = "2023.12.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/a4/6c0eadea1ccfcda27e6cce400c366098b5b082138a073f4252fe399f4148/tifffile-2023.12.9.tar.gz", hash = "sha256:9dd1da91180a6453018a241ff219e1905f169384355cd89c9ef4034c1b46cdb8", size = 353467, upload-time = "2023-12-09T20:46:29.203Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/a4/569fc717831969cf48bced350bdaf070cdeab06918d179429899e144358d/tifffile-2023.12.9-py3-none-any.whl", hash = "sha256:9b066e4b1a900891ea42ffd33dab8ba34c537935618b9893ddef42d7d422692f", size = 223627, upload-time = "2023-12-09T20:46:26.569Z" }, -] - [[package]] name = "tokenizers" version = "0.23.1" @@ -3211,15 +2654,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/a5/7aba9435beb863c2490bae3173a45f42044ac7a48155d3dd42ab49cfae45/watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6", size = 268026, upload-time = "2023-10-13T13:05:38.591Z" }, ] -[[package]] -name = "wcwidth" -version = "0.2.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, -] - [[package]] name = "websocket-client" version = "1.8.0" diff --git a/mobile/.gitignore b/mobile/.gitignore index 6730e3c46a..c426d46ec3 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -32,6 +32,7 @@ .pub/ /build/ lib/**/*.drift.dart +lib/**/*.freezed.dart lib/routing/router.gr.dart test/drift/main/generated/ diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index e828760789..7e314e8d81 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -110,3 +110,17 @@ dart_code_metrics: - prefer-const-border-radius - prefer-declaring-const-constructor - prefer-switch-expression + + # Riverpod + - avoid-calling-notifier-members-inside-build + - avoid-notifier-constructors + - avoid-nullable-async-value-pattern + - avoid-public-notifier-properties + - avoid-ref-inside-state-dispose + - avoid-ref-read-inside-build + - avoid-ref-watch-outside-build + - avoid-unnecessary-consumer-widgets + - dispose-provided-instances + - prefer-immutable-provider-arguments + - use-ref-and-state-synchronously + - use-ref-read-synchronously diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 1b8d2a97fb..5f4de8284a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -152,12 +152,6 @@ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> - - - cursor.count.toLong() } ?: 0L - fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result>) -> Unit) { + fun getAssetsForAlbum( + albumId: String, + updatedTimeCond: Long?, + callback: (Result>) -> Unit + ) { runSync(callback) { getAssetsForAlbum(albumId, updatedTimeCond) } } @@ -419,7 +424,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa }.awaitAll() completeWhenActive(callback, Result.success(results)) - } catch (e: CancellationException) { + } catch (_: CancellationException) { completeWhenActive( callback, Result.failure( FlutterError( @@ -437,23 +442,20 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa private suspend fun hashAsset(assetId: String): HashResult { return try { - val assetUri = ContentUris.withAppendedId( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - assetId.toLong() - ) - val digest = MessageDigest.getInstance("SHA-1") - ctx.contentResolver.openInputStream(assetUri)?.use { inputStream -> - var bytesRead: Int + openOriginalStream(assetId).use { inputStream -> val buffer = ByteArray(HASH_BUFFER_SIZE) - while (inputStream.read(buffer).also { bytesRead = it } > 0) { + while (true) { + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) break currentCoroutineContext().ensureActive() digest.update(buffer, 0, bytesRead) } - } ?: return HashResult(assetId, "Cannot open input stream for asset", null) + } - val hashString = Base64.encodeToString(digest.digest(), Base64.NO_WRAP) - HashResult(assetId, null, hashString) + HashResult(assetId, null, Base64.encodeToString(digest.digest(), Base64.NO_WRAP)) + } catch (e: CancellationException) { + throw e } catch (e: SecurityException) { HashResult(assetId, "Permission denied accessing asset: ${e.message}", null) } catch (e: Exception) { @@ -461,6 +463,37 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa } } + private fun openOriginalStream(assetId: String): InputStream { + val id = assetId.toLong() + val collection = when (getMediaType(id)) { + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + else -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + val uri = ContentUris.withAppendedId(collection, id) + val original = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.setRequireOriginal(uri) + } else { + uri + } + + return ctx.contentResolver.openInputStream(original) + ?: throw IOException("Cannot open original stream for asset $assetId") + } + + private fun getMediaType(id: Long): Int = + getCursor( + MediaStore.VOLUME_EXTERNAL, + "${MediaStore.MediaColumns._ID} = ?", + arrayOf(id.toString()), + arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE) + )?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)) + } else { + MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + } + } ?: MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + fun cancelHashing() { hashTask?.cancel() hashTask = null @@ -476,8 +509,11 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa syncJob = CoroutineScope(Dispatchers.IO).launch { try { completeWhenActive(callback, Result.success(work())) - } catch (e: CancellationException) { - completeWhenActive(callback, Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null))) + } catch (_: CancellationException) { + completeWhenActive( + callback, + Result.failure(FlutterError(SYNC_CANCELLED_CODE, "Sync cancelled", null)) + ) } catch (e: Exception) { completeWhenActive(callback, Result.failure(e)) } diff --git a/mobile/ios/Runner/Images/LocalImagesImpl.swift b/mobile/ios/Runner/Images/LocalImagesImpl.swift index 9c142da054..0d023ad466 100644 --- a/mobile/ios/Runner/Images/LocalImagesImpl.swift +++ b/mobile/ios/Runner/Images/LocalImagesImpl.swift @@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi { requestOptions.version = .current return requestOptions }() + private static let maxPixelSize: CGFloat = 16384 private static let registry = RequestRegistry() @@ -108,11 +109,16 @@ class LocalImageApiImpl: LocalImageApi { ])) } + let isOriginal = !(width > 0 && height > 0) + let targetSize = isOriginal + ? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize) + : CGSize(width: Double(width), height: Double(height)) + let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill var image: UIImage? Self.imageManager.requestImage( for: asset, - targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize, - contentMode: .aspectFill, + targetSize: targetSize, + contentMode: contentMode, options: Self.requestOptions, resultHandler: { (_image, info) -> Void in image = _image @@ -123,12 +129,38 @@ class LocalImageApiImpl: LocalImageApi { return request.completion(ImageProcessing.cancelledResult) } - guard let image = image, - let cgImage = image.cgImage else { + guard let fastImage = image, + var cgImage = fastImage.cgImage else { Self.registry.remove(requestId: requestId) return request.completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } + // .fast can return larger than the target, so retry with .exact to guarantee the bound. + if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) { + let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions + exactOptions.resizeMode = .exact + image = nil + Self.imageManager.requestImage( + for: asset, + targetSize: targetSize, + contentMode: contentMode, + options: exactOptions, + resultHandler: { (_image, info) -> Void in + image = _image + } + ) + + if request.isCancelled { + return request.completion(ImageProcessing.cancelledResult) + } + + guard let exactImage = image?.cgImage else { + Self.registry.remove(requestId: requestId) + return request.completion(.failure(PigeonError(code: "", message: "Could not resize image for \(assetId)", details: nil))) + } + cgImage = exactImage + } + if request.isCancelled { return request.completion(ImageProcessing.cancelledResult) } diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index ee371703a8..58f89339ac 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -12,13 +12,15 @@ struct ImageEntry: TimelineEntry { var subtitle: String? = nil var error: WidgetError? = nil var deepLink: URL? = nil + var forceFullColor: Bool = true } static func build( api: ImmichAPI, asset: Asset, dateOffset: Int, - subtitle: String? = nil + subtitle: String? = nil, + forceFullColor: Bool = true ) async throws -> Self { @@ -34,7 +36,8 @@ struct ImageEntry: TimelineEntry { image: image, metadata: EntryMetadata( subtitle: subtitle, - deepLink: asset.deepLink + deepLink: asset.deepLink, + forceFullColor: forceFullColor ) ) } @@ -111,12 +114,26 @@ struct ImageEntry: TimelineEntry { } +// Required so that we can decode older versions of the metadata +extension ImageEntry.Metadata { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) + error = try container.decodeIfPresent(WidgetError.self, forKey: .error) + deepLink = try container.decodeIfPresent(URL.self, forKey: .deepLink) + + forceFullColor = + try container.decodeIfPresent(Bool.self, forKey: .forceFullColor) ?? true + } +} + func generateRandomEntries( api: ImmichAPI, now: Date, count: Int, filter: SearchFilter = Album.NONE.filter, - subtitle: String? = nil + subtitle: String? = nil, + forceFullColor: Bool = true ) async throws -> [ImageEntry] { @@ -132,7 +149,8 @@ func generateRandomEntries( api: api, asset: asset, dateOffset: dateOffset, - subtitle: subtitle + subtitle: subtitle, + forceFullColor: forceFullColor ) } } diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index 17088d43b8..2e3a816d92 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -3,10 +3,10 @@ import WidgetKit extension Image { @ViewBuilder - func tintedWidgetImageModifier() -> some View { + func tintedWidgetImageModifier(forceFullColor: Bool) -> some View { if #available(iOS 18.0, *) { self - .widgetAccentedRenderingMode(.accentedDesaturated) + .widgetAccentedRenderingMode(forceFullColor ? .accentedDesaturated : .fullColor) } else { self } @@ -18,15 +18,24 @@ struct ImmichWidgetView: View { var body: some View { if let image = entry.image { - ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink) + ImmichWidgetContentView( + image: image, + subtitle: entry.metadata.subtitle, + deepLink: entry.metadata.deepLink, + forceFullColor: entry.metadata.forceFullColor + ) } else { - ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription) + ImmichWidgetLoadingView( + message: entry.metadata.error?.errorDescription, + forceFullColor: entry.metadata.forceFullColor + ) } } } private struct ImmichWidgetLoadingView: View { let message: String? + let forceFullColor: Bool var body: some View { let messageText = Text(message ?? "") @@ -39,7 +48,7 @@ private struct ImmichWidgetLoadingView: View { messageText.hidden() Image("LaunchImage") - .tintedWidgetImageModifier() + .tintedWidgetImageModifier(forceFullColor: forceFullColor) messageText } @@ -50,13 +59,14 @@ private struct ImmichWidgetContentView: View { let image: UIImage let subtitle: String? let deepLink: URL? + let forceFullColor: Bool var body: some View { ZStack(alignment: .leading) { Color.clear.overlay( Image(uiImage: image) .resizable() - .tintedWidgetImageModifier() + .tintedWidgetImageModifier(forceFullColor: forceFullColor) .scaledToFill() ) diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index 15fa3d722e..dee5a32159 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -2,7 +2,21 @@ import AppIntents import SwiftUI import WidgetKit -struct ImmichMemoryProvider: TimelineProvider { +// MARK: Widget Configuration + +struct MemoryConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Memories" } + static var description: IntentDescription { + "See memories from Immich." + } + + @Parameter(title: "Always Display in Full Color", default: false) + var forceFullColor: Bool +} + +// MARK: Provider + +struct ImmichMemoryProvider: AppIntentTimelineProvider { func getYearDifferenceSubtitle(assetYear: Int) -> String { let currentYear = Calendar.current.component(.year, from: Date.now) // construct a "X years ago" subtitle @@ -15,144 +29,132 @@ struct ImmichMemoryProvider: TimelineProvider { ImageEntry(date: Date(), image: nil) } - func getSnapshot( - in context: Context, - completion: @escaping @Sendable (ImageEntry) -> Void - ) { + func snapshot( + for configuration: MemoryConfigurationAppIntent, + in context: Context + ) async -> ImageEntry { let cacheKey = "memory_\(context.family.rawValue)" - Task { - guard let api = try? await ImmichAPI() else { - completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! - ) - return - } - - guard let memories = try? await api.fetchMemory(for: Date.now) - else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) - return - } - - for memory in memories { - if let asset = memory.assets.first(where: { $0.type == .image }), - let entry = try? await ImageEntry.build( - api: api, - asset: asset, - dateOffset: 0, - subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) - ) - { - completion(entry) - return - } - } - - // fallback to random image - guard - let randomImage = try? await api.fetchSearchResults().first, - let imageEntry = try? await ImageEntry.build( - api: api, - asset: randomImage, - dateOffset: 0 - ) - else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) - return - } - - completion(imageEntry) + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries + .first! } + + guard let memories = try? await api.fetchMemory(for: Date.now) else { + return ImageEntry.handleError(for: cacheKey).entries.first! + } + + for memory in memories { + if let asset = memory.assets.first(where: { $0.type == .image }), + let entry = try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: 0, + subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year), + forceFullColor: configuration.forceFullColor + ) + { + return entry + } + } + + // fallback to random image + guard + let randomImage = try? await api.fetchSearchResults().first, + let imageEntry = try? await ImageEntry.build( + api: api, + asset: randomImage, + dateOffset: 0, + forceFullColor: configuration.forceFullColor + ) + else { + return ImageEntry.handleError(for: cacheKey).entries.first! + } + + return imageEntry } - func getTimeline( - in context: Context, - completion: @escaping @Sendable (Timeline) -> Void - ) { - Task { - var entries: [ImageEntry] = [] - let now = Date() + func timeline( + for configuration: MemoryConfigurationAppIntent, + in context: Context + ) async + -> Timeline + { + var entries: [ImageEntry] = [] + let now = Date() - let cacheKey = "memory_\(context.family.rawValue)" + let cacheKey = "memory_\(context.family.rawValue)" - guard let api = try? await ImmichAPI() else { - completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin) - ) - return - } - - let memories: [MemoryResult] - do { - memories = try await api.fetchMemory(for: Date.now) - - await withTaskGroup(of: ImageEntry?.self) { group in - var totalAssets = 0 - - for memory in memories { - for asset in memory.assets { - if asset.type == .image && totalAssets < 12 { - group.addTask { - try? await ImageEntry.build( - api: api, - asset: asset, - dateOffset: totalAssets, - subtitle: getYearDifferenceSubtitle( - assetYear: memory.data.year - ) - ) - } - - totalAssets += 1 - } - } - } - - for await result in group { - if let entry = result { - entries.append(entry) - } - } - } - - } catch { - completion(ImageEntry.handleError(for: cacheKey)) - return - } - - // If we didn't add any memory images (some failure occurred or no images in memory), - // default to 12 hours of random photos - if entries.count == 0 { - // this must be a do/catch since we need to - // distinguish between a network fail and an empty search - do { - let search = try await generateRandomEntries( - api: api, - now: now, - count: 12 - ) - - // Load or save a cached asset for when network conditions are bad - if search.count == 0 { - completion( - ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) - ) - return - } - - entries.append(contentsOf: search) - } catch { - completion(ImageEntry.handleError(for: cacheKey)) - return - } - } - - // cache the last image - try? entries.last!.cache(for: cacheKey) - - completion(Timeline(entries: entries, policy: .atEnd)) + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleError(for: cacheKey, error: .noLogin) } + + let memories: [MemoryResult] + do { + memories = try await api.fetchMemory(for: Date.now) + + await withTaskGroup(of: ImageEntry?.self) { group in + var totalAssets = 0 + + for memory in memories { + for asset in memory.assets { + if asset.type == .image && totalAssets < 12 { + group.addTask { + try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: totalAssets, + subtitle: getYearDifferenceSubtitle( + assetYear: memory.data.year + ), + forceFullColor: configuration.forceFullColor + ) + } + + totalAssets += 1 + } + } + } + + for await result in group { + if let entry = result { + entries.append(entry) + } + } + } + + } catch { + return ImageEntry.handleError(for: cacheKey) + } + + // If we didn't add any memory images (some failure occurred or no images in memory), + // default to 12 hours of random photos + if entries.count == 0 { + // this must be a do/catch since we need to + // distinguish between a network fail and an empty search + do { + let search = try await generateRandomEntries( + api: api, + now: now, + count: 12, + forceFullColor: configuration.forceFullColor + ) + + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + } + + entries.append(contentsOf: search) + } catch { + return ImageEntry.handleError(for: cacheKey) + } + } + + // cache the last image + try? entries.last!.cache(for: cacheKey) + + return Timeline(entries: entries, policy: .atEnd) } } @@ -160,8 +162,9 @@ struct ImmichMemoryWidget: Widget { let kind: String = "com.immich.widget.memory" var body: some WidgetConfiguration { - StaticConfiguration( + AppIntentConfiguration( kind: kind, + intent: MemoryConfigurationAppIntent.self, provider: ImmichMemoryProvider() ) { entry in ImmichWidgetView(entry: entry) diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 37f3c5e596..d987464152 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -47,6 +47,9 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent { @Parameter(title: "Show Album Name", default: false) var showAlbumName: Bool + + @Parameter(title: "Always Display in Full Color", default: false) + var forceFullColor: Bool } // MARK: Provider @@ -76,7 +79,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { let entry = try? await ImageEntry.build( api: api, asset: randomImage, - dateOffset: 0 + dateOffset: 0, + forceFullColor: configuration.forceFullColor ) else { return ImageEntry.handleError(for: cacheKey).entries.first! @@ -114,7 +118,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { now: now, count: 12, filter: album.filter, - subtitle: configuration.showAlbumName ? albumName : nil + subtitle: configuration.showAlbumName ? albumName : nil, + forceFullColor: configuration.forceFullColor ) // Load or save a cached asset for when network conditions are bad diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index d59c48c045..d3f85ceb2e 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -24,3 +24,14 @@ enum SlideshowLook { contain, cover, blurredBackground } enum SlideshowDirection { forward, backward, shuffle } enum PartnerDirection { sharedBy, sharedWith } + +enum DevicePermission { photos, videos, storage, mediaLocation } + +enum DevicePermissionStatus { + denied, + granted, + limited, + permanentlyDenied; + + bool get hasAccess => this == granted || this == limited; +} diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart index 63f4ed6be3..e63a3774e1 100644 --- a/mobile/lib/domain/models/album/album.model.dart +++ b/mobile/lib/domain/models/album/album.model.dart @@ -1,3 +1,8 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'album.model.freezed.dart'; + enum AlbumAssetOrder { // do not change this order! asc, @@ -12,118 +17,20 @@ enum AlbumUserRole { } // Model for an album stored in the server -class RemoteAlbum { - final String id; - final String name; - final String ownerId; - final String description; - final DateTime createdAt; - final DateTime updatedAt; - final String? thumbnailAssetId; - final bool isActivityEnabled; - final AlbumAssetOrder order; - final int assetCount; - final String ownerName; - final bool isShared; - - const RemoteAlbum({ - required this.id, - required this.name, - required this.ownerId, - required this.description, - required this.createdAt, - required this.updatedAt, - this.thumbnailAssetId, - required this.isActivityEnabled, - required this.order, - required this.assetCount, - required this.ownerName, - required this.isShared, - }); - - @override - String toString() { - return '''Album { - id: $id, - name: $name, - ownerId: $ownerId, - description: $description, - createdAt: $createdAt, - updatedAt: $updatedAt, - isActivityEnabled: $isActivityEnabled, - order: $order, - thumbnailAssetId: ${thumbnailAssetId ?? ""} - assetCount: $assetCount - ownerName: $ownerName - isShared: $isShared - }'''; - } - - @override - bool operator ==(Object other) { - if (other is! RemoteAlbum) { - return false; - } - if (identical(this, other)) { - return true; - } - return id == other.id && - name == other.name && - ownerId == other.ownerId && - description == other.description && - createdAt == other.createdAt && - updatedAt == other.updatedAt && - thumbnailAssetId == other.thumbnailAssetId && - isActivityEnabled == other.isActivityEnabled && - order == other.order && - assetCount == other.assetCount && - ownerName == other.ownerName && - isShared == other.isShared; - } - - @override - int get hashCode { - return id.hashCode ^ - name.hashCode ^ - ownerId.hashCode ^ - description.hashCode ^ - createdAt.hashCode ^ - updatedAt.hashCode ^ - thumbnailAssetId.hashCode ^ - isActivityEnabled.hashCode ^ - order.hashCode ^ - assetCount.hashCode ^ - ownerName.hashCode ^ - isShared.hashCode; - } - - RemoteAlbum copyWith({ - String? id, - String? name, - String? ownerId, - String? description, - DateTime? createdAt, - DateTime? updatedAt, +@freezed +abstract class RemoteAlbum with _$RemoteAlbum { + const factory RemoteAlbum({ + required String id, + required String name, + required String ownerId, + required String description, + required DateTime createdAt, + required DateTime updatedAt, String? thumbnailAssetId, - bool? isActivityEnabled, - AlbumAssetOrder? order, - int? assetCount, - String? ownerName, - bool? isShared, - }) { - return RemoteAlbum( - id: id ?? this.id, - name: name ?? this.name, - ownerId: ownerId ?? this.ownerId, - description: description ?? this.description, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, - isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, - order: order ?? this.order, - assetCount: assetCount ?? this.assetCount, - ownerName: ownerName ?? this.ownerName, - isShared: isShared ?? this.isShared, - ); - } + required bool isActivityEnabled, + required AlbumAssetOrder order, + required int assetCount, + required String ownerName, + required bool isShared, + }) = _RemoteAlbum; } diff --git a/mobile/lib/domain/models/album/local_album.model.dart b/mobile/lib/domain/models/album/local_album.model.dart index 9e8521fa02..cddc1d1953 100644 --- a/mobile/lib/domain/models/album/local_album.model.dart +++ b/mobile/lib/domain/models/album/local_album.model.dart @@ -1,3 +1,8 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'local_album.model.freezed.dart'; + enum BackupSelection { // Used to sort albums based on the backupSelection // selected -> none -> excluded @@ -7,85 +12,15 @@ enum BackupSelection { excluded, } -class LocalAlbum { - final String id; - final String name; - final DateTime updatedAt; - final bool isIosSharedAlbum; - - final int assetCount; - final BackupSelection backupSelection; - final String? linkedRemoteAlbumId; - - const LocalAlbum({ - required this.id, - required this.name, - required this.updatedAt, - this.assetCount = 0, - this.backupSelection = BackupSelection.none, - this.isIosSharedAlbum = false, - this.linkedRemoteAlbumId, - }); - - LocalAlbum copyWith({ - String? id, - String? name, - DateTime? updatedAt, - int? assetCount, - BackupSelection? backupSelection, - bool? isIosSharedAlbum, +@freezed +abstract class LocalAlbum with _$LocalAlbum { + const factory LocalAlbum({ + required String id, + required String name, + required DateTime updatedAt, + @Default(0) int assetCount, + @Default(BackupSelection.none) BackupSelection backupSelection, + @Default(false) bool isIosSharedAlbum, String? linkedRemoteAlbumId, - }) { - return LocalAlbum( - id: id ?? this.id, - name: name ?? this.name, - updatedAt: updatedAt ?? this.updatedAt, - assetCount: assetCount ?? this.assetCount, - backupSelection: backupSelection ?? this.backupSelection, - isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, - linkedRemoteAlbumId: linkedRemoteAlbumId ?? this.linkedRemoteAlbumId, - ); - } - - @override - bool operator ==(Object other) { - if (other is! LocalAlbum) { - return false; - } - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.name == name && - other.updatedAt == updatedAt && - other.assetCount == assetCount && - other.backupSelection == backupSelection && - other.isIosSharedAlbum == isIosSharedAlbum && - other.linkedRemoteAlbumId == linkedRemoteAlbumId; - } - - @override - int get hashCode { - return id.hashCode ^ - name.hashCode ^ - updatedAt.hashCode ^ - assetCount.hashCode ^ - backupSelection.hashCode ^ - isIosSharedAlbum.hashCode ^ - linkedRemoteAlbumId.hashCode; - } - - @override - String toString() { - return '''LocalAlbum: { -id: $id, -name: $name, -updatedAt: $updatedAt, -assetCount: $assetCount, -backupSelection: $backupSelection, -isIosSharedAlbum: $isIosSharedAlbum -linkedRemoteAlbumId: $linkedRemoteAlbumId, -}'''; - } + }) = _LocalAlbum; } diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index d7d74daa25..ac89fe9a0a 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -77,6 +77,7 @@ sealed class BaseAsset { // Overridden in subclasses AssetState get storage; + String get id; String? get localId; String? get remoteId; String get heroTag; diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 0ad4c8f39b..15039761a8 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -1,6 +1,7 @@ part of 'base_asset.model.dart'; class LocalAsset extends BaseAsset { + @override final String id; final String? remoteAssetId; final String? cloudId; diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 387a817eab..5707329ba3 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -4,6 +4,7 @@ enum AssetVisibility { timeline, hidden, archive, locked } // Model for an asset stored in the server class RemoteAsset extends BaseAsset { + @override final String id; final String? localAssetId; final String? thumbHash; diff --git a/mobile/lib/domain/models/asset_face.model.dart b/mobile/lib/domain/models/asset_face.model.dart index 1388836946..c65f159fe7 100644 --- a/mobile/lib/domain/models/asset_face.model.dart +++ b/mobile/lib/domain/models/asset_face.model.dart @@ -1,100 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'asset_face.model.freezed.dart'; + // Model for an asset face stored in the server -class AssetFace { - final String id; - final String assetId; - final String? personId; - final int imageWidth; - final int imageHeight; - final int boundingBoxX1; - final int boundingBoxY1; - final int boundingBoxX2; - final int boundingBoxY2; - final String sourceType; - - const AssetFace({ - required this.id, - required this.assetId, - this.personId, - required this.imageWidth, - required this.imageHeight, - required this.boundingBoxX1, - required this.boundingBoxY1, - required this.boundingBoxX2, - required this.boundingBoxY2, - required this.sourceType, - }); - - AssetFace copyWith({ - String? id, - String? assetId, +@freezed +abstract class AssetFace with _$AssetFace { + const factory AssetFace({ + required String id, + required String assetId, String? personId, - int? imageWidth, - int? imageHeight, - int? boundingBoxX1, - int? boundingBoxY1, - int? boundingBoxX2, - int? boundingBoxY2, - String? sourceType, - }) { - return AssetFace( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - personId: personId ?? this.personId, - imageWidth: imageWidth ?? this.imageWidth, - imageHeight: imageHeight ?? this.imageHeight, - boundingBoxX1: boundingBoxX1 ?? this.boundingBoxX1, - boundingBoxY1: boundingBoxY1 ?? this.boundingBoxY1, - boundingBoxX2: boundingBoxX2 ?? this.boundingBoxX2, - boundingBoxY2: boundingBoxY2 ?? this.boundingBoxY2, - sourceType: sourceType ?? this.sourceType, - ); - } - - @override - String toString() { - return '''AssetFace { - id: $id, - assetId: $assetId, - personId: ${personId ?? ""}, - imageWidth: $imageWidth, - imageHeight: $imageHeight, - boundingBoxX1: $boundingBoxX1, - boundingBoxY1: $boundingBoxY1, - boundingBoxX2: $boundingBoxX2, - boundingBoxY2: $boundingBoxY2, - sourceType: $sourceType, -}'''; - } - - @override - bool operator ==(covariant AssetFace other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.assetId == assetId && - other.personId == personId && - other.imageWidth == imageWidth && - other.imageHeight == imageHeight && - other.boundingBoxX1 == boundingBoxX1 && - other.boundingBoxY1 == boundingBoxY1 && - other.boundingBoxX2 == boundingBoxX2 && - other.boundingBoxY2 == boundingBoxY2 && - other.sourceType == sourceType; - } - - @override - int get hashCode { - return id.hashCode ^ - assetId.hashCode ^ - personId.hashCode ^ - imageWidth.hashCode ^ - imageHeight.hashCode ^ - boundingBoxX1.hashCode ^ - boundingBoxY1.hashCode ^ - boundingBoxX2.hashCode ^ - boundingBoxY2.hashCode ^ - sourceType.hashCode; - } + required int imageWidth, + required int imageHeight, + required int boundingBoxX1, + required int boundingBoxY1, + required int boundingBoxX2, + required int boundingBoxY2, + required String sourceType, + }) = _AssetFace; } diff --git a/mobile/lib/domain/models/config/album_config.dart b/mobile/lib/domain/models/config/album_config.dart index a83fc32fbf..16deca1b8f 100644 --- a/mobile/lib/domain/models/config/album_config.dart +++ b/mobile/lib/domain/models/config/album_config.dart @@ -1,26 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -class AlbumConfig { - final AlbumSortMode sortMode; - final bool isReverse; - final bool isGrid; +part 'album_config.freezed.dart'; - const AlbumConfig({this.sortMode = AlbumSortMode.mostRecent, this.isReverse = true, this.isGrid = false}); - - AlbumConfig copyWith({AlbumSortMode? sortMode, bool? isReverse, bool? isGrid}) => AlbumConfig( - sortMode: sortMode ?? this.sortMode, - isReverse: isReverse ?? this.isReverse, - isGrid: isGrid ?? this.isGrid, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AlbumConfig && other.sortMode == sortMode && other.isReverse == isReverse && other.isGrid == isGrid); - - @override - int get hashCode => Object.hash(sortMode, isReverse, isGrid); - - @override - String toString() => 'AlbumConfig(sortMode: $sortMode, isReverse: $isReverse, isGrid: $isGrid)'; +@freezed +abstract class AlbumConfig with _$AlbumConfig { + const factory AlbumConfig({ + @Default(AlbumSortMode.mostRecent) AlbumSortMode sortMode, + @Default(true) bool isReverse, + @Default(false) bool isGrid, + }) = _AlbumConfig; } diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index df147f3e3a..bc089e621e 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/album_config.dart'; @@ -19,107 +21,29 @@ import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/utils/semver.dart'; +part 'app_config.freezed.dart'; + const defaultConfig = AppConfig(); -class AppConfig { - final LogLevel logLevel; - final ThemeConfig theme; - final CleanupConfig cleanup; - final MapConfig map; - final TimelineConfig timeline; - final ImageConfig image; - final ViewerConfig viewer; - final SlideshowConfig slideshow; - final AlbumConfig album; - final BackupConfig backup; - final NetworkConfig network; - final ShareConfig share; - final FeatureMessageConfig featureMessage; +@freezed +abstract class AppConfig with _$AppConfig { + const AppConfig._(); - const AppConfig({ - this.logLevel = .info, - this.theme = const .new(), - this.cleanup = const .new(), - this.map = const .new(), - this.timeline = const .new(), - this.image = const .new(), - this.viewer = const .new(), - this.slideshow = const .new(), - this.album = const .new(), - this.backup = const .new(), - this.network = const .new(), - this.share = const .new(), - this.featureMessage = const .new(), - }); - - AppConfig copyWith({ - LogLevel? logLevel, - ThemeConfig? theme, - CleanupConfig? cleanup, - MapConfig? map, - TimelineConfig? timeline, - ImageConfig? image, - ViewerConfig? viewer, - SlideshowConfig? slideshow, - AlbumConfig? album, - BackupConfig? backup, - NetworkConfig? network, - ShareConfig? share, - FeatureMessageConfig? featureMessage, - }) => .new( - logLevel: logLevel ?? this.logLevel, - theme: theme ?? this.theme, - cleanup: cleanup ?? this.cleanup, - map: map ?? this.map, - timeline: timeline ?? this.timeline, - image: image ?? this.image, - viewer: viewer ?? this.viewer, - slideshow: slideshow ?? this.slideshow, - album: album ?? this.album, - backup: backup ?? this.backup, - network: network ?? this.network, - share: share ?? this.share, - featureMessage: featureMessage ?? this.featureMessage, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AppConfig && - other.logLevel == logLevel && - other.theme == theme && - other.cleanup == cleanup && - other.map == map && - other.timeline == timeline && - other.image == image && - other.viewer == viewer && - other.slideshow == slideshow && - other.album == album && - other.backup == backup && - other.network == network && - other.share == share && - other.featureMessage == featureMessage); - - @override - int get hashCode => Object.hash( - logLevel, - theme, - cleanup, - map, - timeline, - image, - viewer, - slideshow, - album, - backup, - network, - share, - featureMessage, - ); - - @override - String toString() => - 'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)'; + const factory AppConfig({ + @Default(LogLevel.info) LogLevel logLevel, + @Default(ThemeConfig()) ThemeConfig theme, + @Default(CleanupConfig()) CleanupConfig cleanup, + @Default(MapConfig()) MapConfig map, + @Default(TimelineConfig()) TimelineConfig timeline, + @Default(ImageConfig()) ImageConfig image, + @Default(ViewerConfig()) ViewerConfig viewer, + @Default(SlideshowConfig()) SlideshowConfig slideshow, + @Default(AlbumConfig()) AlbumConfig album, + @Default(BackupConfig()) BackupConfig backup, + @Default(NetworkConfig()) NetworkConfig network, + @Default(ShareConfig()) ShareConfig share, + @Default(FeatureMessageConfig()) FeatureMessageConfig featureMessage, + }) = _AppConfig; T read(SettingsKey key) => (switch (key) { diff --git a/mobile/lib/domain/models/config/backup_config.dart b/mobile/lib/domain/models/config/backup_config.dart index 19f91a4ed7..4177408fcc 100644 --- a/mobile/lib/domain/models/config/backup_config.dart +++ b/mobile/lib/domain/models/config/backup_config.dart @@ -1,52 +1,16 @@ -class BackupConfig { - final bool enabled; - final bool useCellularForVideos; - final bool useCellularForPhotos; - final bool requireCharging; - final int triggerDelay; - final bool syncAlbums; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const BackupConfig({ - this.enabled = false, - this.useCellularForVideos = false, - this.useCellularForPhotos = false, - this.requireCharging = false, - this.triggerDelay = 30, - this.syncAlbums = false, - }); +part 'backup_config.freezed.dart'; - BackupConfig copyWith({ - bool? enabled, - bool? useCellularForVideos, - bool? useCellularForPhotos, - bool? requireCharging, - int? triggerDelay, - bool? syncAlbums, - }) => BackupConfig( - enabled: enabled ?? this.enabled, - useCellularForVideos: useCellularForVideos ?? this.useCellularForVideos, - useCellularForPhotos: useCellularForPhotos ?? this.useCellularForPhotos, - requireCharging: requireCharging ?? this.requireCharging, - triggerDelay: triggerDelay ?? this.triggerDelay, - syncAlbums: syncAlbums ?? this.syncAlbums, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is BackupConfig && - other.enabled == enabled && - other.useCellularForVideos == useCellularForVideos && - other.useCellularForPhotos == useCellularForPhotos && - other.requireCharging == requireCharging && - other.triggerDelay == triggerDelay && - other.syncAlbums == syncAlbums); - - @override - int get hashCode => - Object.hash(enabled, useCellularForVideos, useCellularForPhotos, requireCharging, triggerDelay, syncAlbums); - - @override - String toString() => - 'BackupConfig(enabled: $enabled, useCellularForVideos: $useCellularForVideos, useCellularForPhotos: $useCellularForPhotos, requireCharging: $requireCharging, triggerDelay: $triggerDelay, syncAlbums: $syncAlbums)'; +@freezed +abstract class BackupConfig with _$BackupConfig { + const factory BackupConfig({ + @Default(false) bool enabled, + @Default(false) bool useCellularForVideos, + @Default(false) bool useCellularForPhotos, + @Default(false) bool requireCharging, + @Default(30) int triggerDelay, + @Default(false) bool syncAlbums, + }) = _BackupConfig; } diff --git a/mobile/lib/domain/models/config/feature_message_config.dart b/mobile/lib/domain/models/config/feature_message_config.dart index d5c7c69550..0fc13fd51f 100644 --- a/mobile/lib/domain/models/config/feature_message_config.dart +++ b/mobile/lib/domain/models/config/feature_message_config.dart @@ -1,20 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/utils/semver.dart'; -class FeatureMessageConfig { - final SemVer seenRelease; +part 'feature_message_config.freezed.dart'; - const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)}); - - FeatureMessageConfig copyWith({SemVer? seenRelease}) => - FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease); - - @override - bool operator ==(Object other) => - identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease); - - @override - int get hashCode => seenRelease.hashCode; - - @override - String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)'; +@freezed +abstract class FeatureMessageConfig with _$FeatureMessageConfig { + const factory FeatureMessageConfig({@Default(SemVer(major: 0, minor: 0, patch: 0)) SemVer seenRelease}) = + _FeatureMessageConfig; } diff --git a/mobile/lib/domain/models/config/image_config.dart b/mobile/lib/domain/models/config/image_config.dart index 8410a9010b..a51193d1d3 100644 --- a/mobile/lib/domain/models/config/image_config.dart +++ b/mobile/lib/domain/models/config/image_config.dart @@ -1,20 +1,9 @@ -class ImageConfig { - final bool preferRemote; - final bool loadOriginal; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const ImageConfig({this.preferRemote = false, this.loadOriginal = false}); +part 'image_config.freezed.dart'; - ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) => - ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal); - - @override - int get hashCode => Object.hash(preferRemote, loadOriginal); - - @override - String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)'; +@freezed +abstract class ImageConfig with _$ImageConfig { + const factory ImageConfig({@Default(false) bool preferRemote, @Default(false) bool loadOriginal}) = _ImageConfig; } diff --git a/mobile/lib/domain/models/config/share_config.dart b/mobile/lib/domain/models/config/share_config.dart index 898867ff78..49530aca30 100644 --- a/mobile/lib/domain/models/config/share_config.dart +++ b/mobile/lib/domain/models/config/share_config.dart @@ -1,18 +1,10 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/enums.dart'; -class ShareConfig { - final ShareAssetType fileType; +part 'share_config.freezed.dart'; - const ShareConfig({this.fileType = ShareAssetType.original}); - - ShareConfig copyWith({ShareAssetType? fileType}) => ShareConfig(fileType: fileType ?? this.fileType); - - @override - bool operator ==(Object other) => identical(this, other) || (other is ShareConfig && other.fileType == fileType); - - @override - int get hashCode => fileType.hashCode; - - @override - String toString() => 'ShareConfig(fileType: $fileType)'; +@freezed +abstract class ShareConfig with _$ShareConfig { + const factory ShareConfig({@Default(ShareAssetType.original) ShareAssetType fileType}) = _ShareConfig; } diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart index 6bcdaadc77..cacba1a89e 100644 --- a/mobile/lib/domain/models/config/slideshow_config.dart +++ b/mobile/lib/domain/models/config/slideshow_config.dart @@ -1,38 +1,15 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/enums.dart'; -class SlideshowConfig { - final bool repeat; - final int duration; - final SlideshowLook look; - final SlideshowDirection direction; +part 'slideshow_config.freezed.dart'; - const SlideshowConfig({ - this.repeat = true, - this.duration = 5, - this.look = SlideshowLook.blurredBackground, - this.direction = SlideshowDirection.forward, - }); - - SlideshowConfig copyWith({bool? repeat, int? duration, SlideshowLook? look, SlideshowDirection? direction}) => - SlideshowConfig( - repeat: repeat ?? this.repeat, - duration: duration ?? this.duration, - look: look ?? this.look, - direction: direction ?? this.direction, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SlideshowConfig && - other.repeat == repeat && - other.duration == duration && - other.look == look && - other.direction == direction); - - @override - int get hashCode => Object.hash(repeat, duration, look, direction); - - @override - String toString() => 'SlideshowConfig(repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; +@freezed +abstract class SlideshowConfig with _$SlideshowConfig { + const factory SlideshowConfig({ + @Default(true) bool repeat, + @Default(5) int duration, + @Default(SlideshowLook.blurredBackground) SlideshowLook look, + @Default(SlideshowDirection.forward) SlideshowDirection direction, + }) = _SlideshowConfig; } diff --git a/mobile/lib/domain/models/config/theme_config.dart b/mobile/lib/domain/models/config/theme_config.dart index fa955c5d46..ac651a8062 100644 --- a/mobile/lib/domain/models/config/theme_config.dart +++ b/mobile/lib/domain/models/config/theme_config.dart @@ -1,44 +1,16 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/constants/colors.dart'; -class ThemeConfig { - final ThemeMode mode; - final ImmichColorPreset primaryColor; - final bool dynamicTheme; - final bool colorfulInterface; +part 'theme_config.freezed.dart'; - const ThemeConfig({ - this.mode = .system, - this.primaryColor = .indigo, - this.dynamicTheme = false, - this.colorfulInterface = true, - }); - - ThemeConfig copyWith({ - ThemeMode? mode, - ImmichColorPreset? primaryColor, - bool? dynamicTheme, - bool? colorfulInterface, - }) => .new( - mode: mode ?? this.mode, - primaryColor: primaryColor ?? this.primaryColor, - dynamicTheme: dynamicTheme ?? this.dynamicTheme, - colorfulInterface: colorfulInterface ?? this.colorfulInterface, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ThemeConfig && - other.mode == mode && - other.primaryColor == primaryColor && - other.dynamicTheme == dynamicTheme && - other.colorfulInterface == colorfulInterface); - - @override - int get hashCode => Object.hash(mode, primaryColor, dynamicTheme, colorfulInterface); - - @override - String toString() => - 'ThemeConfig(mode: $mode, primaryColor: $primaryColor, dynamicTheme: $dynamicTheme, colorfulInterface: $colorfulInterface)'; +@freezed +abstract class ThemeConfig with _$ThemeConfig { + const factory ThemeConfig({ + @Default(ThemeMode.system) ThemeMode mode, + @Default(ImmichColorPreset.indigo) ImmichColorPreset primaryColor, + @Default(false) bool dynamicTheme, + @Default(true) bool colorfulInterface, + }) = _ThemeConfig; } diff --git a/mobile/lib/domain/models/config/timeline_config.dart b/mobile/lib/domain/models/config/timeline_config.dart index 4b6b9d5625..c4681b0a62 100644 --- a/mobile/lib/domain/models/config/timeline_config.dart +++ b/mobile/lib/domain/models/config/timeline_config.dart @@ -1,30 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; -class TimelineConfig { - final int tilesPerRow; - final GroupAssetsBy groupAssetsBy; - final bool storageIndicator; +part 'timeline_config.freezed.dart'; - const TimelineConfig({this.tilesPerRow = 4, this.groupAssetsBy = GroupAssetsBy.day, this.storageIndicator = true}); - - TimelineConfig copyWith({int? tilesPerRow, GroupAssetsBy? groupAssetsBy, bool? storageIndicator}) => TimelineConfig( - tilesPerRow: tilesPerRow ?? this.tilesPerRow, - groupAssetsBy: groupAssetsBy ?? this.groupAssetsBy, - storageIndicator: storageIndicator ?? this.storageIndicator, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is TimelineConfig && - other.tilesPerRow == tilesPerRow && - other.groupAssetsBy == groupAssetsBy && - other.storageIndicator == storageIndicator); - - @override - int get hashCode => Object.hash(tilesPerRow, groupAssetsBy, storageIndicator); - - @override - String toString() => - 'TimelineConfig(tilesPerRow: $tilesPerRow, groupAssetsBy: $groupAssetsBy, storageIndicator: $storageIndicator)'; +@freezed +abstract class TimelineConfig with _$TimelineConfig { + const factory TimelineConfig({ + @Default(4) int tilesPerRow, + @Default(GroupAssetsBy.day) GroupAssetsBy groupAssetsBy, + @Default(true) bool storageIndicator, + }) = _TimelineConfig; } diff --git a/mobile/lib/domain/models/config/viewer_config.dart b/mobile/lib/domain/models/config/viewer_config.dart index 595f2bee5d..e799843034 100644 --- a/mobile/lib/domain/models/config/viewer_config.dart +++ b/mobile/lib/domain/models/config/viewer_config.dart @@ -1,37 +1,14 @@ -class ViewerConfig { - final bool loopVideo; - final bool loadOriginalVideo; - final bool autoPlayVideo; - final bool tapToNavigate; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const ViewerConfig({ - this.loopVideo = true, - this.loadOriginalVideo = false, - this.autoPlayVideo = true, - this.tapToNavigate = false, - }); +part 'viewer_config.freezed.dart'; - ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) => - ViewerConfig( - loopVideo: loopVideo ?? this.loopVideo, - loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo, - autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo, - tapToNavigate: tapToNavigate ?? this.tapToNavigate, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ViewerConfig && - other.loopVideo == loopVideo && - other.loadOriginalVideo == loadOriginalVideo && - other.autoPlayVideo == autoPlayVideo && - other.tapToNavigate == tapToNavigate); - - @override - int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate); - - @override - String toString() => - 'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)'; +@freezed +abstract class ViewerConfig with _$ViewerConfig { + const factory ViewerConfig({ + @Default(true) bool loopVideo, + @Default(false) bool loadOriginalVideo, + @Default(true) bool autoPlayVideo, + @Default(false) bool tapToNavigate, + }) = _ViewerConfig; } diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index 4284aef2ab..3536048ecc 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -1,30 +1,40 @@ -class ExifInfo { - final int? assetId; - final int? fileSize; - final String? description; - final bool isFlipped; - final String? orientation; - final String? timeZone; - final DateTime? dateTimeOriginal; - final int? rating; - final int? width; - final int? height; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - // GPS - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? country; +part 'exif.model.freezed.dart'; - // Camera related - final String? make; - final String? model; - final String? lens; - final double? f; - final double? mm; - final int? iso; - final double? exposureSeconds; +@freezed +abstract class ExifInfo with _$ExifInfo { + const ExifInfo._(); + + const factory ExifInfo({ + int? assetId, + int? fileSize, + String? description, + @Default(false) bool isFlipped, + String? orientation, + String? timeZone, + DateTime? dateTimeOriginal, + int? rating, + int? width, + int? height, + + // GPS + double? latitude, + double? longitude, + String? city, + String? state, + String? country, + + // Camera related + String? make, + String? model, + String? lens, + double? f, + double? mm, + int? iso, + double? exposureSeconds, + }) = _ExifInfo; bool get hasCoordinates => latitude != null && longitude != null && latitude != 0 && longitude != 0; @@ -41,162 +51,4 @@ class ExifInfo { String get fNumber => f == null ? "" : f!.toStringAsFixed(1); String get focalLength => mm == null ? "" : mm!.toStringAsFixed(3); - - const ExifInfo({ - this.assetId, - this.fileSize, - this.description, - this.orientation, - this.timeZone, - this.dateTimeOriginal, - this.rating, - this.width, - this.height, - this.isFlipped = false, - this.latitude, - this.longitude, - this.city, - this.state, - this.country, - this.make, - this.model, - this.lens, - this.f, - this.mm, - this.iso, - this.exposureSeconds, - }); - - @override - bool operator ==(covariant ExifInfo other) { - if (identical(this, other)) { - return true; - } - - return other.fileSize == fileSize && - other.description == description && - other.isFlipped == isFlipped && - other.orientation == orientation && - other.timeZone == timeZone && - other.dateTimeOriginal == dateTimeOriginal && - other.rating == rating && - other.width == width && - other.height == height && - other.latitude == latitude && - other.longitude == longitude && - other.city == city && - other.state == state && - other.country == country && - other.make == make && - other.model == model && - other.lens == lens && - other.f == f && - other.mm == mm && - other.iso == iso && - other.exposureSeconds == exposureSeconds && - other.assetId == assetId; - } - - @override - int get hashCode { - return fileSize.hashCode ^ - description.hashCode ^ - orientation.hashCode ^ - isFlipped.hashCode ^ - timeZone.hashCode ^ - dateTimeOriginal.hashCode ^ - rating.hashCode ^ - width.hashCode ^ - height.hashCode ^ - latitude.hashCode ^ - longitude.hashCode ^ - city.hashCode ^ - state.hashCode ^ - country.hashCode ^ - make.hashCode ^ - model.hashCode ^ - lens.hashCode ^ - f.hashCode ^ - mm.hashCode ^ - iso.hashCode ^ - exposureSeconds.hashCode ^ - assetId.hashCode; - } - - @override - String toString() { - return '''{ -fileSize: ${fileSize ?? 'NA'}, -description: ${description ?? 'NA'}, -orientation: ${orientation ?? 'NA'}, -isFlipped: $isFlipped, -timeZone: ${timeZone ?? 'NA'}, -dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, -rating: ${rating ?? 'NA'}, -width: ${width ?? 'NA'}, -height: ${height ?? 'NA'}, -latitude: ${latitude ?? 'NA'}, -longitude: ${longitude ?? 'NA'}, -city: ${city ?? 'NA'}, -state: ${state ?? 'NA'}, -country: ${country ?? ''}, -make: ${make ?? 'NA'}, -model: ${model ?? 'NA'}, -lens: ${lens ?? 'NA'}, -f: ${f ?? 'NA'}, -mm: ${mm ?? ''}, -iso: ${iso ?? 'NA'}, -exposureSeconds: ${exposureSeconds ?? 'NA'}, -}'''; - } - - ExifInfo copyWith({ - int? assetId, - int? fileSize, - String? description, - String? orientation, - String? timeZone, - DateTime? dateTimeOriginal, - int? rating, - int? width, - int? height, - double? latitude, - double? longitude, - String? city, - String? state, - String? country, - bool? isFlipped, - String? make, - String? model, - String? lens, - double? f, - double? mm, - int? iso, - double? exposureSeconds, - }) { - return ExifInfo( - assetId: assetId ?? this.assetId, - fileSize: fileSize ?? this.fileSize, - description: description ?? this.description, - orientation: orientation ?? this.orientation, - timeZone: timeZone ?? this.timeZone, - dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, - rating: rating ?? this.rating, - width: width ?? this.width, - height: height ?? this.height, - isFlipped: isFlipped ?? this.isFlipped, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - city: city ?? this.city, - state: state ?? this.state, - country: country ?? this.country, - make: make ?? this.make, - model: model ?? this.model, - lens: lens ?? this.lens, - f: f ?? this.f, - mm: mm ?? this.mm, - iso: iso ?? this.iso, - exposureSeconds: exposureSeconds ?? this.exposureSeconds, - ); - } } diff --git a/mobile/lib/domain/models/ocr.model.dart b/mobile/lib/domain/models/ocr.model.dart index 403c06d44e..bfb398bc79 100644 --- a/mobile/lib/domain/models/ocr.model.dart +++ b/mobile/lib/domain/models/ocr.model.dart @@ -1,128 +1,24 @@ -class Ocr { - final String id; - final String assetId; - final double x1; - final double y1; - final double x2; - final double y2; - final double x3; - final double y3; - final double x4; - final double y4; - final double boxScore; - final double textScore; - final String text; - final bool isVisible; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const Ocr({ - required this.id, - required this.assetId, - required this.x1, - required this.y1, - required this.x2, - required this.y2, - required this.x3, - required this.y3, - required this.x4, - required this.y4, - required this.boxScore, - required this.textScore, - required this.text, - required this.isVisible, - }); +part 'ocr.model.freezed.dart'; - Ocr copyWith({ - String? id, - String? assetId, - double? x1, - double? y1, - double? x2, - double? y2, - double? x3, - double? y3, - double? x4, - double? y4, - double? boxScore, - double? textScore, - String? text, - bool? isVisible, - }) { - return Ocr( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - x1: x1 ?? this.x1, - y1: y1 ?? this.y1, - x2: x2 ?? this.x2, - y2: y2 ?? this.y2, - x3: x3 ?? this.x3, - y3: y3 ?? this.y3, - x4: x4 ?? this.x4, - y4: y4 ?? this.y4, - boxScore: boxScore ?? this.boxScore, - textScore: textScore ?? this.textScore, - text: text ?? this.text, - isVisible: isVisible ?? this.isVisible, - ); - } - - @override - String toString() { - return '''Ocr { - id: $id, - assetId: $assetId, - x1: $x1, - y1: $y1, - x2: $x2, - y2: $y2, - x3: $x3, - y3: $y3, - x4: $x4, - y4: $y4, - boxScore: $boxScore, - textScore: $textScore, - text: $text, - isVisible: $isVisible - }'''; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is Ocr && - other.id == id && - other.assetId == assetId && - other.x1 == x1 && - other.y1 == y1 && - other.x2 == x2 && - other.y2 == y2 && - other.x3 == x3 && - other.y3 == y3 && - other.x4 == x4 && - other.y4 == y4 && - other.boxScore == boxScore && - other.textScore == textScore && - other.text == text && - other.isVisible == isVisible; - } - - @override - int get hashCode { - return id.hashCode ^ - assetId.hashCode ^ - x1.hashCode ^ - y1.hashCode ^ - x2.hashCode ^ - y2.hashCode ^ - x3.hashCode ^ - y3.hashCode ^ - x4.hashCode ^ - y4.hashCode ^ - boxScore.hashCode ^ - textScore.hashCode ^ - text.hashCode ^ - isVisible.hashCode; - } +@freezed +abstract class Ocr with _$Ocr { + const factory Ocr({ + required String id, + required String assetId, + required double x1, + required double y1, + required double x2, + required double y2, + required double x3, + required double y3, + required double x4, + required double y4, + required double boxScore, + required double textScore, + required String text, + required bool isVisible, + }) = _Ocr; } diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index c7cdcff3af..38d740eeb4 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -1,5 +1,10 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'person.model.freezed.dart'; + // TODO: Remove PersonDto once Isar is removed class PersonDto { const PersonDto({ @@ -93,102 +98,18 @@ class PersonDto { } // Model for a person stored in the server -class DriftPerson { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final String ownerId; - final String name; - final String? faceAssetId; - final bool isFavorite; - final bool isHidden; - final String? color; - final DateTime? birthDate; - - const DriftPerson({ - required this.id, - required this.createdAt, - required this.updatedAt, - required this.ownerId, - required this.name, - this.faceAssetId, - required this.isFavorite, - required this.isHidden, - required this.color, - this.birthDate, - }); - - DriftPerson copyWith({ - String? id, - DateTime? createdAt, - DateTime? updatedAt, - String? ownerId, - String? name, +@freezed +abstract class DriftPerson with _$DriftPerson { + const factory DriftPerson({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + required String ownerId, + required String name, String? faceAssetId, - bool? isFavorite, - bool? isHidden, - String? color, + required bool isFavorite, + required bool isHidden, + required String? color, DateTime? birthDate, - }) { - return DriftPerson( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - name: name ?? this.name, - faceAssetId: faceAssetId ?? this.faceAssetId, - isFavorite: isFavorite ?? this.isFavorite, - isHidden: isHidden ?? this.isHidden, - color: color ?? this.color, - birthDate: birthDate ?? this.birthDate, - ); - } - - @override - String toString() { - return '''Person { - id: $id, - createdAt: $createdAt, - updatedAt: $updatedAt, - ownerId: $ownerId, - name: $name, - faceAssetId: ${faceAssetId ?? ""}, - isFavorite: $isFavorite, - isHidden: $isHidden, - color: ${color ?? ""}, - birthDate: ${birthDate ?? ""} -}'''; - } - - @override - bool operator ==(covariant DriftPerson other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.createdAt == createdAt && - other.updatedAt == updatedAt && - other.ownerId == ownerId && - other.name == name && - other.faceAssetId == faceAssetId && - other.isFavorite == isFavorite && - other.isHidden == isHidden && - other.color == color && - other.birthDate == birthDate; - } - - @override - int get hashCode { - return id.hashCode ^ - createdAt.hashCode ^ - updatedAt.hashCode ^ - ownerId.hashCode ^ - name.hashCode ^ - faceAssetId.hashCode ^ - isFavorite.hashCode ^ - isHidden.hashCode ^ - color.hashCode ^ - birthDate.hashCode; - } + }) = _DriftPerson; } diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index f17f5788c9..4e88a02c6c 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -1,57 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'stack.model.freezed.dart'; + // Model for a stack stored in the server -class Stack { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final String ownerId; - final String primaryAssetId; - - const Stack({ - required this.id, - required this.createdAt, - required this.updatedAt, - required this.ownerId, - required this.primaryAssetId, - }); - - Stack copyWith({String? id, DateTime? createdAt, DateTime? updatedAt, String? ownerId, String? primaryAssetId}) { - return Stack( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - primaryAssetId: primaryAssetId ?? this.primaryAssetId, - ); - } - - @override - String toString() { - return '''Stack { - id: $id, - createdAt: $createdAt, - updatedAt: $updatedAt, - ownerId: $ownerId, - primaryAssetId: $primaryAssetId -}'''; - } - - @override - bool operator ==(covariant Stack other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.createdAt == createdAt && - other.updatedAt == updatedAt && - other.ownerId == ownerId && - other.primaryAssetId == primaryAssetId; - } - - @override - int get hashCode { - return id.hashCode ^ createdAt.hashCode ^ updatedAt.hashCode ^ ownerId.hashCode ^ primaryAssetId.hashCode; - } +@freezed +abstract class Stack with _$Stack { + const factory Stack({ + required String id, + required DateTime createdAt, + required DateTime updatedAt, + required String ownerId, + required String primaryAssetId, + }) = _Stack; } class StackResponse { diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart index 0e702ba868..8bce1aca13 100644 --- a/mobile/lib/domain/models/user_metadata.model.dart +++ b/mobile/lib/domain/models/user_metadata.model.dart @@ -1,5 +1,9 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +part 'user_metadata.model.freezed.dart'; + enum UserMetadataKey { // do not change this order! onboarding, @@ -224,61 +228,17 @@ licenseKey: $licenseKey, } // Model for a user metadata stored in the server -class UserMetadata { - final String userId; - final UserMetadataKey key; - final Onboarding? onboarding; - final Preferences? preferences; - final License? license; - - const UserMetadata({required this.userId, required this.key, this.onboarding, this.preferences, this.license}) - : assert( - onboarding != null || preferences != null || license != null, - 'One of onboarding, preferences and license must be provided', - ); - - UserMetadata copyWith({ - String? userId, - UserMetadataKey? key, +@freezed +abstract class UserMetadata with _$UserMetadata { + @Assert( + 'onboarding != null || preferences != null || license != null', + 'One of onboarding, preferences and license must be provided', + ) + const factory UserMetadata({ + required String userId, + required UserMetadataKey key, Onboarding? onboarding, Preferences? preferences, License? license, - }) { - return UserMetadata( - userId: userId ?? this.userId, - key: key ?? this.key, - onboarding: onboarding ?? this.onboarding, - preferences: preferences ?? this.preferences, - license: license ?? this.license, - ); - } - - @override - String toString() { - return '''UserMetadata: { -userId: $userId, -key: $key, -onboarding: ${onboarding ?? ""}, -preferences: ${preferences ?? ""}, -license: ${license ?? ""}, -}'''; - } - - @override - bool operator ==(covariant UserMetadata other) { - if (identical(this, other)) { - return true; - } - - return other.userId == userId && - other.key == key && - other.onboarding == onboarding && - other.preferences == preferences && - other.license == license; - } - - @override - int get hashCode { - return userId.hashCode ^ key.hashCode ^ onboarding.hashCode ^ preferences.hashCode ^ license.hashCode; - } + }) = _UserMetadata; } diff --git a/mobile/lib/domain/services/device_permission.service.dart b/mobile/lib/domain/services/device_permission.service.dart new file mode 100644 index 0000000000..d6ce128b8a --- /dev/null +++ b/mobile/lib/domain/services/device_permission.service.dart @@ -0,0 +1,47 @@ +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; + +typedef _Handler = Future Function(DevicePermission); + +class DevicePermissionService { + const DevicePermissionService(this._permissionRepository); + + final DevicePermissionRepository _permissionRepository; + + Future galleryStatus() => _gallery(_permissionRepository.getStatus); + + Future requestGallery() => _gallery(_permissionRepository.request); + + Future _gallery(_Handler handler) async { + if (CurrentPlatform.isIOS) { + return handler(.photos); + } + + final sdkVersion = await _permissionRepository.getAndroidSdkVersion(); + const maxExternalStorageSdk = 32; // READ/WRITE_EXTERNAL_STORAGE - Android 12.1 + final status = sdkVersion <= maxExternalStorageSdk ? await handler(.storage) : await _photosAndVideos(handler); + + const minMediaLocationSdk = 29; // ACCESS_MEDIA_LOCATION - Android 10 + if (status.hasAccess && sdkVersion >= minMediaLocationSdk) { + final mediaLocation = await handler(.mediaLocation); + return mediaLocation.hasAccess ? status : mediaLocation; + } + + return status; + } + + Future _photosAndVideos(_Handler handler) async { + final photos = await handler(.photos); + if (!photos.hasAccess) { + return photos; + } + + final videos = await handler(.videos); + if (!videos.hasAccess) { + return videos; + } + + return photos == .granted && videos == .granted ? .granted : .limited; + } +} diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index b0dbf8fbea..8a573ee209 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -66,11 +66,12 @@ class HashService { await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true); } } - } on PlatformException catch (e) { + } on PlatformException catch (e, s) { if (e.code == _kHashCancelledCode) { _log.warning("Hashing cancelled by platform"); return; } + _log.severe("Native hashing failed: ${e.code}", e, s); } catch (e, s) { _log.severe("Error during hashing", e, s); } diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index b4ebc66f89..9e272ac65f 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -27,7 +27,7 @@ class LocalSyncService { final NativeSyncApi _nativeSyncApi; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; - final IPermissionRepository _permissionRepository; + final DevicePermissionRepository _permissionRepository; final Completer? _cancellation; final Logger _log = Logger("DeviceSyncService"); diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 9b4d4bb275..0eb3ff2f99 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -36,7 +36,7 @@ class SyncStreamService { final DriftLocalAssetRepository _localAssetRepository; final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; - final IPermissionRepository _permissionRepository; + final DevicePermissionRepository _permissionRepository; final SyncMigrationRepository _syncMigrationRepository; final ApiService _api; final Completer? _cancellation; diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 82f397d9b6..db0d93c92f 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -28,6 +28,7 @@ class BackgroundSyncManager { final SyncErrorCallback? onCloudIdSyncError; Cancelable? _syncTask; + bool _syncQueued = false; Cancelable? _syncWebsocketTask; Cancelable? _cloudIdSyncTask; Cancelable? _deviceAlbumSyncTask; @@ -50,6 +51,7 @@ class BackgroundSyncManager { }); Future cancel() async { + _syncQueued = false; final tasks = [ _syncTask, _syncWebsocketTask, @@ -133,8 +135,9 @@ class BackgroundSyncManager { }); } - Future syncRemote() { + Future syncRemote({bool enqueue = false}) { if (_syncTask != null) { + _syncQueued |= enqueue; return _syncTask!.future.then((result) => result ?? false).catchError((_) => false); } @@ -148,15 +151,20 @@ class BackgroundSyncManager { .then((result) { final success = result ?? false; onRemoteSyncComplete?.call(success); + _syncQueued &= success; return success; }) .catchError((error) { onRemoteSyncError?.call(error.toString()); - _syncTask = null; + _syncQueued = false; return false; }) .whenComplete(() { _syncTask = null; + if (_syncQueued) { + _syncQueued = false; + unawaited(syncRemote()); + } }); } diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index d3f99aea64..9a589ef729 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,65 +1,21 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +part 'activity.model.freezed.dart'; + enum ActivityType { comment, like } -class Activity { - final String id; - final String? assetId; - final String? comment; - final DateTime createdAt; - final ActivityType type; - final UserDto user; - - const Activity({ - required this.id, - this.assetId, - this.comment, - required this.createdAt, - required this.type, - required this.user, - }); - - Activity copyWith({ - String? id, +@freezed +abstract class Activity with _$Activity { + const factory Activity({ + required String id, String? assetId, String? comment, - DateTime? createdAt, - ActivityType? type, - UserDto? user, - }) { - return Activity( - id: id ?? this.id, - assetId: assetId ?? this.assetId, - comment: comment ?? this.comment, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - user: user ?? this.user, - ); - } - - @override - String toString() { - return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; - } - - @override - bool operator ==(covariant Activity other) { - if (identical(this, other)) { - return true; - } - - return other.id == id && - other.assetId == assetId && - other.comment == comment && - other.createdAt == createdAt && - other.type == type && - other.user == user; - } - - @override - int get hashCode { - return id.hashCode ^ assetId.hashCode ^ comment.hashCode ^ createdAt.hashCode ^ type.hashCode ^ user.hashCode; - } + required DateTime createdAt, + required ActivityType type, + required UserDto user, + }) = _Activity; } class ActivityStats { diff --git a/mobile/lib/models/auth/auth_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart index c8a8018929..0a3bf983b2 100644 --- a/mobile/lib/models/auth/auth_state.model.dart +++ b/mobile/lib/models/auth/auth_state.model.dart @@ -1,71 +1,17 @@ -class AuthState { - final String deviceId; - final String userId; - final String userEmail; - final bool isAuthenticated; - final String name; - final bool isAdmin; - final String profileImagePath; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; - const AuthState({ - required this.deviceId, - required this.userId, - required this.userEmail, - required this.isAuthenticated, - required this.name, - required this.isAdmin, - required this.profileImagePath, - }); +part 'auth_state.model.freezed.dart'; - AuthState copyWith({ - String? deviceId, - String? userId, - String? userEmail, - bool? isAuthenticated, - String? name, - bool? isAdmin, - String? profileImagePath, - }) { - return AuthState( - deviceId: deviceId ?? this.deviceId, - userId: userId ?? this.userId, - userEmail: userEmail ?? this.userEmail, - isAuthenticated: isAuthenticated ?? this.isAuthenticated, - name: name ?? this.name, - isAdmin: isAdmin ?? this.isAdmin, - profileImagePath: profileImagePath ?? this.profileImagePath, - ); - } - - @override - String toString() { - return 'AuthenticationState(deviceId: $deviceId, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, name: $name, isAdmin: $isAdmin, profileImagePath: $profileImagePath)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is AuthState && - other.deviceId == deviceId && - other.userId == userId && - other.userEmail == userEmail && - other.isAuthenticated == isAuthenticated && - other.name == name && - other.isAdmin == isAdmin && - other.profileImagePath == profileImagePath; - } - - @override - int get hashCode { - return deviceId.hashCode ^ - userId.hashCode ^ - userEmail.hashCode ^ - isAuthenticated.hashCode ^ - name.hashCode ^ - isAdmin.hashCode ^ - profileImagePath.hashCode; - } +@freezed +abstract class AuthState with _$AuthState { + const factory AuthState({ + required String deviceId, + required String userId, + required String userEmail, + required bool isAuthenticated, + required String name, + required bool isAdmin, + required String profileImagePath, + }) = _AuthState; } diff --git a/mobile/lib/models/map/map_marker.model.dart b/mobile/lib/models/map/map_marker.model.dart index d730f9bd6d..4093747e84 100644 --- a/mobile/lib/models/map/map_marker.model.dart +++ b/mobile/lib/models/map/map_marker.model.dart @@ -1,29 +1,14 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; -class MapMarker { - final LatLng latLng; - final String assetRemoteId; - const MapMarker({required this.latLng, required this.assetRemoteId}); +part 'map_marker.model.freezed.dart'; - MapMarker copyWith({LatLng? latLng, String? assetRemoteId}) { - return MapMarker(latLng: latLng ?? this.latLng, assetRemoteId: assetRemoteId ?? this.assetRemoteId); - } +@freezed +abstract class MapMarker with _$MapMarker { + const factory MapMarker({required LatLng latLng, required String assetRemoteId}) = _MapMarker; - MapMarker.fromDto(MapMarkerResponseDto dto) : latLng = LatLng(dto.lat, dto.lon), assetRemoteId = dto.id; - - @override - String toString() => 'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)'; - - @override - bool operator ==(covariant MapMarker other) { - if (identical(this, other)) { - return true; - } - - return other.latLng == latLng && other.assetRemoteId == assetRemoteId; - } - - @override - int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode; + factory MapMarker.fromDto(MapMarkerResponseDto dto) => + MapMarker(latLng: LatLng(dto.lat, dto.lon), assetRemoteId: dto.id); } diff --git a/mobile/lib/models/map/map_state.model.dart b/mobile/lib/models/map/map_state.model.dart index a4863aa465..f7bd435073 100644 --- a/mobile/lib/models/map/map_state.model.dart +++ b/mobile/lib/models/map/map_state.model.dart @@ -1,79 +1,20 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class MapState { - final ThemeMode themeMode; - final bool showFavoriteOnly; - final bool includeArchived; - final bool withPartners; - final int relativeTime; - final bool shouldRefetchMarkers; - final AsyncValue lightStyleFetched; - final AsyncValue darkStyleFetched; +part 'map_state.model.freezed.dart'; - const MapState({ - this.themeMode = ThemeMode.system, - this.showFavoriteOnly = false, - this.includeArchived = false, - this.withPartners = false, - this.relativeTime = 0, - this.shouldRefetchMarkers = false, - this.lightStyleFetched = const AsyncLoading(), - this.darkStyleFetched = const AsyncLoading(), - }); - - MapState copyWith({ - ThemeMode? themeMode, - bool? showFavoriteOnly, - bool? includeArchived, - bool? withPartners, - int? relativeTime, - bool? shouldRefetchMarkers, - AsyncValue? lightStyleFetched, - AsyncValue? darkStyleFetched, - }) { - return MapState( - themeMode: themeMode ?? this.themeMode, - showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly, - includeArchived: includeArchived ?? this.includeArchived, - withPartners: withPartners ?? this.withPartners, - relativeTime: relativeTime ?? this.relativeTime, - shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers, - lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched, - darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched, - ); - } - - @override - String toString() { - return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, withPartners: $withPartners, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)'; - } - - @override - bool operator ==(covariant MapState other) { - if (identical(this, other)) { - return true; - } - - return other.themeMode == themeMode && - other.showFavoriteOnly == showFavoriteOnly && - other.includeArchived == includeArchived && - other.withPartners == withPartners && - other.relativeTime == relativeTime && - other.shouldRefetchMarkers == shouldRefetchMarkers && - other.lightStyleFetched == lightStyleFetched && - other.darkStyleFetched == darkStyleFetched; - } - - @override - int get hashCode { - return themeMode.hashCode ^ - showFavoriteOnly.hashCode ^ - includeArchived.hashCode ^ - withPartners.hashCode ^ - relativeTime.hashCode ^ - shouldRefetchMarkers.hashCode ^ - lightStyleFetched.hashCode ^ - darkStyleFetched.hashCode; - } +@freezed +abstract class MapState with _$MapState { + const factory MapState({ + @Default(ThemeMode.system) ThemeMode themeMode, + @Default(false) bool showFavoriteOnly, + @Default(false) bool includeArchived, + @Default(false) bool withPartners, + @Default(0) int relativeTime, + @Default(false) bool shouldRefetchMarkers, + @Default(AsyncLoading()) AsyncValue lightStyleFetched, + @Default(AsyncLoading()) AsyncValue darkStyleFetched, + }) = _MapState; } diff --git a/mobile/lib/models/server_info/server_disk_info.model.dart b/mobile/lib/models/server_info/server_disk_info.model.dart index 16e58b331d..910f06b065 100644 --- a/mobile/lib/models/server_info/server_disk_info.model.dart +++ b/mobile/lib/models/server_info/server_disk_info.model.dart @@ -1,53 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; -class ServerDiskInfo { - final String diskAvailable; - final String diskSize; - final String diskUse; - final double diskUsagePercentage; +part 'server_disk_info.model.freezed.dart'; - const ServerDiskInfo({ - required this.diskAvailable, - required this.diskSize, - required this.diskUse, - required this.diskUsagePercentage, - }); +@freezed +abstract class ServerDiskInfo with _$ServerDiskInfo { + const factory ServerDiskInfo({ + required String diskAvailable, + required String diskSize, + required String diskUse, + required double diskUsagePercentage, + }) = _ServerDiskInfo; - ServerDiskInfo copyWith({String? diskAvailable, String? diskSize, String? diskUse, double? diskUsagePercentage}) { - return ServerDiskInfo( - diskAvailable: diskAvailable ?? this.diskAvailable, - diskSize: diskSize ?? this.diskSize, - diskUse: diskUse ?? this.diskUse, - diskUsagePercentage: diskUsagePercentage ?? this.diskUsagePercentage, - ); - } - - @override - String toString() { - return 'ServerDiskInfo(diskAvailable: $diskAvailable, diskSize: $diskSize, diskUse: $diskUse, diskUsagePercentage: $diskUsagePercentage)'; - } - - ServerDiskInfo.fromDto(ServerStorageResponseDto dto) - : diskAvailable = dto.diskAvailable, - diskSize = dto.diskSize, - diskUse = dto.diskUse, - diskUsagePercentage = dto.diskUsagePercentage; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is ServerDiskInfo && - other.diskAvailable == diskAvailable && - other.diskSize == diskSize && - other.diskUse == diskUse && - other.diskUsagePercentage == diskUsagePercentage; - } - - @override - int get hashCode { - return diskAvailable.hashCode ^ diskSize.hashCode ^ diskUse.hashCode ^ diskUsagePercentage.hashCode; - } + factory ServerDiskInfo.fromDto(ServerStorageResponseDto dto) => ServerDiskInfo( + diskAvailable: dto.diskAvailable, + diskSize: dto.diskSize, + diskUse: dto.diskUse, + diskUsagePercentage: dto.diskUsagePercentage, + ); } diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index c288c1bfbf..4de7e2e3cd 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -1,74 +1,26 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; -class ServerFeatures { - final bool trash; - final bool map; - final bool oauthEnabled; - final bool passwordLogin; - final bool ocr; - final bool smartSearch; +part 'server_features.model.freezed.dart'; - const ServerFeatures({ - required this.trash, - required this.map, - required this.oauthEnabled, - required this.passwordLogin, - this.ocr = false, - this.smartSearch = false, - }); +@freezed +abstract class ServerFeatures with _$ServerFeatures { + const factory ServerFeatures({ + required bool trash, + required bool map, + required bool oauthEnabled, + required bool passwordLogin, + @Default(false) bool ocr, + @Default(false) bool smartSearch, + }) = _ServerFeatures; - ServerFeatures copyWith({ - bool? trash, - bool? map, - bool? oauthEnabled, - bool? passwordLogin, - bool? ocr, - bool? smartSearch, - }) { - return ServerFeatures( - trash: trash ?? this.trash, - map: map ?? this.map, - oauthEnabled: oauthEnabled ?? this.oauthEnabled, - passwordLogin: passwordLogin ?? this.passwordLogin, - ocr: ocr ?? this.ocr, - smartSearch: smartSearch ?? this.smartSearch, - ); - } - - @override - String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)'; - } - - ServerFeatures.fromDto(ServerFeaturesDto dto) - : trash = dto.trash, - map = dto.map, - oauthEnabled = dto.oauth, - passwordLogin = dto.passwordLogin, - ocr = dto.ocr, - smartSearch = dto.smartSearch; - - @override - bool operator ==(covariant ServerFeatures other) { - if (identical(this, other)) { - return true; - } - - return other.trash == trash && - other.map == map && - other.oauthEnabled == oauthEnabled && - other.passwordLogin == passwordLogin && - other.ocr == ocr && - other.smartSearch == smartSearch; - } - - @override - int get hashCode { - return trash.hashCode ^ - map.hashCode ^ - oauthEnabled.hashCode ^ - passwordLogin.hashCode ^ - ocr.hashCode ^ - smartSearch.hashCode; - } + factory ServerFeatures.fromDto(ServerFeaturesDto dto) => ServerFeatures( + trash: dto.trash, + map: dto.map, + oauthEnabled: dto.oauth, + passwordLogin: dto.passwordLogin, + ocr: dto.ocr, + smartSearch: dto.smartSearch, + ); } diff --git a/mobile/lib/models/server_info/server_info.model.dart b/mobile/lib/models/server_info/server_info.model.dart index 33d6393e15..adc55d3f4e 100644 --- a/mobile/lib/models/server_info/server_info.model.dart +++ b/mobile/lib/models/server_info/server_info.model.dart @@ -1,9 +1,13 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:immich_mobile/models/server_info/server_config.model.dart'; import 'package:immich_mobile/models/server_info/server_disk_info.model.dart'; import 'package:immich_mobile/models/server_info/server_features.model.dart'; import 'package:immich_mobile/models/server_info/server_version.model.dart'; +part 'server_info.model.freezed.dart'; + enum VersionStatus { upToDate, clientOutOfDate, @@ -18,68 +22,14 @@ enum VersionStatus { }; } -class ServerInfo { - final ServerVersion serverVersion; - final ServerVersion? latestVersion; - final ServerFeatures serverFeatures; - final ServerConfig serverConfig; - final ServerDiskInfo serverDiskInfo; - final VersionStatus versionStatus; - - const ServerInfo({ - required this.serverVersion, - this.latestVersion, - required this.serverFeatures, - required this.serverConfig, - required this.serverDiskInfo, - required this.versionStatus, - }); - - ServerInfo copyWith({ - ServerVersion? serverVersion, +@freezed +abstract class ServerInfo with _$ServerInfo { + const factory ServerInfo({ + required ServerVersion serverVersion, ServerVersion? latestVersion, - ServerFeatures? serverFeatures, - ServerConfig? serverConfig, - ServerDiskInfo? serverDiskInfo, - VersionStatus? versionStatus, - }) { - return ServerInfo( - serverVersion: serverVersion ?? this.serverVersion, - latestVersion: latestVersion ?? this.latestVersion, - serverFeatures: serverFeatures ?? this.serverFeatures, - serverConfig: serverConfig ?? this.serverConfig, - serverDiskInfo: serverDiskInfo ?? this.serverDiskInfo, - versionStatus: versionStatus ?? this.versionStatus, - ); - } - - @override - String toString() { - return 'ServerInfo(serverVersion: $serverVersion, latestVersion: $latestVersion, serverFeatures: $serverFeatures, serverConfig: $serverConfig, serverDiskInfo: $serverDiskInfo, versionStatus: $versionStatus)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is ServerInfo && - other.serverVersion == serverVersion && - other.latestVersion == latestVersion && - other.serverFeatures == serverFeatures && - other.serverConfig == serverConfig && - other.serverDiskInfo == serverDiskInfo && - other.versionStatus == versionStatus; - } - - @override - int get hashCode { - return serverVersion.hashCode ^ - latestVersion.hashCode ^ - serverFeatures.hashCode ^ - serverConfig.hashCode ^ - serverDiskInfo.hashCode ^ - versionStatus.hashCode; - } + required ServerFeatures serverFeatures, + required ServerConfig serverConfig, + required ServerDiskInfo serverDiskInfo, + required VersionStatus versionStatus, + }) = _ServerInfo; } diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index e7b65a96ef..cf96609d3c 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -1,119 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:openapi/api.dart'; +part 'shared_link.model.freezed.dart'; + enum SharedLinkSource { album, individual } -class SharedLink { - final String id; - final String title; - final bool allowDownload; - final bool allowUpload; - final String? thumbAssetId; - final String? description; - final String? password; - final DateTime? expiresAt; - final String key; - final bool showMetadata; - final SharedLinkSource type; - final String? slug; +@freezed +abstract class SharedLink with _$SharedLink { + const factory SharedLink({ + required String id, + required String title, + required bool allowDownload, + required bool allowUpload, + required String? thumbAssetId, + required String? description, + required String? password, + required DateTime? expiresAt, + required String key, + required bool showMetadata, + required SharedLinkSource type, + required String? slug, + }) = _SharedLink; - const SharedLink({ - required this.id, - required this.title, - required this.allowDownload, - required this.allowUpload, - required this.thumbAssetId, - required this.description, - required this.password, - required this.expiresAt, - required this.key, - required this.showMetadata, - required this.type, - required this.slug, - }); - - SharedLink copyWith({ - String? id, - String? title, - String? thumbAssetId, - bool? allowDownload, - bool? allowUpload, - String? description, - String? password, - DateTime? expiresAt, - String? key, - bool? showMetadata, - SharedLinkSource? type, - String? slug, - }) { + factory SharedLink.fromDto(SharedLinkResponseDto dto) { + final isAlbum = dto.type == SharedLinkType.ALBUM; return SharedLink( - id: id ?? this.id, - title: title ?? this.title, - thumbAssetId: thumbAssetId ?? this.thumbAssetId, - allowDownload: allowDownload ?? this.allowDownload, - allowUpload: allowUpload ?? this.allowUpload, - description: description ?? this.description, - password: password ?? this.password, - expiresAt: expiresAt ?? this.expiresAt, - key: key ?? this.key, - showMetadata: showMetadata ?? this.showMetadata, - type: type ?? this.type, - slug: slug ?? this.slug, - ); - } - - SharedLink.fromDto(SharedLinkResponseDto dto) - : id = dto.id, - allowDownload = dto.allowDownload, - allowUpload = dto.allowUpload, - description = dto.description, - password = dto.password, - expiresAt = dto.expiresAt, - key = dto.key, - showMetadata = dto.showMetadata, - slug = dto.slug, - type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, - title = dto.type == SharedLinkType.ALBUM - ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" - : "INDIVIDUAL SHARE", - thumbAssetId = dto.type == SharedLinkType.ALBUM + id: dto.id, + allowDownload: dto.allowDownload, + allowUpload: dto.allowUpload, + description: dto.description, + password: dto.password, + expiresAt: dto.expiresAt, + key: dto.key, + showMetadata: dto.showMetadata, + slug: dto.slug, + type: isAlbum ? SharedLinkSource.album : SharedLinkSource.individual, + title: isAlbum ? dto.album.orElse(null)?.albumName.toUpperCase() ?? "UNKNOWN SHARE" : "INDIVIDUAL SHARE", + thumbAssetId: isAlbum ? dto.album.orElse(null)?.albumThumbnailAssetId : dto.assets.isNotEmpty ? dto.assets[0].id - : null; - - @override - String toString() => - 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)'; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SharedLink && - other.id == id && - other.title == title && - other.thumbAssetId == thumbAssetId && - other.allowDownload == allowDownload && - other.allowUpload == allowUpload && - other.description == description && - other.password == password && - other.expiresAt == expiresAt && - other.key == key && - other.showMetadata == showMetadata && - other.type == type && - other.slug == slug; - - @override - int get hashCode => - id.hashCode ^ - title.hashCode ^ - thumbAssetId.hashCode ^ - allowDownload.hashCode ^ - allowUpload.hashCode ^ - description.hashCode ^ - password.hashCode ^ - expiresAt.hashCode ^ - key.hashCode ^ - showMetadata.hashCode ^ - type.hashCode ^ - slug.hashCode; + : null, + ); + } } diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index cc618dbe60..e2472f1063 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -81,8 +81,8 @@ class _DriftBackupPageState extends ConsumerState { final error = ref.watch(driftBackupProvider.select((p) => p.error)); - final backupNotifier = ref.read(driftBackupProvider.notifier); - final backupSyncManager = ref.read(backgroundSyncProvider); + final backupNotifier = ref.watch(driftBackupProvider.notifier); + final backupSyncManager = ref.watch(backgroundSyncProvider); Future startBackup() async { final currentUser = Store.tryGet(StoreKey.currentUser); @@ -143,7 +143,7 @@ class _DriftBackupPageState extends ConsumerState { onStart: () async => await startBackup(), onStop: () { syncSuccess = null; - backupNotifier.stopForegroundBackup(); + backupNotifier.stopForegroundBackup(reason: "backup button toggled off"); }, ), switch (error) { @@ -321,7 +321,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { Widget buildSelectedAlbumName() { String text = "backup_controller_page_backup_selected".tr(); final albums = ref - .watch(backupAlbumProvider) + .read(backupAlbumProvider) .where((album) => album.backupSelection == BackupSelection.selected) .toList(); @@ -355,7 +355,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { Widget buildExcludedAlbumName() { String text = "backup_controller_page_excluded".tr(); final albums = ref - .watch(backupAlbumProvider) + .read(backupAlbumProvider) .where((album) => album.backupSelection == BackupSelection.excluded) .toList(); @@ -403,6 +403,10 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { trailing: ElevatedButton( onPressed: () async { await context.pushRoute(const DriftBackupAlbumSelectionRoute()); + if (!context.mounted) { + return; + } + final currentUser = ref.read(currentUserProvider); if (currentUser == null) { return; @@ -562,6 +566,11 @@ class _PreparingStatusState extends ConsumerState { final currentUser = ref.read(currentUserProvider); if (currentUser != null && mounted) { await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id); + if (!context.mounted) { + timer.cancel(); + _pollingTimer = null; + return; + } // Stop polling if processing count reaches 0 final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount)); diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 7667dbc3f1..de0ad52b5a 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -102,6 +102,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount)); final totalChanged = currentTotalAssetCount != _initialTotalAssetCount; final backupNotifier = ref.read(driftBackupProvider.notifier); @@ -119,7 +126,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { - backupNotifier.stopForegroundBackup(); + backupNotifier.stopForegroundBackup(reason: "backup albums updated"); unawaited( backgroundSync.syncRemote().then((success) { if (success) { diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index ef4974d4dd..ccd486e04d 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { bool hasPopped = false; - final previousBackup = ref.read(appConfigProvider).backup; + final previousBackup = ref.watch(appConfigProvider.select((s) => s.backup)); final previousCellularForVideos = previousBackup.useCellularForVideos; final previousCellularForPhotos = previousBackup.useCellularForPhotos; return PopScope( @@ -59,7 +59,7 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); - backupNotifier.stopForegroundBackup(); + backupNotifier.stopForegroundBackup(reason: "backup settings updated"); unawaited( backgroundSync.syncRemote().then((success) { if (success) { diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index b04d8dc926..370d6a106d 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -13,11 +12,11 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; @RoutePage() -class AppLogPage extends HookConsumerWidget { +class AppLogPage extends HookWidget { const AppLogPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final immichLogger = LogService.I; final shouldReload = useState(false); final logMessages = useFuture(useMemoized(() => immichLogger.getMessages(), [shouldReload.value])); diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart index 52f792df9d..bc3d26a019 100644 --- a/mobile/lib/pages/common/app_log_detail.page.dart +++ b/mobile/lib/pages/common/app_log_detail.page.dart @@ -4,18 +4,18 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @RoutePage() -class AppLogDetailPage extends HookConsumerWidget { +class AppLogDetailPage extends HookWidget { const AppLogDetailPage({super.key, required this.logMessage}); final LogMessage logMessage; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { Padding buildTextWithCopyButton(String header, String text) { return Padding( padding: const EdgeInsets.all(8.0), diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart index 267d32fc3d..2015948fad 100644 --- a/mobile/lib/pages/common/download_panel.dart +++ b/mobile/lib/pages/common/download_panel.dart @@ -17,7 +17,7 @@ class DownloadPanel extends ConsumerWidget { final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList(); void onCancelDownload(String id) { - unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id)); + unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id)); } return Positioned( diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index f0b3f4b67f..e0228eecac 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -21,7 +21,7 @@ class HeaderSettingsPage extends HookConsumerWidget { final headers = useState>([]); final setInitialHeaders = useState(false); - final storedHeaders = ref.read(appConfigProvider).network.customHeaders; + final storedHeaders = ref.watch(appConfigProvider.select((s) => s.network.customHeaders)); if (!setInitialHeaders.value) { storedHeaders.forEach((k, v) { final header = SettingsHeader(); @@ -93,8 +93,9 @@ class HeaderSettingsPage extends HookConsumerWidget { headersMap[key] = value; } + final apiService = ref.read(apiServiceProvider); await ref.read(settingsProvider).write(.networkCustomHeaders, headersMap); - await ref.read(apiServiceProvider).updateHeaders(); + await apiService.updateHeaders(); } } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 2c39883b7e..65a78aed8a 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -353,11 +353,11 @@ class SplashScreenPageState extends ConsumerState { }, onError: (exception) { log.severe('Failed to update auth info with access token: $accessToken'); - unawaited(ref.read(authProvider.notifier).logout()); if (!mounted) { return; } + unawaited(ref.read(authProvider.notifier).logout()); unawaited(context.router.replaceAll([const LoginRoute()])); }, ), diff --git a/mobile/lib/pages/login/change_password.page.dart b/mobile/lib/pages/login/change_password.page.dart index 248526df1b..051f7f408c 100644 --- a/mobile/lib/pages/login/change_password.page.dart +++ b/mobile/lib/pages/login/change_password.page.dart @@ -1,14 +1,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/widgets/forms/change_password_form.dart'; @RoutePage() -class ChangePasswordPage extends HookConsumerWidget { +class ChangePasswordPage extends StatelessWidget { const ChangePasswordPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return const Scaffold(body: ChangePasswordForm()); - } + Widget build(BuildContext context) => const Scaffold(body: ChangePasswordForm()); } diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index e225c4c066..228fa1cc30 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -11,11 +10,11 @@ import 'package:immich_mobile/widgets/forms/login/login_form.dart'; import 'package:package_info_plus/package_info_plus.dart'; @RoutePage() -class LoginPage extends HookConsumerWidget { +class LoginPage extends HookWidget { const LoginPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final appVersion = useState('0.0.0'); Future getAppInfo() async { diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index bb848b24bc..e8a507bfdc 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -5,7 +5,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; @@ -14,13 +13,13 @@ import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class MapLocationPickerPage extends HookConsumerWidget { +class MapLocationPickerPage extends HookWidget { final LatLng initialLatLng; const MapLocationPickerPage({super.key, this.initialLatLng = const LatLng(0, 0)}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final selectedLatLng = useValueNotifier(initialLatLng); final controller = useRef(null); final marker = useRef(null); diff --git a/mobile/lib/presentation/pages/download_info.page.dart b/mobile/lib/presentation/pages/download_info.page.dart index c2c63c7860..8073d3098c 100644 --- a/mobile/lib/presentation/pages/download_info.page.dart +++ b/mobile/lib/presentation/pages/download_info.page.dart @@ -17,7 +17,7 @@ class DownloadInfoPage extends ConsumerWidget { final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList(); void onCancelDownload(String id) { - unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id)); + unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id)); } return Scaffold( diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart index ebf7c2efa7..2bb3507e6e 100644 --- a/mobile/lib/presentation/pages/drift_activities.page.dart +++ b/mobile/lib/presentation/pages/drift_activities.page.dart @@ -23,7 +23,7 @@ class DriftActivitiesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final activityNotifier = ref.read(albumActivityProvider((album.id, assetId)).notifier); + final activityNotifier = ref.watch(albumActivityProvider((album.id, assetId)).notifier); final activities = ref.watch(albumActivityProvider((album.id, assetId))); final listViewScrollController = useScrollController(); diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 9c5161fa8a..7b4c48a6b0 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -75,6 +75,10 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { } try { + if (!context.mounted) { + return; + } + await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers); ref.invalidate(remoteAlbumSharedUsersProvider(album.id)); if (!context.mounted) { @@ -137,7 +141,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { Widget buildOwnerInfo() { if (isOwner) { - final owner = ref.watch(currentUserProvider); + final owner = ref.read(currentUserProvider); return ListTile( leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), @@ -145,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), ); } else { - final usersProvider = ref.watch(driftUsersProvider); + final usersProvider = ref.read(driftUsersProvider); return usersProvider.maybeWhen( data: (users) { final user = users.firstWhereOrNull((u) => u.id == album.ownerId); diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart index a12ca4932b..d256cc2220 100644 --- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart @@ -7,12 +7,12 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @RoutePage() -class DriftAssetSelectionTimelinePage extends ConsumerWidget { +class DriftAssetSelectionTimelinePage extends StatelessWidget { final Set lockedSelectionAssets; const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ProviderScope( overrides: [ multiSelectProvider.overrideWith( diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart index d50e4b190e..7e98d25cf0 100644 --- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart @@ -10,13 +10,13 @@ import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; @RoutePage() -class AssetTroubleshootPage extends ConsumerWidget { +class AssetTroubleshootPage extends StatelessWidget { final BaseAsset asset; const AssetTroubleshootPage({super.key, required this.asset}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('asset_troubleshoot'.tr())), body: SingleChildScrollView( @@ -29,13 +29,13 @@ class AssetTroubleshootPage extends ConsumerWidget { } } -class _AssetDetailsView extends ConsumerWidget { +class _AssetDetailsView extends StatelessWidget { final BaseAsset asset; const _AssetDetailsView({required this.asset}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index b2b4d250f1..740de441e7 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -21,11 +21,11 @@ import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class DriftLibraryPage extends ConsumerWidget { +class DriftLibraryPage extends StatelessWidget { const DriftLibraryPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return const Scaffold( body: CustomScrollView( slivers: [ diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 5d018561cc..f9e59da0a3 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -76,6 +76,10 @@ class _RemoteAlbumPageState extends ConsumerState { } try { + if (!context.mounted) { + return; + } + await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers); ref.invalidate(remoteAlbumSharedUsersProvider(_album.id)); if (!context.mounted) { @@ -133,6 +137,10 @@ class _RemoteAlbumPageState extends ConsumerState { if (confirmed == true) { try { + if (!context.mounted) { + return; + } + await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id); if (!context.mounted) { return; @@ -431,7 +439,7 @@ class _AlbumKebabMenu extends ConsumerWidget { return FutureBuilder( future: ref - .read(remoteAlbumServiceProvider) + .watch(remoteAlbumServiceProvider) .getUserRole(album.id, user?.id ?? '') .then((role) => role == AlbumUserRole.editor), builder: (context, snapshot) { diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 81c09bea67..48b29b3499 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -93,8 +93,8 @@ class _DriftSlideshowPageState extends ConsumerState with Si if (asset.isImage) { _createTimer(); - } else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) { - unawaited(ref.read(videoPlayerProvider(asset.heroTag).notifier).play()); + } else if (ref.read(videoPlayerProvider(asset.id)).status == VideoPlaybackStatus.paused) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).play()); } else { unawaited(_nextPage()); } @@ -113,7 +113,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si final asset = widget.timeline.getAssetSafe(_index)!; if (!asset.isImage) { - unawaited(ref.read(videoPlayerProvider(asset.heroTag).notifier).pause()); + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).pause()); } setState(() { @@ -300,7 +300,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si borderRadius: BorderRadius.zero, minHeight: 5, value: - ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds / + ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds / asset.duration.inMilliseconds, ); } @@ -374,13 +374,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { - final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); - final position = ref.read(videoPlayerProvider(asset.heroTag)).position; + final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status)); + final position = ref.read(videoPlayerProvider(asset.id)).position; if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { unawaited(_nextPage()); } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false)); + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); } return PhotoView.customChild( diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart index 19a450b435..a145b96a70 100644 --- a/mobile/lib/presentation/pages/drift_user_selection.page.dart +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -137,7 +137,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { body: suggestedShareUsers.widgetWhen( onData: (users) { // Get shared users for this album from the database - final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); + final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id)); return sharedUsers.when( data: (albumSharedUsers) { diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart index ec7d8ac3d7..0fe5d28850 100644 --- a/mobile/lib/presentation/pages/edit/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart @@ -220,7 +220,7 @@ class _AspectRatioSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final editorState = ref.watch(editorStateProvider); - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); return SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -245,7 +245,7 @@ class _TransformControls extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); return Column( mainAxisSize: MainAxisSize.min, @@ -333,7 +333,7 @@ class _ResetEditsButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final editorState = ref.watch(editorStateProvider); - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); return ImmichTextButton( labelText: 'reset'.tr(), @@ -384,7 +384,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi @override Widget build(BuildContext context) { final editorState = ref.watch(editorStateProvider); - final editorNotifier = ref.read(editorStateProvider.notifier); + final editorNotifier = ref.watch(editorStateProvider.notifier); ref.listen(editorStateProvider, (previous, current) { // Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart index 3d97f2173f..39cf4cc3ff 100644 --- a/mobile/lib/presentation/pages/edit/editor.provider.dart +++ b/mobile/lib/presentation/pages/edit/editor.provider.dart @@ -1,10 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/aspect_ratios.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/utils/editor.utils.dart'; +part 'editor.provider.freezed.dart'; + final editorStateProvider = NotifierProvider(EditorProvider.new); class EditorProvider extends Notifier { @@ -32,8 +36,8 @@ class EditorProvider extends Notifier { final transform = normalizeTransformEdits(edits); state = state.copyWith( - originalWidth: originalWidth, - originalHeight: originalHeight, + originalWidth: originalWidth ?? state.originalWidth, + originalHeight: originalHeight ?? state.originalHeight, crop: crop, flipHorizontal: transform.mirrorHorizontal, flipVertical: transform.mirrorVertical, @@ -106,104 +110,24 @@ class EditorProvider extends Notifier { } } -class EditorState { - final bool isApplyingEdits; +@freezed +abstract class EditorState with _$EditorState { + const EditorState._(); - final int rotationAngle; - final bool flipHorizontal; - final bool flipVertical; - final Rect crop; - final CropAspectRatio aspectRatio; - - final int originalWidth; - final int originalHeight; - - final Duration animationDuration; - - final bool hasUnsavedEdits; - - const EditorState({ - bool? isApplyingEdits, - int? rotationAngle, - bool? flipHorizontal, - bool? flipVertical, - Rect? crop, - CropAspectRatio? aspectRatio, - int? originalWidth, - int? originalHeight, - Duration? animationDuration, - bool? hasUnsavedEdits, - }) : isApplyingEdits = isApplyingEdits ?? false, - rotationAngle = rotationAngle ?? 0, - flipHorizontal = flipHorizontal ?? false, - flipVertical = flipVertical ?? false, - animationDuration = animationDuration ?? Duration.zero, - originalWidth = originalWidth ?? 0, - originalHeight = originalHeight ?? 0, - crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1), - aspectRatio = aspectRatio ?? CropAspectRatio.free, - hasUnsavedEdits = hasUnsavedEdits ?? false; - - EditorState copyWith({ - bool? isApplyingEdits, - int? rotationAngle, - bool? flipHorizontal, - bool? flipVertical, - CropAspectRatio? aspectRatio, - int? originalWidth, - int? originalHeight, - Duration? animationDuration, - Rect? crop, - bool? hasUnsavedEdits, - }) { - return EditorState( - isApplyingEdits: isApplyingEdits ?? this.isApplyingEdits, - rotationAngle: rotationAngle ?? this.rotationAngle, - flipHorizontal: flipHorizontal ?? this.flipHorizontal, - flipVertical: flipVertical ?? this.flipVertical, - aspectRatio: aspectRatio ?? this.aspectRatio, - animationDuration: animationDuration ?? this.animationDuration, - originalWidth: originalWidth ?? this.originalWidth, - originalHeight: originalHeight ?? this.originalHeight, - crop: crop ?? this.crop, - hasUnsavedEdits: hasUnsavedEdits ?? this.hasUnsavedEdits, - ); - } + const factory EditorState({ + @Default(false) bool isApplyingEdits, + @Default(0) int rotationAngle, + @Default(false) bool flipHorizontal, + @Default(false) bool flipVertical, + @Default(Rect.fromLTRB(0, 0, 1, 1)) Rect crop, + @Default(CropAspectRatio.free) CropAspectRatio aspectRatio, + @Default(0) int originalWidth, + @Default(0) int originalHeight, + @Default(Duration.zero) Duration animationDuration, + @Default(false) bool hasUnsavedEdits, + }) = _EditorState; bool get hasEdits { return rotationAngle != 0 || flipHorizontal || flipVertical || crop != const Rect.fromLTRB(0, 0, 1, 1); } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is EditorState && - other.isApplyingEdits == isApplyingEdits && - other.rotationAngle == rotationAngle && - other.flipHorizontal == flipHorizontal && - other.flipVertical == flipVertical && - other.crop == crop && - other.aspectRatio == aspectRatio && - other.originalWidth == originalWidth && - other.originalHeight == originalHeight && - other.animationDuration == animationDuration && - other.hasUnsavedEdits == hasUnsavedEdits; - } - - @override - int get hashCode { - return isApplyingEdits.hashCode ^ - rotationAngle.hashCode ^ - flipHorizontal.hashCode ^ - flipVertical.hashCode ^ - crop.hashCode ^ - aspectRatio.hashCode ^ - originalWidth.hashCode ^ - originalHeight.hashCode ^ - animationDuration.hashCode ^ - hasUnsavedEdits.hashCode; - } } diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart index a987c9ca29..523781b274 100644 --- a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart +++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart @@ -70,6 +70,10 @@ class _ProfilePictureCropPageState extends ConsumerState final croppedImage = await _cropController.croppedImage(); final pngBytes = await imageToUint8List(croppedImage); final xFile = XFile.fromData(pngBytes, mimeType: 'image/png'); + if (!context.mounted) { + return; + } + final success = await ref .read(uploadProfileImageProvider.notifier) .upload(xFile, fileName: 'profile-picture.png'); diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 39588cf051..12935b4f23 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -749,7 +749,7 @@ class _SearchResultGrid extends ConsumerWidget { } Widget? _bottomWidget(BuildContext context, WidgetRef ref) { - final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading)); if (isLoading) { return const SliverFillRemaining( @@ -761,7 +761,7 @@ class _SearchResultGrid extends ConsumerWidget { ); } - final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); + final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null)); if (hasMore) { return null; diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index ee2dace729..bb25fc4739 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class BaseActionButton extends ConsumerWidget { +class BaseActionButton extends StatelessWidget { const BaseActionButton({ super.key, required this.label, @@ -31,7 +30,7 @@ class BaseActionButton extends ConsumerWidget { final void Function()? onLongPressed; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; diff --git a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart index 3bd67978e2..878c589b21 100644 --- a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart @@ -17,7 +17,7 @@ class MotionPhotoActionButton extends ConsumerWidget { return BaseActionButton( iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, label: "play_motion_photo".t(context: context), - onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle, iconOnly: iconOnly, menuItem: menuItem, ); diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index f30796e406..9babdb5b57 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -669,7 +669,7 @@ class _GridAlbumCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); + final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); return GestureDetector( onTap: () => onAlbumSelected(album), @@ -746,7 +746,7 @@ class AddToAlbumHeader extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { Future onCreateAlbum() async { final albumName = await showDialog(context: context, builder: (context) => const NewAlbumNameModal()); - if (albumName == null) { + if (albumName == null || !context.mounted) { return; } diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart index bbf7e11e5a..598666cda9 100644 --- a/mobile/lib/presentation/widgets/album/album_tile.dart +++ b/mobile/lib/presentation/widgets/album/album_tile.dart @@ -17,7 +17,7 @@ class AlbumTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); + final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? ""); return LargeLeadingTile( title: Text( diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart index e97fa4889b..e92b671562 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart @@ -72,7 +72,7 @@ class TechnicalDetails extends ConsumerWidget { final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary); if (asset is LocalAsset) { - final assetMediaRepository = ref.watch(assetMediaRepositoryProvider); + final assetMediaRepository = ref.read(assetMediaRepositoryProvider); return FutureBuilder( future: assetMediaRepository.getOriginalFilename(asset.id), builder: (context, snapshot) { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 7233356402..c887f8e65a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -401,7 +401,7 @@ class _AssetPageState extends ConsumerState { _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); - final timelineOrigin = ref.read(timelineServiceProvider).origin; + final timelineOrigin = ref.watch(timelineServiceProvider).origin; final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr)); final asset = _asset; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart index 23c473e618..77832c2685 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -17,11 +17,16 @@ class AssetStackRow extends ConsumerWidget { return const SizedBox.shrink(); } - final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final hideAssetStack = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash; if (hideAssetStack) { return const SizedBox.shrink(); } + final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); + if (showingDetails) { + return const SizedBox.shrink(); + } + final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); final double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0); diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 3ed2b3a576..15b90e58ea 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -22,7 +22,9 @@ import 'package:immich_ui/immich_ui.dart'; List _actionColumnButtons(BuildContext context, WidgetRef ref, List actions) => actions .map((a) => a.create(context, ref)) .nonNulls - .map((item) => ImmichColumnButton(icon: item.icon, label: item.label, onPressed: item.onAction)) + .map( + (item) => .new(icon: item.icon, label: item.label, onPressed: item.onAction, onLongPress: item.onSecondaryAction), + ) .toList(growable: false); class ViewerBottomBar extends ConsumerWidget { @@ -38,7 +40,7 @@ class ViewerBottomBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final isInLockedView = ref.watch(inLockedViewProvider); - final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash; + final isInTrash = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash; final originalTheme = context.themeData; @@ -51,7 +53,7 @@ class ViewerBottomBar extends ConsumerWidget { UploadAction(source: .viewer, showProgress: true), EditAssetAction(source: .viewer), ]), - if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), + if (asset.hasRemote) ImmichColorOverride(color: null, child: AddActionButton(originalTheme: originalTheme)), ], ..._actionColumnButtons(context, ref, const [DeleteAction(source: .viewer)]), ], @@ -92,9 +94,12 @@ class ViewerBottomBar extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (asset.isImage) OcrToggleButton(asset: asset), - if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), + if (asset.isVideo) VideoControls(videoPlayerName: asset.id), if (!isReadonlyModeEnabled) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ImmichColorOverride( + color: Colors.white, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), + ), ], ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart index 800af23039..08d6b59423 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart @@ -30,7 +30,7 @@ class MotionPhotoPlayButton extends ConsumerWidget { child: Center( child: _MotionButton( isPlaying: isPlaying, - onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle, ), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart index 73c7b2d3e7..cdff727967 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart @@ -28,7 +28,7 @@ class OcrToggleButton extends ConsumerWidget { shape: const CircleBorder(), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: ref.read(assetViewerProvider.notifier).toggleOcr, + onTap: ref.watch(assetViewerProvider.notifier).toggleOcr, child: const Padding( padding: EdgeInsets.all(10.0), child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white), diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 63b5663d9d..9011888b54 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -47,7 +47,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg bool _isVideoReady = false; bool _shouldPlayOnForeground = true; - VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier); + VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.id).notifier); @override void initState() { @@ -150,6 +150,10 @@ class _NativeVideoViewerState extends ConsumerState with Widg final remoteAsset = videoAsset as RemoteAsset; final serverEndpoint = Store.get(StoreKey.serverEndpoint); + if (!context.mounted) { + return null; + } + final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id; @@ -300,7 +304,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg @override Widget build(BuildContext context) { final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status)); + final status = ref.watch(videoPlayerProvider(widget.asset.id).select((v) => v.status)); return IgnorePointer( child: Stack( diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index da0abad1dd..cbbc851108 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -29,7 +29,7 @@ class ViewerKebabMenu extends ConsumerWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); - final timelineOrigin = ref.read(timelineServiceProvider).origin; + final timelineOrigin = ref.watch(timelineServiceProvider).origin; final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); final isInLockedView = ref.watch(inLockedViewProvider); final currentAlbum = ref.watch(currentRemoteAlbumProvider); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 3e5edaa149..837397254c 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -73,6 +74,7 @@ class _ArchiveBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: const [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: ShareLinkAction(source: .timeline)), .new(action: ArchiveAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 7540da4fff..3322035320 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -75,6 +76,7 @@ class FavoriteBottomSheet extends ConsumerWidget { maxChildSize: 0.7, shouldCloseOnMinExtent: false, actions: const [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: ShareLinkAction(source: .timeline)), .new(action: FavoriteAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index 478b37212e..94e708015c 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/actions/upload.action.dart'; @@ -66,6 +67,7 @@ class _LocalAlbumBottomSheetState extends ConsumerState { maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: const [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: DeleteAction(source: .timeline)), .new(action: CleanupLocalAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index d7334337ae..32730b8ac4 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -1,22 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/lock.action.dart'; import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; -class LockedFolderBottomSheet extends ConsumerWidget { +class LockedFolderBottomSheet extends StatelessWidget { const LockedFolderBottomSheet({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return const BaseBottomSheet( initialChildSize: 0.25, maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: DownloadAction(source: .timeline)), .new(action: DeleteAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 8a20f26c73..c764669cf6 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/share.action.dart'; import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; @@ -65,6 +66,7 @@ class _PartnerDetailBottomSheetState extends ConsumerState[ + .new(action: AssetDebugAction(source: .timeline)), .new(action: ShareAction(source: .timeline)), .new(action: DownloadAction(source: .timeline)), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 1e51a26811..cdae658b45 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/presentation/actions/archive.action.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/download.action.dart'; import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart'; @@ -84,6 +85,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState maxChildSize: 0.85, shouldCloseOnMinExtent: false, actions: [ + const .new(action: AssetDebugAction(source: .timeline)), const .new(action: ShareAction(source: .timeline)), const .new(action: ShareLinkAction(source: .timeline)), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart index 1d378f1350..32e5b01a99 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart @@ -1,15 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; +import 'package:immich_mobile/presentation/actions/asset_debug.action.dart'; import 'package:immich_mobile/presentation/actions/delete.action.dart'; import 'package:immich_mobile/presentation/actions/restore.action.dart'; -class TrashBottomBar extends ConsumerWidget { +class TrashBottomBar extends StatelessWidget { const TrashBottomBar({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Align( alignment: Alignment.bottomCenter, child: Container( @@ -20,6 +20,7 @@ class TrashBottomBar extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + .new(action: AssetDebugAction(source: .timeline)), .new(action: DeleteAction(source: .timeline)), .new(action: RestoreAction(source: .timeline)), ], diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 927734ca25..aaf3e0dbaf 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -162,7 +162,15 @@ ImageProvider getFullImageProvider( provider = FileImage(File(localFilePath)); } else if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage); + provider = LocalFullImageProvider( + id: id, + size: size, + assetType: asset.type, + isAnimated: asset.isAnimatedImage, + width: asset.width, + height: asset.height, + checksum: asset.checksum, + ); } else { final String assetId; final String thumbhash; @@ -190,7 +198,7 @@ ImageProvider getFullImageProvider( ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) { if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - return LocalThumbProvider(id: id, size: size, assetType: asset.type); + return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: asset.checksum); } final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index eba4f0a1cd..46e8eda85b 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -8,13 +10,19 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +// iOS GPU textures max out at 16384px; larger images squish. +const _kMaxPixelSize = 16384; + class LocalThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { final String id; final Size size; final AssetType assetType; - LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution}); + // an edit on the device keeps the id and changes the bytes, so the checksum is what separates two renders + final String? checksum; + + LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution}); @override Future obtainKey(ImageConfiguration configuration) { @@ -44,13 +52,13 @@ class LocalThumbProvider extends CancellableImageProvider return true; } if (other is LocalThumbProvider) { - return id == other.id; + return id == other.id && checksum == other.checksum; } return false; } @override - int get hashCode => id.hashCode; + int get hashCode => Object.hash(id, checksum); } class LocalFullImageProvider extends CancellableImageProvider @@ -59,8 +67,38 @@ class LocalFullImageProvider extends CancellableImageProvider + previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal); + + // Use an aspect-correct target when aspectFill would exceed the texture limit. + @visibleForTesting + static Size previewTargetSize(double boxW, double boxH, int? width, int? height, {required bool previewIsFinal}) { + if (width == null || height == null || width <= 0 || height <= 0) { + return Size(boxW, boxH); + } + final imgLong = math.max(width, height).toDouble(); + final coverLong = imgLong * math.max(boxW / width, boxH / height); + if (coverLong <= _kMaxPixelSize) { + return Size(boxW, boxH); + } + final bound = previewIsFinal ? _kMaxPixelSize.toDouble() : math.max(boxW, boxH); + final scale = math.min(1.0, bound / imgLong); + return Size(math.max(1.0, width * scale), math.max(1.0, height * scale)); + } @override Future obtainKey(ImageConfiguration configuration) { @@ -73,7 +111,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), @@ -86,7 +124,7 @@ class LocalFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Id', key.id), @@ -108,7 +146,7 @@ class LocalFullImageProvider extends CancellableImageProvider id.hashCode ^ size.hashCode ^ isAnimated.hashCode; + int get hashCode => Object.hash(id, size, isAnimated, width, height, checksum); } diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index b0b7816889..94a91437b0 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -45,13 +45,13 @@ class DriftMemoryLane extends ConsumerWidget { } } -class DriftMemoryCard extends ConsumerWidget { +class DriftMemoryCard extends StatelessWidget { const DriftMemoryCard({super.key, required this.memory}); final DriftMemory memory; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final yearsAgo = DateTime.now().year - memory.data.year; final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()}); return Center( diff --git a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart index b0ded02624..ac2d4580c9 100644 --- a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -class PersonOptionSheet extends ConsumerWidget { +class PersonOptionSheet extends StatelessWidget { const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false}); final VoidCallback? onEditName; @@ -10,7 +9,7 @@ class PersonOptionSheet extends ConsumerWidget { final bool birthdayExists; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600); return SafeArea( diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart index 355e1a01a8..cd94ef89a8 100644 --- a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart +++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; -class DriftRemoteAlbumOption extends ConsumerWidget { +class DriftRemoteAlbumOption extends StatelessWidget { const DriftRemoteAlbumOption({ super.key, this.onAddPhotos, @@ -31,7 +30,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget { final List? iconShadows; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final theme = context.themeData; final menuChildren = []; diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 16c947ca5e..b108acde3b 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -107,7 +107,7 @@ class _FixedSegmentRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); - final timelineService = ref.read(timelineServiceProvider); + final timelineService = ref.watch(timelineServiceProvider); final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); if (timelineService.hasRange(assetIndex, assetCount)) { @@ -261,7 +261,7 @@ class _AssetTileWidget extends ConsumerWidget { final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); - final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash; + final showStackIndicator = ref.watch(timelineServiceProvider).origin != TimelineOrigin.trash; return RepaintBoundary( child: GestureDetector( diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart index 76176041fa..433118d13c 100644 --- a/mobile/lib/presentation/widgets/timeline/header.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; @@ -12,7 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -class TimelineHeader extends HookConsumerWidget { +class TimelineHeader extends HookWidget { final Bucket bucket; final HeaderType header; final double height; @@ -39,7 +40,7 @@ class TimelineHeader extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { if (bucket is! TimeBucket || header == HeaderType.none) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index b39c431b43..bcdc3b4783 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; @@ -8,6 +10,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +part 'timeline.state.freezed.dart'; + class TimelineArgs { final double maxWidth; final double maxHeight; @@ -49,25 +53,13 @@ class TimelineArgs { groupBy.hashCode; } -class TimelineState { - final bool isScrubbing; - final bool isScrolling; +@freezed +abstract class TimelineState with _$TimelineState { + const TimelineState._(); - const TimelineState({this.isScrubbing = false, this.isScrolling = false}); + const factory TimelineState({@Default(false) bool isScrubbing, @Default(false) bool isScrolling}) = _TimelineState; bool get isInteracting => isScrubbing || isScrolling; - - @override - bool operator ==(covariant TimelineState other) { - return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling; - } - - @override - int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode; - - TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) { - return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing, isScrolling: isScrolling ?? this.isScrolling); - } } class TimelineStateNotifier extends Notifier { diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 8678f7c32e..7dc475ab93 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -81,6 +81,10 @@ class AppLifeCycleNotifier extends StateNotifier { await _ref.read(serverInfoProvider.notifier).getServerVersion(); } + if (!_shouldContinueOperation()) { + _wasPaused = true; + return; + } _ref.read(websocketProvider.notifier).connect(); await _handleBetaTimelineResume(); @@ -89,13 +93,13 @@ class AppLifeCycleNotifier extends StateNotifier { await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); } - Future _safeRun(Future action, String debugName) async { + Future _safeRun(Future Function() action, String debugName) async { if (!_shouldContinueOperation()) { return; } try { - await action; + await action(); } catch (e, stackTrace) { _log.warning("Error during $debugName operation", e, stackTrace); } @@ -113,13 +117,15 @@ class AppLifeCycleNotifier extends StateNotifier { try { bool syncSuccess = false; await Future.wait([ - _safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"), - _safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"), + _safeRun(() => backgroundManager.syncLocal(full: CurrentPlatform.isAndroid), "syncLocal"), + _safeRun(() async { + syncSuccess = await backgroundManager.syncRemote(); + }, "syncRemote"), ]); _ref.invalidate(driftMemoryFutureProvider); if (syncSuccess) { await Future.wait([ - _safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) { + _safeRun(backgroundManager.hashAssets, "hashAssets").then((_) { unawaited(_resumeBackup()); }), _resumeBackup(), @@ -127,11 +133,11 @@ class AppLifeCycleNotifier extends StateNotifier { // _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"), ]); } else { - await _safeRun(backgroundManager.hashAssets(), "hashAssets"); + await _safeRun(backgroundManager.hashAssets, "hashAssets"); } if (isAlbumLinkedSyncEnable) { - await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum"); + await _safeRun(backgroundManager.syncLinkedAlbum, "syncLinkedAlbum"); } } catch (e, stackTrace) { _log.severe("Error during background sync", e, stackTrace); @@ -145,7 +151,7 @@ class AppLifeCycleNotifier extends StateNotifier { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { await _safeRun( - _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id), + () => _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id), "handleBackupResume", ); } @@ -195,7 +201,7 @@ class AppLifeCycleNotifier extends StateNotifier { Future _performPause() { if (_ref.read(authProvider).isAuthenticated) { - _ref.read(driftBackupProvider.notifier).stopForegroundBackup(); + _ref.read(driftBackupProvider.notifier).stopForegroundBackup(reason: "the app being sent to the background"); _ref.read(websocketProvider.notifier).disconnect(); } diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index 7b1d5d2caa..ac929d2b6a 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -1,81 +1,25 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -class AssetViewerState { - final double backgroundOpacity; - final bool showingDetails; - final bool showingControls; - final bool isZoomed; - final bool showingOcr; - final BaseAsset? currentAsset; - final int stackIndex; +part 'asset_viewer.provider.freezed.dart'; - const AssetViewerState({ - this.backgroundOpacity = 1.0, - this.showingDetails = false, - this.showingControls = true, - this.isZoomed = false, - this.showingOcr = false, - this.currentAsset, - this.stackIndex = 0, - }); - - AssetViewerState copyWith({ - double? backgroundOpacity, - bool? showingDetails, - bool? showingControls, - bool? isZoomed, - bool? showingOcr, +@freezed +abstract class AssetViewerState with _$AssetViewerState { + const factory AssetViewerState({ + @Default(1.0) double backgroundOpacity, + @Default(false) bool showingDetails, + @Default(true) bool showingControls, + @Default(false) bool isZoomed, + @Default(false) bool showingOcr, BaseAsset? currentAsset, - int? stackIndex, - }) { - return AssetViewerState( - backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, - showingDetails: showingDetails ?? this.showingDetails, - showingControls: showingControls ?? this.showingControls, - isZoomed: isZoomed ?? this.isZoomed, - showingOcr: showingOcr ?? this.showingOcr, - currentAsset: currentAsset ?? this.currentAsset, - stackIndex: stackIndex ?? this.stackIndex, - ); - } - - @override - String toString() { - return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - if (other.runtimeType != runtimeType) { - return false; - } - return other is AssetViewerState && - other.backgroundOpacity == backgroundOpacity && - other.showingDetails == showingDetails && - other.showingControls == showingControls && - other.isZoomed == isZoomed && - other.showingOcr == showingOcr && - other.currentAsset == currentAsset && - other.stackIndex == stackIndex; - } - - @override - int get hashCode => - backgroundOpacity.hashCode ^ - showingDetails.hashCode ^ - showingControls.hashCode ^ - isZoomed.hashCode ^ - showingOcr.hashCode ^ - currentAsset.hashCode ^ - stackIndex.hashCode; + @Default(0) int stackIndex, + }) = _AssetViewerState; } class AssetViewerStateNotifier extends Notifier { @@ -123,9 +67,9 @@ class AssetViewerStateNotifier extends Notifier { } state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls); - final heroTag = state.currentAsset?.heroTag; - if (heroTag != null) { - final notifier = ref.read(videoPlayerProvider(heroTag).notifier); + final id = state.currentAsset?.id; + if (id != null) { + final notifier = ref.read(videoPlayerProvider(id).notifier); showing ? notifier.hold() : notifier.release(); } } diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 37b3145eb4..5873e992b1 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -9,6 +9,7 @@ final backgroundSyncProvider = Provider((ref) { final manager = BackgroundSyncManager( onRemoteSyncStart: () { syncStatusNotifier.startRemoteSync(); + // ignore: dispose-provided-instances final backupProvider = ref.read(driftBackupProvider.notifier); if (backupProvider.mounted) { backupProvider.updateError(BackupError.none); @@ -16,6 +17,7 @@ final backgroundSyncProvider = Provider((ref) { }, onRemoteSyncComplete: (isSuccess) { syncStatusNotifier.completeRemoteSync(); + // ignore: dispose-provided-instances final backupProvider = ref.read(driftBackupProvider.notifier); if (backupProvider.mounted) { backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed); diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index b43fec023c..1756d0fd30 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; -import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; @@ -12,6 +13,8 @@ import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/upload_speed_calculator.dart'; import 'package:logging/logging.dart'; +part 'drift_backup.provider.freezed.dart'; + class EnqueueStatus { final int enqueueCount; final int totalCount; @@ -26,75 +29,17 @@ class EnqueueStatus { String toString() => 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)'; } -class DriftUploadStatus { - final String taskId; - final String filename; - final double progress; - final int fileSize; - final String networkSpeedAsString; - final bool? isFailed; - final String? error; - - const DriftUploadStatus({ - required this.taskId, - required this.filename, - required this.progress, - required this.fileSize, - required this.networkSpeedAsString, - this.isFailed, - this.error, - }); - - DriftUploadStatus copyWith({ - String? taskId, - String? filename, - double? progress, - int? fileSize, - String? networkSpeedAsString, +@freezed +abstract class DriftUploadStatus with _$DriftUploadStatus { + const factory DriftUploadStatus({ + required String taskId, + required String filename, + required double progress, + required int fileSize, + required String networkSpeedAsString, bool? isFailed, String? error, - }) { - return DriftUploadStatus( - taskId: taskId ?? this.taskId, - filename: filename ?? this.filename, - progress: progress ?? this.progress, - fileSize: fileSize ?? this.fileSize, - networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, - isFailed: isFailed ?? this.isFailed, - error: error ?? this.error, - ); - } - - @override - String toString() { - return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString, isFailed: $isFailed, error: $error)'; - } - - @override - bool operator ==(covariant DriftUploadStatus other) { - if (identical(this, other)) { - return true; - } - - return other.taskId == taskId && - other.filename == filename && - other.progress == progress && - other.fileSize == fileSize && - other.networkSpeedAsString == networkSpeedAsString && - other.isFailed == isFailed && - other.error == error; - } - - @override - int get hashCode { - return taskId.hashCode ^ - filename.hashCode ^ - progress.hashCode ^ - fileSize.hashCode ^ - networkSpeedAsString.hashCode ^ - isFailed.hashCode ^ - error.hashCode; - } + }) = _DriftUploadStatus; } enum BackupError { none, syncFailed } @@ -255,19 +200,25 @@ class DriftBackupNotifier extends StateNotifier { state = state.copyWith(isSyncing: isSyncing); } - Future startForegroundBackup(String userId) { + Future startForegroundBackup(String userId) async { // Cancel any existing backup before starting a new one if (_cancelToken != null) { - stopForegroundBackup(); + stopForegroundBackup(reason: "restarting the backup"); } state = state.copyWith(error: BackupError.none); - _cancelToken = Completer(); + // A pause during the recount below nulls _cancelToken, so the run keeps its own reference. + final cancelToken = Completer(); + _cancelToken = cancelToken; + + // Re-baseline the counters against the same DB read that feeds this run's candidate list, + // otherwise a resume counts duplicate successes against the old baseline (#26215). + await getBackupStatus(userId); return _foregroundUploadService.uploadCandidates( userId, - _cancelToken!, + cancelToken, callbacks: UploadCallbacks( onProgress: _handleForegroundBackupProgress, onSuccess: _handleForegroundBackupSuccess, @@ -277,7 +228,10 @@ class DriftBackupNotifier extends StateNotifier { ); } - void stopForegroundBackup() { + void stopForegroundBackup({required String reason}) { + if (_cancelToken != null) { + _logger.info("Foreground backup cancelled: $reason"); + } _cancelToken?.complete(); _cancelToken = null; _uploadSpeedManager.clear(); @@ -333,6 +287,10 @@ class DriftBackupNotifier extends StateNotifier { } void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) { + if (!mounted) { + _logger.warning("Skip _handleForegroundBackupSuccess: notifier disposed"); + return; + } state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1); _uploadSpeedManager.removeTask(localAssetId); diff --git a/mobile/lib/providers/gallery_permission.provider.dart b/mobile/lib/providers/gallery_permission.provider.dart index 6d4703c834..6be504bd2e 100644 --- a/mobile/lib/providers/gallery_permission.provider.dart +++ b/mobile/lib/providers/gallery_permission.provider.dart @@ -1,109 +1,28 @@ import 'dart:async'; -import 'dart:io'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/services/device_permission.service.dart'; +import 'package:immich_mobile/repositories/permission.repository.dart'; -class GalleryPermissionNotifier extends StateNotifier { - GalleryPermissionNotifier() - : super(PermissionStatus.denied) // Denied is the initial state - { - // Sets the initial state +final galleryPermissionServiceProvider = Provider((ref) { + return DevicePermissionService(ref.watch(permissionRepositoryProvider)); +}); + +class GalleryPermissionNotifier extends StateNotifier { + GalleryPermissionNotifier(this._service) : super(.denied) { unawaited(getGalleryPermissionStatus()); } - bool get hasPermission => state.isGranted || state.isLimited; + final DevicePermissionService _service; - /// Requests the gallery permission - Future requestGalleryPermission() async { - PermissionStatus result; - // Android 32 and below uses Permission.storage - if (Platform.isAndroid) { - final androidInfo = await DeviceInfoPlugin().androidInfo; - if (androidInfo.version.sdkInt <= 32) { - // Android 32 and below need storage - final permission = await Permission.storage.request(); - result = permission; - } else { - // Android 33 need photo & video - final photos = await Permission.photos.request(); - if (!photos.isGranted) { - // Don't ask twice for the same permission - state = photos; - return photos; - } - final videos = await Permission.videos.request(); + bool get hasPermission => state.hasAccess; - // Return the joint result of those two permissions - final PermissionStatus status; - if ((photos.isGranted && videos.isGranted) || (photos.isLimited && videos.isLimited)) { - status = PermissionStatus.granted; - } else if (photos.isDenied || videos.isDenied) { - status = PermissionStatus.denied; - } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { - status = PermissionStatus.permanentlyDenied; - } else { - status = PermissionStatus.denied; - } + Future requestGalleryPermission() async => state = await _service.requestGallery(); - result = status; - } - if (result == PermissionStatus.granted && androidInfo.version.sdkInt >= 29) { - result = await Permission.accessMediaLocation.request(); - } - } else { - // iOS can use photos - final photos = await Permission.photos.request(); - result = photos; - } - state = result; - return result; - } - - /// Checks the current state of the gallery permissions without - /// requesting them again - Future getGalleryPermissionStatus() async { - PermissionStatus result; - // Android 32 and below uses Permission.storage - if (Platform.isAndroid) { - final androidInfo = await DeviceInfoPlugin().androidInfo; - if (androidInfo.version.sdkInt <= 32) { - // Android 32 and below need storage - final permission = await Permission.storage.status; - result = permission; - } else { - // Android 33 needs photo & video - final photos = await Permission.photos.status; - final videos = await Permission.videos.status; - - // Return the joint result of those two permissions - final PermissionStatus status; - if ((photos.isGranted && videos.isGranted) || (photos.isLimited && videos.isLimited)) { - status = PermissionStatus.granted; - } else if (photos.isDenied || videos.isDenied) { - status = PermissionStatus.denied; - } else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) { - status = PermissionStatus.permanentlyDenied; - } else { - status = PermissionStatus.denied; - } - - result = status; - } - if (state == PermissionStatus.granted && androidInfo.version.sdkInt >= 29) { - result = await Permission.accessMediaLocation.status; - } - } else { - // iOS can use photos - final photos = await Permission.photos.status; - result = photos; - } - state = result; - return result; - } + Future getGalleryPermissionStatus() async => state = await _service.galleryStatus(); } -final galleryPermissionNotifier = StateNotifierProvider( - (ref) => GalleryPermissionNotifier(), +final galleryPermissionNotifier = StateNotifierProvider( + (ref) => GalleryPermissionNotifier(ref.watch(galleryPermissionServiceProvider)), ); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 919836737f..3fb9f8acbc 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'dart:async'; import 'package:auto_route/auto_route.dart'; diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index b392f5f401..4457b9f831 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'dart:async'; import 'package:collection/collection.dart'; diff --git a/mobile/lib/providers/infrastructure/setting.provider.dart b/mobile/lib/providers/infrastructure/setting.provider.dart index 7d8be72cd0..9e33017cab 100644 --- a/mobile/lib/providers/infrastructure/setting.provider.dart +++ b/mobile/lib/providers/infrastructure/setting.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart index 2d527768c5..152582aa93 100644 --- a/mobile/lib/providers/infrastructure/tag.provider.dart +++ b/mobile/lib/providers/infrastructure/tag.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/tag.model.dart'; import 'package:immich_mobile/domain/services/tag.service.dart'; diff --git a/mobile/lib/providers/permission.provider.dart b/mobile/lib/providers/permission.provider.dart index dc82285122..bd4dc7b61e 100644 --- a/mobile/lib/providers/permission.provider.dart +++ b/mobile/lib/providers/permission.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'dart:async'; import 'dart:io'; diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index 6cc3d08c5d..776389b330 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -5,9 +5,6 @@ import 'package:immich_mobile/routing/router.dart'; @visibleForTesting bool isRouteInStack(Ref ref, String routeName) { final router = ref.watch(appRouterProvider); - void onChange() => ref.invalidateSelf(); - router.addListener(onChange); - ref.onDispose(() => router.removeListener(onChange)); return router.stackData.any((route) => route.name == routeName); } diff --git a/mobile/lib/providers/search/search_input_focus.provider.dart b/mobile/lib/providers/search/search_input_focus.provider.dart index 4f6ed41ee0..4ebbfc6553 100644 --- a/mobile/lib/providers/search/search_input_focus.provider.dart +++ b/mobile/lib/providers/search/search_input_focus.provider.dart @@ -2,5 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; final searchInputFocusProvider = Provider((ref) { - return FocusNode(); + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; }); diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index c25e496a04..79593cce95 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -59,7 +59,7 @@ class ServerInfoNotifier extends StateNotifier { } Future _checkServerVersionMismatch(ServerVersion serverVersion, {ServerVersion? latestVersion}) async { - state = state.copyWith(serverVersion: serverVersion, latestVersion: latestVersion); + state = state.copyWith(serverVersion: serverVersion, latestVersion: latestVersion ?? state.latestVersion); final packageInfo = await PackageInfo.fromPlatform(); final SemVer clientVersion = SemVer.fromString(packageInfo.version); diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart index b7d4f8bdc3..7a70514ca7 100644 --- a/mobile/lib/providers/sync_status.provider.dart +++ b/mobile/lib/providers/sync_status.provider.dart @@ -1,6 +1,10 @@ import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +part 'sync_status.provider.freezed.dart'; + enum SyncStatus { idle, syncing, @@ -17,58 +21,22 @@ enum SyncStatus { } } -class SyncStatusState { - final SyncStatus remoteSyncStatus; - final SyncStatus localSyncStatus; - final SyncStatus hashJobStatus; - final SyncStatus cloudIdSyncStatus; +@freezed +abstract class SyncStatusState with _$SyncStatusState { + const SyncStatusState._(); - final String? errorMessage; - - const SyncStatusState({ - this.remoteSyncStatus = SyncStatus.idle, - this.localSyncStatus = SyncStatus.idle, - this.hashJobStatus = SyncStatus.idle, - this.cloudIdSyncStatus = SyncStatus.idle, - this.errorMessage, - }); - - SyncStatusState copyWith({ - SyncStatus? remoteSyncStatus, - SyncStatus? localSyncStatus, - SyncStatus? hashJobStatus, - SyncStatus? cloudIdSyncStatus, + const factory SyncStatusState({ + @Default(SyncStatus.idle) SyncStatus remoteSyncStatus, + @Default(SyncStatus.idle) SyncStatus localSyncStatus, + @Default(SyncStatus.idle) SyncStatus hashJobStatus, + @Default(SyncStatus.idle) SyncStatus cloudIdSyncStatus, String? errorMessage, - }) { - return SyncStatusState( - remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, - localSyncStatus: localSyncStatus ?? this.localSyncStatus, - hashJobStatus: hashJobStatus ?? this.hashJobStatus, - cloudIdSyncStatus: cloudIdSyncStatus ?? this.cloudIdSyncStatus, - errorMessage: errorMessage ?? this.errorMessage, - ); - } + }) = _SyncStatusState; bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; bool get isLocalSyncing => localSyncStatus == SyncStatus.syncing; bool get isHashing => hashJobStatus == SyncStatus.syncing; bool get isCloudIdSyncing => cloudIdSyncStatus == SyncStatus.syncing; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is SyncStatusState && - other.remoteSyncStatus == remoteSyncStatus && - other.localSyncStatus == localSyncStatus && - other.hashJobStatus == hashJobStatus && - other.cloudIdSyncStatus == cloudIdSyncStatus && - other.errorMessage == errorMessage; - } - - @override - int get hashCode => Object.hash(remoteSyncStatus, localSyncStatus, hashJobStatus, cloudIdSyncStatus, errorMessage); } class SyncStatusNotifier extends Notifier { @@ -88,7 +56,11 @@ class SyncStatusNotifier extends Notifier { /// void setRemoteSyncStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(remoteSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + remoteSyncStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startRemoteSync() => setRemoteSyncStatus(SyncStatus.syncing); @@ -100,7 +72,11 @@ class SyncStatusNotifier extends Notifier { /// void setLocalSyncStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(localSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + localSyncStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startLocalSync() => setLocalSyncStatus(SyncStatus.syncing); @@ -112,7 +88,11 @@ class SyncStatusNotifier extends Notifier { /// void setHashJobStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(hashJobStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + hashJobStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startHashJob() => setHashJobStatus(SyncStatus.syncing); @@ -124,7 +104,11 @@ class SyncStatusNotifier extends Notifier { /// void setCloudIdSyncStatus(SyncStatus status, [String? errorMessage]) { - state = state.copyWith(cloudIdSyncStatus: status, errorMessage: status == SyncStatus.error ? errorMessage : null); + // TODO(agg23): These error messages probably should be cleared, not preserved on null + state = state.copyWith( + cloudIdSyncStatus: status, + errorMessage: (status == SyncStatus.error ? errorMessage : null) ?? state.errorMessage, + ); } void startCloudIdSync() => setCloudIdSyncStatus(SyncStatus.syncing); diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 64edd4cd3f..36de8d5e4f 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -1,3 +1,5 @@ +// ignore_for_file: use-ref-and-state-synchronously + import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index c6ed09360a..32531c354b 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -15,30 +17,11 @@ import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:socket_io_client/socket_io_client.dart'; -class WebsocketState { - final Socket? socket; - final bool isConnected; +part 'websocket.provider.freezed.dart'; - const WebsocketState({this.socket, required this.isConnected}); - - WebsocketState copyWith({Socket? socket, bool? isConnected}) { - return WebsocketState(socket: socket ?? this.socket, isConnected: isConnected ?? this.isConnected); - } - - @override - String toString() => 'WebsocketState(socket: $socket, isConnected: $isConnected)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - return other is WebsocketState && other.socket == socket && other.isConnected == isConnected; - } - - @override - int get hashCode => socket.hashCode ^ isConnected.hashCode; +@freezed +abstract class WebsocketState with _$WebsocketState { + const factory WebsocketState({Socket? socket, required bool isConnected}) = _WebsocketState; } class WebsocketNotifier extends StateNotifier { @@ -56,14 +39,16 @@ class WebsocketNotifier extends StateNotifier { @override void dispose() { _batchDebouncer.dispose(); + state.socket?.dispose(); super.dispose(); } - /// Connects websocket to server unless already connected + /// Connects websocket to server unless an active socket already exists void connect() { - if (state.isConnected) { + if (state.socket?.active == true) { return; } + state.socket?.dispose(); final authenticationState = _ref.read(authProvider); if (authenticationState.isAuthenticated) { @@ -84,6 +69,8 @@ class WebsocketNotifier extends StateNotifier { .build(), ); + state = WebsocketState(isConnected: false, socket: socket); + socket.onConnect((_) { dPrint(() => "Established Websocket Connection"); state = WebsocketState(isConnected: true, socket: socket); @@ -91,12 +78,12 @@ class WebsocketNotifier extends StateNotifier { socket.onDisconnect((_) { dPrint(() => "Disconnect to Websocket Connection"); - state = const WebsocketState(isConnected: false, socket: null); + state = WebsocketState(isConnected: false, socket: socket); }); socket.on('error', (errorMessage) { _log.severe("Websocket Error - $errorMessage"); - state = const WebsocketState(isConnected: false, socket: null); + state = WebsocketState(isConnected: false, socket: socket); }); socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1); @@ -105,6 +92,11 @@ class WebsocketNotifier extends StateNotifier { socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); socket.on('on_album_update', _handleRemoteChange); socket.on('on_asset_stack_update', _handleRemoteChange); + socket.on('on_asset_delete', _handleRemoteChange); + socket.on('on_asset_trash', _handleRemoteChange); + socket.on('on_asset_restore', _handleRemoteChange); + socket.on('on_asset_hidden', _handleRemoteChange); + socket.on('on_asset_update', _handleRemoteChange); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -187,7 +179,7 @@ class WebsocketNotifier extends StateNotifier { } void _handleRemoteChange(dynamic _) { - unawaited(_ref.read(backgroundSyncProvider).syncRemote()); + unawaited(_ref.read(backgroundSyncProvider).syncRemote(enqueue: true)); } void _handleSyncAssetEditReadyV2(dynamic data) { diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index ed1ffd9e3d..855b8302a9 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +// ignore: dispose-provided-instances final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); class DownloadRepository { diff --git a/mobile/lib/repositories/permission.repository.dart b/mobile/lib/repositories/permission.repository.dart index e4b6d99f25..932be4a290 100644 --- a/mobile/lib/repositories/permission.repository.dart +++ b/mobile/lib/repositories/permission.repository.dart @@ -1,67 +1,83 @@ +import 'package:device_info_plus/device_info_plus.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/platform/permission_api.g.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:permission_handler/permission_handler.dart' as ph; final permissionRepositoryProvider = Provider((ref) { - return PermissionRepository(ref.watch(permissionApiProvider)); + return DevicePermissionRepository(ref.watch(permissionApiProvider)); }); -class PermissionRepository implements IPermissionRepository { +class DevicePermissionRepository { final PermissionApi _permissionApi; - const PermissionRepository(this._permissionApi); + const DevicePermissionRepository(this._permissionApi); - @override - Future hasLocationWhenInUsePermission() { - return Permission.locationWhenInUse.isGranted; + Future getStatus(DevicePermission permission) async => + (await permission.handler.status).toDevicePermissionStatus(); + + Future request(DevicePermission permission) async => + (await permission.handler.request()).toDevicePermissionStatus(); + + // TODO(shenlong): Move this to it's own device info repo + Future getAndroidSdkVersion() async { + if (CurrentPlatform.isIOS) { + throw UnsupportedError("This method is available only on Android"); + } + + final androidInfo = await DeviceInfoPlugin().androidInfo; + return androidInfo.version.sdkInt; } - @override + Future hasLocationWhenInUsePermission() => ph.Permission.locationWhenInUse.isGranted; + Future requestLocationWhenInUsePermission() async { - final result = await Permission.locationWhenInUse.request(); + final result = await ph.Permission.locationWhenInUse.request(); return result.isGranted; } - @override Future hasLocationAlwaysPermission() { - return Permission.locationAlways.isGranted; + return ph.Permission.locationAlways.isGranted; } - @override Future requestLocationAlwaysPermission() async { - final result = await Permission.locationAlways.request(); + final result = await ph.Permission.locationAlways.request(); return result.isGranted; } - @override Future openSettings() { - return openAppSettings(); + return ph.openAppSettings(); } - @override Future hasManageMediaPermission() { return _permissionApi.hasManageMediaPermission(); } - @override Future requestManageMediaPermission() { return _permissionApi.requestManageMediaPermission(); } - @override Future manageMediaPermission() { return _permissionApi.manageMediaPermission(); } } -abstract interface class IPermissionRepository { - Future hasLocationWhenInUsePermission(); - Future requestLocationWhenInUsePermission(); - Future hasLocationAlwaysPermission(); - Future requestLocationAlwaysPermission(); - Future openSettings(); - Future hasManageMediaPermission(); - Future requestManageMediaPermission(); - Future manageMediaPermission(); +extension on DevicePermission { + ph.Permission get handler => switch (this) { + .photos => ph.Permission.photos, + .videos => ph.Permission.videos, + .storage => ph.Permission.storage, + .mediaLocation => ph.Permission.accessMediaLocation, + }; +} + +extension on ph.PermissionStatus { + DevicePermissionStatus toDevicePermissionStatus() => switch (this) { + .granted => .granted, + .limited => .limited, + .permanentlyDenied => .permanentlyDenied, + _ => .denied, + }; } diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index f126788008..a7f6d532e9 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -13,6 +13,8 @@ class AppNavigationObserver extends AutoRouterObserver { @override void didPush(Route route, Route? previousRoute) { + ref.invalidate(inLockedViewProvider); + ref.invalidate(isAssetViewerOpenProvider); unawaited( Future(() { ref.read(currentRouteNameProvider.notifier).state = route.settings.name; @@ -21,4 +23,10 @@ class AppNavigationObserver extends AutoRouterObserver { }), ); } + + @override + void didPop(Route route, Route? previousRoute) { + ref.invalidate(inLockedViewProvider); + ref.invalidate(isAssetViewerOpenProvider); + } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ea33e4ba25..a4c5bab4fb 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -84,6 +84,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; final appRouterProvider = Provider( + // ignore: dispose-provided-instances (ref) => AppRouter( ref.watch(apiServiceProvider), ref.watch(authServiceProvider), diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 36d3975a26..11cbd03017 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -35,6 +35,7 @@ class UploadCallbacks { } final foregroundUploadServiceProvider = Provider((ref) { + // ignore: dispose-provided-instances return ForegroundUploadService( ref.watch(uploadRepositoryProvider), ref.watch(storageRepositoryProvider), @@ -383,7 +384,6 @@ class ForegroundUploadService { if (result.isSuccess && result.remoteAssetId != null) { callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!); } else if (result.isCancelled) { - _logger.warning(() => "Backup was cancelled by the user"); shouldAbortUpload = true; } else if (result.errorMessage != null) { _logger.severe( diff --git a/mobile/lib/services/network.service.dart b/mobile/lib/services/network.service.dart index 8622400e7a..e31ecb617e 100644 --- a/mobile/lib/services/network.service.dart +++ b/mobile/lib/services/network.service.dart @@ -8,7 +8,7 @@ final networkServiceProvider = Provider((ref) { class NetworkService { final NetworkRepository _repository; - final IPermissionRepository _permissionRepository; + final DevicePermissionRepository _permissionRepository; const NetworkService(this._repository, this._permissionRepository); diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart index fcaff8bfc3..5431420741 100644 --- a/mobile/lib/widgets/activities/comment_bubble.dart +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -28,7 +28,7 @@ class CommentBubble extends ConsumerWidget { final isLike = activity.type == ActivityType.like; final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer; - final activityNotifier = ref.read( + final activityNotifier = ref.watch( albumActivityProvider((album.id, isAssetActivity ? activity.assetId : null)).notifier, ); diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart index d406f29a22..f91d8f5b27 100644 --- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart +++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart @@ -28,7 +28,7 @@ class CastDialog extends ConsumerWidget { width: 250, height: 250, child: FutureBuilder>( - future: ref.read(castProvider.notifier).getDevices(), + future: ref.watch(castProvider.notifier).getDevices(), builder: (context, snapshot) { if (snapshot.hasError) { return Text('error_saving_image'.tr(args: [snapshot.error.toString()])); diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index f39077a522..14c4daf5d8 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -99,7 +99,7 @@ class _VideoControlsState extends ConsumerState { }); ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset()); - final notifier = ref.read(_provider.notifier); + final notifier = ref.watch(_provider.notifier); final isLoaded = duration != Duration.zero; return Padding( diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart index 999b64e9de..41a215fb99 100644 --- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -53,7 +53,7 @@ class DriftAlbumInfoListTile extends HookConsumerWidget { return GestureDetector( onDoubleTap: () { - ref.watch(hapticFeedbackProvider.notifier).selectionClick(); + ref.read(hapticFeedbackProvider.notifier).selectionClick(); if (isExcluded) { unawaited(ref.read(backupAlbumProvider.notifier).deselectAlbum(album)); diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 53c1eb1af9..b390d2a423 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -124,6 +124,9 @@ class ImmichAppBarDialog extends HookConsumerWidget { onOk: () async { isLoggingOut.value = true; await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false); + if (!context.mounted) { + return; + } ref.read(websocketProvider.notifier).disconnect(); if (!context.mounted) { diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index c963564c69..51cdd7c5a3 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -45,12 +45,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget { Future pickUserProfileImage() async { final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024); - if (image != null) { - final success = await ref.watch(uploadProfileImageProvider.notifier).upload(image); + if (image != null && context.mounted) { + final success = await ref.read(uploadProfileImageProvider.notifier).upload(image); - if (success) { + if (success && context.mounted) { final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath; - ref.watch(authProvider.notifier).updateUserProfileImagePath(profileImagePath); + ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath); if (user != null) { unawaited(ref.read(currentUserProvider.notifier).refresh()); } @@ -61,7 +61,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { } void toggleReadonlyMode() { - final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isReadonlyModeEnabled = ref.read(readonlyModeProvider); ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); context.scaffoldMessenger.showSnackBar( diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index c5bce92cff..54495365db 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -113,7 +113,7 @@ class _ProfileIndicator extends ConsumerWidget { final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; void toggleReadonlyMode() { - final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final isReadonlyModeEnabled = ref.read(readonlyModeProvider); ref.read(readonlyModeProvider.notifier).toggleReadonlyMode(); context.scaffoldMessenger.showSnackBar( @@ -193,11 +193,11 @@ class _BackupIndicator extends ConsumerWidget { } Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { - final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); - final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled)); + final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none)); final isDarkTheme = context.isDarkTheme; final iconColor = isDarkTheme ? Colors.white : Colors.black; - final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); + final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); if (!backupEnabled) { return _BadgeLabel( diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart index a9a68fe044..82228f5b0b 100644 --- a/mobile/lib/widgets/common/tag_picker.dart +++ b/mobile/lib/widgets/common/tag_picker.dart @@ -17,13 +17,13 @@ Future<(Set, Set)?> showTagPickerModal({required BuildContext co ); } -class _TagPickerModal extends HookConsumerWidget { +class _TagPickerModal extends HookWidget { final Set? initialSelection; const _TagPickerModal({this.initialSelection}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final selectedTagIds = useState>(initialSelection ?? {}); final newTagValues = useState>({}); diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index c6e4f4719e..11257da77b 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -// ignore: must_be_immutable -class UserCircleAvatar extends ConsumerWidget { +class UserCircleAvatar extends StatelessWidget { final UserDto user; - double size; - bool hasBorder; - double opacity; + final double size; + final bool hasBorder; + final double opacity; - UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); + const UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity); final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart index dd307f4ede..229eaa7ec6 100644 --- a/mobile/lib/widgets/forms/change_password_form.dart +++ b/mobile/lib/widgets/forms/change_password_form.dart @@ -69,12 +69,16 @@ class ChangePasswordForm extends HookConsumerWidget { return; } - await ref.read(authProvider.notifier).logout(); - ref.read(websocketProvider.notifier).disconnect(); if (!context.mounted) { return; } + await ref.read(authProvider.notifier).logout(); + if (!context.mounted) { + return; + } + + ref.read(websocketProvider.notifier).disconnect(); AutoRouter.of(context).back(); ImmichToast.show( context: context, @@ -149,13 +153,13 @@ class ConfirmPasswordInput extends StatelessWidget { } } -class ChangePasswordButton extends ConsumerWidget { +class ChangePasswordButton extends StatelessWidget { final TextEditingController passwordController; final VoidCallback onPressed; const ChangePasswordButton({super.key, required this.passwordController, required this.onPressed}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ElevatedButton( style: ElevatedButton.styleFrom( visualDensity: VisualDensity.standard, diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 18963f0cfd..e4c7516c43 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -101,9 +101,15 @@ class LoginForm extends HookConsumerWidget { try { final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl); + if (!context.mounted) { + return; + } // Fetch and load server config and features await ref.read(serverInfoProvider.notifier).getServerInfo(); + if (!context.mounted) { + return; + } final serverInfo = ref.read(serverInfoProvider); final features = serverInfo.serverFeatures; @@ -264,6 +270,10 @@ class LoginForm extends HookConsumerWidget { await getManageMediaPermission(); } unawaited(handleSyncFlow()); + if (!context.mounted) { + return; + } + ref.read(websocketProvider.notifier).connect(); unawaited(ref.read(featureMessageServiceProvider).markSeen()); if (!context.mounted) { @@ -312,7 +322,7 @@ class LoginForm extends HookConsumerWidget { } Future oAuthLogin() async { - final oAuthService = ref.watch(oAuthServiceProvider); + final oAuthService = ref.read(oAuthServiceProvider); String? oAuthServerUrl; final state = generateRandomString(32); @@ -349,27 +359,27 @@ class LoginForm extends HookConsumerWidget { try { final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier); - if (loginResponseDto == null) { + if (loginResponseDto == null || !context.mounted) { return; } log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}"); final isSuccess = await ref - .watch(authProvider.notifier) + .read(authProvider.notifier) .saveAuthInfo(accessToken: loginResponseDto.accessToken); - if (isSuccess) { + if (isSuccess && context.mounted) { await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); if (isSyncRemoteDeletionsMode()) { await getManageMediaPermission(); } unawaited(handleSyncFlow()); - unawaited(ref.read(featureMessageServiceProvider).markSeen()); if (!context.mounted) { return; } + unawaited(ref.read(featureMessageServiceProvider).markSeen()); unawaited(context.router.replaceAll([const TabShellRoute()])); return; } diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index eb4653e17e..84c2ac0100 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; @@ -15,7 +14,7 @@ import 'package:maplibre_gl/maplibre_gl.dart'; /// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set /// [showMarkerPin] to true which would display a marker pin instead. If both are provided, /// [assetMarkerRemoteId] will take precedence -class MapThumbnail extends HookConsumerWidget { +class MapThumbnail extends HookWidget { final Function(Point, LatLng)? onTap; final LatLng centre; final String? assetMarkerRemoteId; @@ -44,7 +43,7 @@ class MapThumbnail extends HookConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final controller = useRef(null); final styleLoaded = useState(false); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index bf8673ccc5..cb035a22e9 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -32,8 +32,8 @@ class AdvancedSettings extends HookConsumerWidget { final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); - final levelId = useState(ref.read(appConfigProvider).logLevel.index); - final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote); + final levelId = useState(ref.watch(appConfigProvider).logLevel.index); + final preferRemote = useState(ref.watch(appConfigProvider).image.preferRemote); useValueChanged( preferRemote.value, (_, __) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)), @@ -60,7 +60,7 @@ class AdvancedSettings extends HookConsumerWidget { useEffect(() { unawaited(() async { isManageMediaSupported.value = await checkAndroidVersion(); - if (isManageMediaSupported.value) { + if (isManageMediaSupported.value && context.mounted) { manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission(); } }()); diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index eda0d819d5..5b4c4bb89f 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -15,7 +15,7 @@ class LayoutSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow))); + final tilesPerRow = useState(ref.watch(appConfigProvider.select((s) => s.timeline.tilesPerRow))); useValueChanged(tilesPerRow.value, (_, __) { unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart index e3173dcdc7..82a2d804a3 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart @@ -14,7 +14,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal); + final isOriginal = useState(ref.watch(appConfigProvider).image.loadOriginal); useValueChanged(isOriginal.value, (_, __) { unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart index c785e8fed4..fc3466c3c6 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart @@ -13,7 +13,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate); + final tapToNavigate = useState(ref.watch(appConfigProvider).viewer.tapToNavigate); useValueChanged(tapToNavigate.value, (_, __) { unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart index ec52d23ca8..d8ccd4f0ed 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -17,7 +17,7 @@ class SlideshowSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final slideshow = ref.read(appConfigProvider).slideshow; + final slideshow = ref.watch(appConfigProvider).slideshow; final useRepeat = useState(slideshow.repeat); final useDuration = useState(slideshow.duration); final useLook = useState(slideshow.look); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart index 2b302e0427..7bea439f0c 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart @@ -13,7 +13,7 @@ class VideoViewerSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final viewer = ref.read(appConfigProvider).viewer; + final viewer = ref.watch(appConfigProvider).viewer; final useAutoPlayVideo = useState(viewer.autoPlayVideo); final useLoopVideo = useState(viewer.loopVideo); final useOriginalVideo = useState(viewer.loadOriginalVideo); diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index f0f6435f90..e7d56ed66a 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -19,11 +19,11 @@ import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/setting_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -class DriftBackupSettings extends ConsumerWidget { +class DriftBackupSettings extends StatelessWidget { const DriftBackupSettings({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return SettingsSubPageScaffold( settings: [ SettingGroupTitle( @@ -68,8 +68,9 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton> }); try { - await ref.read(backgroundSyncProvider).syncLinkedAlbum(); - await ref.read(backgroundSyncProvider).syncRemote(); + final backgroundSync = ref.read(backgroundSyncProvider); + await backgroundSync.syncLinkedAlbum(); + await backgroundSync.syncRemote(); } catch (_) { } finally { Future.delayed(const Duration(seconds: 1), () { @@ -225,7 +226,7 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final fgService = ref.read(backgroundWorkerFgServiceProvider); + final fgService = ref.watch(backgroundWorkerFgServiceProvider); return _BackupSwitchTile( metadataKey: SettingsKey.backupRequireCharging, selector: (c) => c.backup.requireCharging, diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index 58a044cea4..097f72e1e1 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -163,7 +163,7 @@ class SyncStatusAndActions extends HookConsumerWidget { leading: const Icon(Icons.cloud_circle_rounded), subtitle: "tap_to_run_job".t(context: context), trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus), - onTap: ref.read(backgroundSyncProvider).syncCloudIds, + onTap: ref.watch(backgroundSyncProvider).syncCloudIds, ), SettingListTile( title: "hash_asset".t(context: context), diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart index 72e85ac821..8d4785d664 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -39,11 +39,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState { } Future _initializeAlbumDefaults() async { + final notifier = ref.read(cleanupProvider.notifier); final albums = await ref.read(localAlbumProvider.future); final existingAlbumIds = albums.map((a) => a.id).toSet(); final albumsWithNames = albums.map((a) => (a.id, a.name)).toList(); - final notifier = ref.read(cleanupProvider.notifier); notifier.applyDefaultAlbumSelections(albumsWithNames); notifier.cleanupStaleAlbumIds(existingAlbumIds); } @@ -105,7 +105,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { lastDate: DateTime.now(), ); - if (picked != null) { + if (picked != null && context.mounted) { ref.read(cleanupProvider.notifier).setSelectedDate(picked); setState(() => _hasScanned = false); } @@ -122,6 +122,10 @@ class _FreeUpSpaceSettingsState extends ConsumerState { ref.read(hapticFeedbackProvider.notifier).mediumImpact(); await ref.read(cleanupProvider.notifier).scanAssets(); + if (!mounted) { + return; + } + final state = ref.read(cleanupProvider); setState(() { @@ -146,7 +150,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { _DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!), ); - if (confirmed != true) { + if (confirmed != true || !context.mounted) { return; } diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart index b12d1476f5..e8478da453 100644 --- a/mobile/lib/widgets/settings/language_settings.dart +++ b/mobile/lib/widgets/settings/language_settings.dart @@ -3,14 +3,13 @@ import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; -class LanguageSettings extends HookConsumerWidget { +class LanguageSettings extends HookWidget { const LanguageSettings({super.key}); Future _applyLanguageChange( @@ -33,7 +32,7 @@ class LanguageSettings extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final localeEntries = useMemoized(() => locales.entries.toList(), const []); final currentLocale = context.locale; final filteredLocaleEntries = useState>>(localeEntries); diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart index 68278e0e1d..81ffb4b19b 100644 --- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart +++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart @@ -36,7 +36,7 @@ class ExternalNetworkPreference extends HookConsumerWidget { entries.value[index] = entries.value[index].copyWith(url: url, status: status); await saveEndpointList(); - if (status == AuxCheckStatus.valid) { + if (status == AuxCheckStatus.valid && context.mounted) { await ref.read(apiServiceProvider).updateHeaders(); } } diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 513648f030..dad827230a 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -21,7 +21,7 @@ class NetworkingSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl(); - final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching); + final featureEnabled = useState(ref.watch(appConfigProvider).network.autoEndpointSwitching); useValueChanged(featureEnabled.value, (_, __) { unawaited(ref.read(settingsProvider).write(.networkAutoEndpointSwitching, featureEnabled.value)); }); diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index 8c858a231b..0bab4e051a 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -47,7 +47,7 @@ class NotificationSetting extends HookConsumerWidget { subtileText: 'notification_permission_list_tile_content'.tr(), buttonText: 'notification_permission_list_tile_enable_button'.tr(), onButtonTap: () => - ref.watch(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) { + ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) { if (permission == PermissionStatus.permanentlyDenied) { showPermissionsDialog(); } diff --git a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart index bfdb0f065c..c5003a9ced 100644 --- a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -class HapticSetting extends HookConsumerWidget { +class HapticSetting extends HookWidget { const HapticSetting({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final hapticFeedbackSetting = useAppSettingsState(AppSettingsEnum.enableHapticFeedback); final isHapticFeedbackEnabled = useValueNotifier(hapticFeedbackSetting.value); diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart index b1564b4145..7964ec3861 100644 --- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart @@ -16,7 +16,7 @@ class PrimaryColorSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final themeProvider = ref.read(immichThemeProvider); + final themeProvider = ref.watch(immichThemeProvider); final themeConfig = ref.watch(appConfigProvider.select((config) => config.theme)); const tileSize = 55.0; diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart index ec84d7be01..8281b2262a 100644 --- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart @@ -15,7 +15,7 @@ class ThemeSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentTheme = useState(ref.read(appConfigProvider.select((config) => config.theme.mode))); + final currentTheme = useState(ref.watch(appConfigProvider.select((config) => config.theme.mode))); final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark); final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system); final colorfulInterface = useValueNotifier( diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index d419d6ead0..d98925bdf8 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -179,7 +179,7 @@ class SharedLinkItem extends ConsumerWidget { ), ); - if (confirmed == true) { + if (confirmed == true && context.mounted) { await ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id); return true; } diff --git a/mobile/mise.toml b/mobile/mise.toml index ee312178a4..7caf6dfbd5 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -58,13 +58,7 @@ depends = ["//:open-api-dart"] [tasks."codegen:translation"] alias = "translation" description = "Generate translations from i18n JSONs" -run = [ - { task = "//:i18n:format-fix" }, - { tasks = [ - "i18n:loader", - "i18n:keys", - ] }, -] +depends = ["//:i18n:format-fix", "i18n:loader", "i18n:keys"] [tasks."codegen:app-icon"] description = "Generate app icons" @@ -127,6 +121,7 @@ run = [ "dart format lib/generated/codegen_loader.g.dart", ] depends = ["//:open-api-dart"] +wait_for = ["//:i18n:format-fix"] [tasks."i18n:keys"] description = "Generate i18n keys" @@ -135,6 +130,7 @@ run = [ "dart run bin/generate_keys.dart", "dart format lib/generated/translations.g.dart", ] +wait_for = ["//:i18n:format-fix"] [tasks."analyze:dart"] description = "Run Dart analysis" diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e0198f2683..f02df0b622 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -667,6 +667,22 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.14" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 + url: "https://pub.dev" + source: hosted + version: "3.2.5" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" frontend_server_client: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a55bcf515b..c2848e6dd8 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -91,6 +91,7 @@ dependencies: url: https://github.com/mertalev/http ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877 path: pkgs/ok_http/ + freezed_annotation: ^3.1.0 dev_dependencies: auto_route_generator: ^10.5.0 @@ -104,6 +105,7 @@ dev_dependencies: flutter_native_splash: ^2.4.7 flutter_test: sdk: flutter + freezed: ^3.2.5 integration_test: sdk: flutter mocktail: ^1.0.5 diff --git a/mobile/test/domain/models/base_asset_test.dart b/mobile/test/domain/models/base_asset_test.dart index 4c2eb3ca38..6a6c836aa7 100644 --- a/mobile/test/domain/models/base_asset_test.dart +++ b/mobile/test/domain/models/base_asset_test.dart @@ -43,4 +43,31 @@ void main() { expect(localOnly.refersToSameAsset(remoteOnly), isTrue); }); }); + + group('BaseAsset.id', () { + test('survives the DB copy filling localId', () { + final searchCopy = RemoteAssetFactory.create(id: 'asset-1'); + final mergedCopy = searchCopy.copyWith(localId: 'local-1'); + + expect(searchCopy.heroTag, isNot(mergedCopy.heroTag)); + expect(mergedCopy.id, searchCopy.id); + }); + + test('survives a local asset gaining a remote id', () { + final localOnly = LocalAssetFactory.create(id: 'local-1'); + final uploaded = localOnly.copyWith(remoteId: 'asset-1'); + + expect(localOnly.heroTag, isNot(uploaded.heroTag)); + expect(uploaded.id, localOnly.id); + }); + + test('differs between assets', () { + final a = RemoteAssetFactory.create(id: 'asset-1'); + final b = RemoteAssetFactory.create(id: 'asset-2'); + final local = LocalAssetFactory.create(id: 'local-1'); + + expect(a.id, isNot(b.id)); + expect(local.id, isNot(a.id)); + }); + }); } diff --git a/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart b/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart new file mode 100644 index 0000000000..f2463c090d --- /dev/null +++ b/mobile/test/presentation/widgets/asset_viewer/bottom_bar_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/actions/action.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/services/gcast.service.dart'; +import 'package:immich_mobile/utils/asset_filter.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:native_video_player/native_video_player.dart'; + +import '../../../service.mocks.dart'; +import '../../../unit/factories/remote_asset_factory.dart'; +import '../../../widget_tester_extensions.dart'; + +class MockNativeVideoPlayerController extends Mock implements NativeVideoPlayerController {} + +class MockTimelineService extends Mock implements TimelineService {} + +class TestReadOnlyModeNotifier extends ReadOnlyModeNotifier { + @override + bool build() => true; +} + +void main() { + testWidgets('player stays bound after local id arrives', (tester) async { + final searchCopy = RemoteAssetFactory.create(type: .video); + final mergedCopy = searchCopy.copyWith(localId: 'local-1', isFavorite: true); + final assetService = MockAssetService(); + final controller = MockNativeVideoPlayerController(); + final timeline = MockTimelineService(); + final updates = StreamController(sync: true); + when(() => assetService.watchAsset(searchCopy)).thenAnswer((_) => updates.stream); + when(controller.play).thenAnswer((_) async {}); + when(controller.pause).thenAnswer((_) async {}); + when(() => timeline.origin).thenReturn(.search); + + late WidgetRef ref; + + await tester.pumpConsumerWidget( + Consumer( + builder: (context, widgetRef, _) { + ref = widgetRef; + return const ViewerBottomBar(); + }, + ), + overrides: [ + assetServiceProvider.overrideWithValue(assetService), + assetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter({})), + gCastServiceProvider.overrideWithValue(MockGCastService()), + inLockedViewProvider.overrideWithValue(true), + ownedAssetsActionProvider(ActionSource.viewer).overrideWithValue(const AssetFilter({})), + readonlyModeProvider.overrideWith(TestReadOnlyModeNotifier.new), + timelineServiceProvider.overrideWithValue(timeline), + ], + ); + + final viewer = ref.read(assetViewerProvider.notifier); + viewer.setAsset(searchCopy); + await tester.pump(); + ref.read(videoPlayerProvider(searchCopy.id).notifier).attachController(controller); + updates.add(mergedCopy); + await tester.pump(); + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + verify(controller.play).called(1); + viewer.setShowingDetails(true); + await tester.pump(); + verify(controller.pause).called(1); + + await tester.pumpWidget(const SizedBox.shrink()); + unawaited(updates.close()); + }); +} diff --git a/mobile/test/presentation/widgets/images/local_image_provider_test.dart b/mobile/test/presentation/widgets/images/local_image_provider_test.dart new file mode 100644 index 0000000000..5b556f8a41 --- /dev/null +++ b/mobile/test/presentation/widgets/images/local_image_provider_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; + +import '../../../unit/factories/local_asset_factory.dart'; + +class _StubCompleter extends ImageStreamCompleter {} + +void main() { + late ImageCache cache; + late int loads; + + ImageStreamCompleter load() { + loads++; + return _StubCompleter(); + } + + setUp(() { + cache = ImageCache(); + loads = 0; + }); + + group('LocalFullImageProvider.previewTargetSize', () { + const box = Size(1179, 2556); + const cases = <(String, Size, int?, int?, bool, Size)>[ + ('normal', box, 4032, 3024, true, box), + ('missing dimensions', box, null, null, true, box), + ('invalid dimensions', box, 1000, 0, true, box), + ('long final preview', box, 1000, 30000, true, Size(16384 / 30, 16384)), + ('long first preview', box, 30000, 1000, false, Size(2556, 2556 / 30)), + ('ultra-thin preview', box, 10, 50000, false, Size(1, 2556)), + ('small source', box, 50, 2000, true, Size(50, 2000)), + ('at limit', Size(16384, 100), 1000, 1000, true, Size(16384, 100)), + ('over limit', Size(16385, 100), 1000, 1000, true, Size(1000, 1000)), + ]; + + for (final (name, box, width, height, previewIsFinal, expected) in cases) { + test(name, () { + final actual = LocalFullImageProvider.previewTargetSize( + box.width, + box.height, + width, + height, + previewIsFinal: previewIsFinal, + ); + expect(actual.width, closeTo(expected.width, 1e-6)); + expect(actual.height, closeTo(expected.height, 1e-6)); + }); + } + }); + + group('LocalFullImageProvider equality', () { + LocalFullImageProvider make({int? width, int? height}) => LocalFullImageProvider( + id: 'a', + assetType: AssetType.image, + size: const Size(100, 200), + isAnimated: false, + width: width, + height: height, + ); + + test('uses dimensions in the cache key', () { + final a = make(width: 100, height: 200); + final b = make(width: 100, height: 200); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a == make(width: 200, height: 100), isFalse); + }); + }); + + group('LocalThumbProvider caching', () { + test('editing on device re-renders the thumbnail', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load); + + expect(loads, 2); + }); + + test('an unchanged thumbnail still comes from the cache', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load); + + expect(loads, 1); + }); + + // The rehash clears the checksum before writing the new one, so the tile has to + // follow that step too or it waits for the hash to land before showing the edit. + test('re-renders while the checksum is still being recomputed', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + + expect(loads, 2); + }); + + test('stays cached while the checksum is missing', () { + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load); + + expect(loads, 1); + }); + }); + + group('factories', () { + test('thumbnails are keyed by the asset checksum', () { + final asset = LocalAssetFactory.create().copyWith(checksum: 'abc'); + + final provider = getThumbnailImageProvider(asset)! as LocalThumbProvider; + + expect(provider.checksum, 'abc'); + }); + }); + + group('LocalFullImageProvider caching', () { + test('editing on device re-renders the full image', () { + cache.putIfAbsent( + LocalFullImageProvider( + id: 'asset-1', + assetType: AssetType.image, + size: const Size(100, 100), + isAnimated: false, + checksum: 'before', + ), + load, + ); + cache.putIfAbsent( + LocalFullImageProvider( + id: 'asset-1', + assetType: AssetType.image, + size: const Size(100, 100), + isAnimated: false, + checksum: 'after', + ), + load, + ); + + expect(loads, 2); + }); + }); +} diff --git a/mobile/test/providers/app_life_cycle_provider_test.dart b/mobile/test/providers/app_life_cycle_provider_test.dart new file mode 100644 index 0000000000..d4489a58ba --- /dev/null +++ b/mobile/test/providers/app_life_cycle_provider_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/models/auth/auth_state.model.dart'; +import 'package:immich_mobile/models/server_info/server_version.model.dart'; +import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/utils/upload_speed_calculator.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../infrastructure/repository.mock.dart'; +import '../service.mocks.dart'; + +class FakeLogMessage extends Fake implements LogMessage {} + +class TestAuthNotifier extends AuthNotifier { + TestAuthNotifier(Ref ref) + : super( + MockAuthService(), + MockApiService(), + MockUserService(), + MockSecureStorageService(), + MockWidgetService(), + ref, + ) { + state = const AuthState( + deviceId: 'device-1', + userId: 'user-1', + userEmail: 'user@example.com', + name: 'User', + profileImagePath: '', + isAdmin: false, + isAuthenticated: true, + ); + } + + @override + Future setOpenApiServiceEndpoint() async => 'http://test-server.com'; +} + +class TestWebsocketNotifier extends WebsocketNotifier { + TestWebsocketNotifier(super.ref); + + int connectCount = 0; + int disconnectCount = 0; + final connectCalled = Completer(); + + @override + void connect() { + connectCount++; + if (!connectCalled.isCompleted) { + connectCalled.complete(); + } + throw StateError('unexpected websocket connection'); + } + + @override + void disconnect() => disconnectCount++; +} + +class TestDriftBackupNotifier extends DriftBackupNotifier { + TestDriftBackupNotifier() : super(MockForegroundUploadService(), MockBackgroundUploadService(), UploadSpeedManager()); +} + +void main() { + late LogService logService; + late Completer serverVersion; + late MockServerInfoService serverInfoService; + late MockBackgroundWorkerLockService lockService; + late ProviderContainer container; + late TestWebsocketNotifier websocket; + late AppLifeCycleNotifier lifeCycle; + late int serverVersionCount; + + setUpAll(() async { + final logRepository = MockLogRepository(); + final settingsRepository = MockSettingsRepository(); + registerFallbackValue(FakeLogMessage()); + when(() => logRepository.truncate(limit: any(named: 'limit'))).thenAnswer((_) async {}); + when(() => logRepository.insert(any())).thenAnswer((_) async => true); + when(() => settingsRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.info)); + logService = await LogService.init( + logRepository: logRepository, + settingsRepository: settingsRepository, + shouldBuffer: false, + ); + }); + + tearDownAll(() => logService.dispose()); + + setUp(() { + serverVersion = Completer(); + serverInfoService = MockServerInfoService(); + lockService = MockBackgroundWorkerLockService(); + serverVersionCount = 0; + + when(() => serverInfoService.getServerVersion()).thenAnswer((_) { + serverVersionCount++; + return serverVersionCount == 1 ? serverVersion.future : Future.value(); + }); + when(() => lockService.lock()).thenAnswer((_) async {}); + when(() => lockService.unlock()).thenAnswer((_) async {}); + + container = ProviderContainer( + overrides: [ + authProvider.overrideWith(TestAuthNotifier.new), + serverInfoProvider.overrideWith((_) => ServerInfoNotifier(serverInfoService)), + websocketProvider.overrideWith((ref) { + return websocket = TestWebsocketNotifier(ref); + }), + driftBackupProvider.overrideWith((_) => TestDriftBackupNotifier()), + backgroundWorkerLockServiceProvider.overrideWithValue(lockService), + ], + ); + lifeCycle = container.read(appStateProvider.notifier); + }); + + tearDown(() => container.dispose()); + + Future startResume() async { + await lifeCycle.handleAppPause(); + unawaited(lifeCycle.handleAppResume()); + await untilCalled(() => serverInfoService.getServerVersion()); + } + + Future releaseResume() async { + serverVersion.complete(); + await Future.delayed(Duration.zero); + } + + test('pause during resume does not reconnect websocket', () async { + await startResume(); + await lifeCycle.handleAppPause(); + await releaseResume(); + + expect(lifeCycle.getAppState(), AppLifeCycleEnum.paused); + expect(serverVersionCount, 1); + expect(websocket.disconnectCount, 2); + expect(websocket.connectCount, 0); + }); + + test('inactive resume retries when the app resumes again', () async { + await startResume(); + lifeCycle.handleAppInactivity(); + await releaseResume(); + + unawaited(lifeCycle.handleAppResume()); + await websocket.connectCalled.future; + + expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed); + expect(serverVersionCount, 2); + expect(websocket.disconnectCount, 1); + expect(websocket.connectCount, 1); + }); + + test('pause after an inactive abort resumes once', () async { + await startResume(); + lifeCycle.handleAppInactivity(); + await releaseResume(); + await lifeCycle.handleAppPause(); + + unawaited(lifeCycle.handleAppResume()); + await websocket.connectCalled.future; + unawaited(lifeCycle.handleAppResume()); + await Future.delayed(Duration.zero); + + expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed); + expect(serverVersionCount, 2); + expect(websocket.disconnectCount, 2); + expect(websocket.connectCount, 1); + }); +} diff --git a/mobile/test/providers/backup/drift_backup_provider_test.dart b/mobile/test/providers/backup/drift_backup_provider_test.dart new file mode 100644 index 0000000000..205f564cd9 --- /dev/null +++ b/mobile/test/providers/backup/drift_backup_provider_test.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/utils/upload_speed_calculator.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockForegroundUploadService extends Mock implements ForegroundUploadService {} + +class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} + +void main() { + late MockForegroundUploadService foregroundUploadService; + late MockBackgroundUploadService backgroundUploadService; + late DriftBackupNotifier notifier; + + setUpAll(() { + registerFallbackValue(Completer()); + registerFallbackValue(const UploadCallbacks()); + }); + + setUp(() { + foregroundUploadService = MockForegroundUploadService(); + backgroundUploadService = MockBackgroundUploadService(); + notifier = DriftBackupNotifier(foregroundUploadService, backgroundUploadService, UploadSpeedManager()); + addTearDown(() { + if (notifier.mounted) { + notifier.dispose(); + } + }); + }); + + void mockCounts({required int total, required int remainder, int processing = 0}) { + when( + () => foregroundUploadService.getBackupCounts('user-1'), + ).thenAnswer((_) async => (total: total, remainder: remainder, processing: processing)); + } + + // Drives a backup run so we can grab the onSuccess callback the notifier wires up. + Future startAndCaptureOnSuccess() async { + void Function(String, String)? onSuccess; + when(() => foregroundUploadService.uploadCandidates(any(), any(), callbacks: any(named: 'callbacks'))).thenAnswer(( + invocation, + ) async { + onSuccess = (invocation.namedArguments[#callbacks] as UploadCallbacks).onSuccess; + }); + await notifier.startForegroundBackup('user-1'); + return onSuccess!; + } + + group('foreground backup counts', () { + test('successes move one asset from remainder to backup', () async { + mockCounts(total: 25, remainder: 25); + final onSuccess = await startAndCaptureOnSuccess(); + + for (var i = 0; i < 10; i++) { + onSuccess('asset-$i', 'remote-$i'); + } + + expect(notifier.state.remainderCount, 15); + expect(notifier.state.backupCount, 10); + expect(notifier.state.backupCount + notifier.state.remainderCount, notifier.state.totalCount); + }); + + test('a duplicate success after pause and resume cannot go below zero', () async { + // #26215: app pauses mid-backup, sync has not recorded the upload yet, so the + // resumed run re-uploads the same asset and the server answers 200 duplicate. + // The start of each run re-baselines the counters from the DB, so the duplicate + // success is counted against a baseline that includes the asset again. + mockCounts(total: 1, remainder: 1); + + final firstRun = await startAndCaptureOnSuccess(); + expect(notifier.state.remainderCount, 1); + firstRun('asset-1', 'remote-1'); + expect(notifier.state.remainderCount, 0); + + notifier.stopForegroundBackup(reason: "test"); + + final resumedRun = await startAndCaptureOnSuccess(); + expect(notifier.state.remainderCount, 1); + verify(() => foregroundUploadService.getBackupCounts('user-1')).called(2); + + resumedRun('asset-1', 'remote-1'); + expect(notifier.state.remainderCount, 0); + expect(notifier.state.backupCount, 1); + }); + + test('a drifted counter state heals at run start', () async { + mockCounts(total: 91, remainder: 7); + notifier.state = notifier.state.copyWith(totalCount: 91, backupCount: 103, remainderCount: -12); + + await startAndCaptureOnSuccess(); + + expect(notifier.state.totalCount, 91); + expect(notifier.state.remainderCount, 7); + expect(notifier.state.backupCount, 84); + }); + + test('a late success after dispose does not throw', () async { + mockCounts(total: 2, remainder: 2); + final onSuccess = await startAndCaptureOnSuccess(); + notifier.dispose(); + + onSuccess('asset-1', 'remote-1'); + }); + }); +} diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index b56a8a098a..164e7fe062 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -12,7 +12,7 @@ class MockAssetApiRepository extends Mock implements AssetApiRepository {} class MockAssetMediaRepository extends Mock implements AssetMediaRepository {} -class MockPermissionRepository extends Mock implements IPermissionRepository {} +class MockPermissionRepository extends Mock implements DevicePermissionRepository {} class MockAuthApiRepository extends Mock implements AuthApiRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 785567de56..ecf9c771bc 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/services/asset.service.dart'; +import 'package:immich_mobile/domain/services/background_worker.service.dart'; import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; @@ -7,12 +8,16 @@ import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/platform/native_sync_api.g.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/gcast.service.dart'; import 'package:immich_mobile/services/network.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/toast.service.dart'; +import 'package:immich_mobile/services/widget.service.dart'; import 'package:mocktail/mocktail.dart'; class MockApiService extends Mock implements ApiService {} @@ -46,3 +51,13 @@ class MockCleanupService extends Mock implements CleanupService {} class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {} class MockToastService extends Mock implements ToastService {} + +class MockAuthService extends Mock implements AuthService {} + +class MockSecureStorageService extends Mock implements SecureStorageService {} + +class MockWidgetService extends Mock implements WidgetService {} + +class MockBackgroundUploadService extends Mock implements BackgroundUploadService {} + +class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {} diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 1d43198263..6c41320a85 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -32,6 +32,7 @@ class RepositoryMocks { final trashedAsset = MockTrashedLocalAssetRepository(); final remoteAlbum = MockRemoteAlbumRepository(); final albumApi = MockDriftAlbumApiRepository(); + final permission = PermissionRepositoryStub(MockPermissionRepository()); final nativeApi = NativeSyncApiStub(MockNativeSyncApi()); final assetApi = AssetApiRepositoryStub(MockAssetApiRepository()); @@ -55,6 +56,7 @@ class RepositoryMocks { assetApi.reset(); assetMedia.reset(); download.reset(); + permission.reset(); _stubLocalAlbumRepository(); _stubLocalAssetRepository(); _stubRemoteAssetRepository(); @@ -63,6 +65,7 @@ class RepositoryMocks { _stubAssetApiRepository(); _stubAssetMediaRepository(); _stubDownloadRepository(); + _stubPermissionRepository(); } void _stubRemoteAssetRepository() { @@ -101,6 +104,12 @@ class RepositoryMocks { void _stubDownloadRepository() { when(download.downloadAllAssets).thenAnswer((_) async => const []); } + + void _stubPermissionRepository() { + when(permission.getStatus).thenAnswer((_) async => DevicePermissionStatus.denied); + when(permission.request).thenAnswer((_) async => DevicePermissionStatus.denied); + when(permission.getAndroidSdkVersion).thenAnswer((_) async => 34); + } } class ServiceMocks { @@ -220,6 +229,8 @@ void _registerFallbacks() { registerFallbackValue(ShareAssetType.original); registerFallbackValue(const UploadCallbacks()); registerFallbackValue(_FakeBuildContext()); + registerFallbackValue(DevicePermissionStatus.granted); + registerFallbackValue(DevicePermission.photos); } class _FakeBuildContext extends Fake implements BuildContext {} @@ -400,6 +411,17 @@ extension type const DownloadRepositoryStub(MockDownloadRepository repo) impleme () => repo.downloadAllAssets(any()); } +extension type const PermissionRepositoryStub(MockPermissionRepository repo) implements Stub { + Future Function() get getStatus => + () => repo.getStatus(any()); + + Future Function() get request => + () => repo.request(any()); + + Future Function() get getAndroidSdkVersion => + () => repo.getAndroidSdkVersion(); +} + extension type const TagServiceStub(MockTagService service) implements Stub { Future Function() get bulkTagAssets => () => service.bulkTagAssets(any(), any()); diff --git a/mobile/test/unit/services/gallery_permission_service_test.dart b/mobile/test/unit/services/gallery_permission_service_test.dart new file mode 100644 index 0000000000..9a399f9948 --- /dev/null +++ b/mobile/test/unit/services/gallery_permission_service_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/services/device_permission.service.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../mocks.dart'; + +void main() { + late RepositoryMocks mocks; + late DevicePermissionService sut; + + void stubPermissions(Map statuses) { + DevicePermissionStatus answer(Invocation i) => statuses[i.positionalArguments.first] ?? .denied; + when(mocks.permission.getStatus).thenAnswer((i) async => answer(i)); + when(mocks.permission.request).thenAnswer((i) async => answer(i)); + } + + setUp(() { + debugDefaultTargetPlatformOverride = .android; + mocks = .new(); + sut = .new(mocks.permission.repo); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + group('DevicePermissionService', () { + group('Gallery', () { + test('is denied when media location is missing but photos and videos are granted', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 34); + stubPermissions({.photos: .granted, .videos: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('is granted when media location is granted', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 34); + stubPermissions({.photos: .granted, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.granted); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('checks media location on the legacy storage path', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 30); + stubPermissions({.storage: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('ignores media location below SDK 29', () async { + when(mocks.permission.getAndroidSdkVersion).thenAnswer((_) async => 28); + stubPermissions({.storage: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.granted); + verifyNever(() => mocks.permission.repo.getStatus(.mediaLocation)); + }); + + test('ignores media location on iOS', () async { + debugDefaultTargetPlatformOverride = .iOS; + stubPermissions({.photos: .granted, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.granted); + verifyNever(() => mocks.permission.repo.getStatus(.mediaLocation)); + }); + + test('is limited when only one of photos and videos is limited', () async { + stubPermissions({.photos: .granted, .videos: .limited, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + + stubPermissions({.photos: .limited, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + + stubPermissions({.photos: .limited, .videos: .limited, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + }); + + test('is denied when only one of photos and videos is denied', () async { + stubPermissions({.photos: .granted, .videos: .denied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + + stubPermissions({.photos: .denied, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + + stubPermissions({.photos: .limited, .videos: .denied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + }); + + test('is permanently denied when only one of photos and videos is permanently denied', () async { + stubPermissions({.photos: .granted, .videos: .permanentlyDenied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied); + + stubPermissions({.photos: .permanentlyDenied, .videos: .denied, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied); + + stubPermissions({.photos: .permanentlyDenied, .videos: .granted, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.permanentlyDenied); + }); + + test('checks media location when access is only partial', () async { + stubPermissions({.photos: .limited, .videos: .limited, .mediaLocation: .denied}); + expect(await sut.galleryStatus(), DevicePermissionStatus.denied); + verify(() => mocks.permission.repo.getStatus(DevicePermission.mediaLocation)).called(1); + }); + + test('stays limited when media location is granted, but photos or video is limited', () async { + stubPermissions({.photos: .granted, .videos: .limited, .mediaLocation: .granted}); + expect(await sut.galleryStatus(), DevicePermissionStatus.limited); + }); + }); + + test('does not ask for videos when photos was refused', () async { + stubPermissions({.photos: .permanentlyDenied}); + + expect(await sut.requestGallery(), DevicePermissionStatus.permanentlyDenied); + verifyNever(() => mocks.permission.repo.request(.videos)); + }); + }); +} diff --git a/server/Dockerfile b/server/Dockerfile index df9d8ad7ba..802877421d 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -56,7 +56,7 @@ FROM builder AS plugins ARG TARGETPLATFORM -COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise WORKDIR /app COPY ./mise.toml ./mise.toml diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 178f4440df..23a565b4bb 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -2,7 +2,7 @@ FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev -COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 94047bf326..1d504f6c71 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -20,7 +20,8 @@ type TagsWithWrongTypes = | 'TagsList' | 'Keywords' | 'HierarchicalSubject' - | 'ISO'; + | 'ISO' + | 'LensModel'; export interface ImmichTags extends Omit { ContentIdentifier?: string; @@ -43,6 +44,9 @@ export interface ImmichTags extends Omit { Description?: StringOrNumber; ImageDescription?: StringOrNumber; + // Apparently LensModel can also be a float: https://github.com/immich-app/immich/issues/30492 + LensModel?: StringOrNumber; + // Extended properties for image regions, such as faces RegionInfo?: { AppliedToDimensions: { diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 2195c66a22..6ed8ce925a 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -117,7 +117,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ ViewRepository, WebsocketRepository, WorkflowRepository, -]; +] as const; @Injectable() export class BaseService { @@ -191,7 +191,7 @@ export class BaseService { ); } - static create(Service: ClassConstructor, ctx: BaseService) { + static create>(Service: T, ctx: BaseService) { const service = new Service( LoggingRepository.create(), ctx.accessRepository, @@ -242,6 +242,7 @@ export class BaseService { ctx.trashRepository, ctx.userRepository, ctx.versionRepository, + ctx.videoStreamRepository, ctx.viewRepository, ctx.websocketRepository, ctx.workflowRepository, @@ -249,7 +250,7 @@ export class BaseService { service.logger.setContext(BaseService.name); - return service as T; + return service as InstanceType; } get worker() { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 171dcfe514..37dd92e27d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -117,7 +117,9 @@ const validateRange = (value: number | undefined, min: number, max: number): Non }; const getLensModel = (exifTags: ImmichTags): string | null => { - const lensModel = (exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '').trim(); + const lensModel = String( + exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '', + ).trim(); if (lensModel === '----') { return null; } diff --git a/server/src/types.ts b/server/src/types.ts index 521aba6a05..7b51639404 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -32,6 +32,7 @@ import { UserMetadataKey, WorkflowType, } from 'src/enum'; +import { Mocked } from 'vitest'; export type DeepPartial = T extends Date ? T @@ -649,7 +650,10 @@ export type JSONSchemaProperty = { required?: string[]; }; -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -export interface ClassConstructor extends Function { - new (...args: any[]): T; -} +export type ClassConstructor = T extends new (...args: infer R) => infer L + ? new (...args: R) => L + : new (...args: any[]) => unknown; + +export type ClassConstructorsToInstances[]> = { + [K in keyof T]: InstanceType | Mocked>; +}; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index b335a0fba5..f0f14757b2 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -76,7 +76,7 @@ import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; -import { ClassConstructor, UploadFile } from 'src/types'; +import { ClassConstructor, ClassConstructorsToInstances, UploadFile } from 'src/types'; import { getConfig, updateConfig } from 'src/utils/config'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; @@ -88,29 +88,34 @@ import { Mocked } from 'vitest'; export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets'); type MediumTestOptions = { - mock: ClassConstructor[]; - real: ClassConstructor[]; + mock: Array<(typeof BASE_SERVICE_DEPENDENCIES)[number]>; + real: Array<(typeof BASE_SERVICE_DEPENDENCIES)[number]>; database: Kysely; }; -export const newMediumService = (Service: ClassConstructor, options: MediumTestOptions) => { +type BaseServiceDeps = typeof BASE_SERVICE_DEPENDENCIES; + +export const newMediumService = >( + Service: S, + options: MediumTestOptions, +) => { const ctx = new MediumTestContext(Service, options); return { sut: ctx.sut, ctx }; }; -export class MediumTestContext { +export class MediumTestContext = ClassConstructor> { private repoCache: Record = {}; - private sutDeps: any[]; + private sutDeps: ClassConstructorsToInstances; - sut: S; + sut: InstanceType; database: Kysely; constructor( - Service: ClassConstructor, + Service: S, private options: MediumTestOptions, ) { this.sutDeps = this.makeDeps(options); - this.sut = new Service(...this.sutDeps); + this.sut = new Service(...this.sutDeps) as InstanceType; this.database = options.database; } @@ -128,7 +133,7 @@ export class MediumTestContext { throw new Error(`Real repository ${dep.name} is not a valid dependency`); } } - return (deps as ClassConstructor[]).map((dep) => { + return deps.map((dep) => { if (options.real.includes(dep)) { return this.get(dep); } @@ -136,10 +141,10 @@ export class MediumTestContext { if (options.mock.includes(dep)) { return newMockRepository(dep); } - }); + }) as unknown as ClassConstructorsToInstances; } - get(key: ClassConstructor): T { + get(key: T): InstanceType { if (!Object.hasOwn(this.repoCache, key.name)) { const real = newRealRepository(key, this.options.database); this.repoCache[key.name] = real; @@ -148,8 +153,8 @@ export class MediumTestContext { return this.repoCache[key.name]; } - getMock>(key: ClassConstructor): R { - const index = BASE_SERVICE_DEPENDENCIES.indexOf(key as any); + getMock>>(key: T): R { + const index = BASE_SERVICE_DEPENDENCIES.indexOf(key); if (index === -1 || !this.options.mock.includes(key)) { throw new Error(`getMock called with a key that is not a mock: ${key.name}`); } @@ -328,7 +333,7 @@ export class MediumTestContext { } } -export class SyncTestContext extends MediumTestContext { +export class SyncTestContext extends MediumTestContext { constructor(database: Kysely) { super(SyncService, { database, @@ -379,7 +384,7 @@ const mockStats = { birthtimeMs: 0, }; -export class ExifTestContext extends MediumTestContext { +export class ExifTestContext extends MediumTestContext { constructor(database: Kysely) { super(MetadataService, { database, @@ -431,7 +436,7 @@ export class ExifTestContext extends MediumTestContext { } } -const newRealRepository = (key: ClassConstructor, db: Kysely): T => { +const newRealRepository = (key: T, db: Kysely): InstanceType => { switch (key) { case AccessRepository: case AlbumRepository: @@ -457,41 +462,41 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case UserRepository: case VersionHistoryRepository: case WorkflowRepository: { - return new key(db); + return new key(db) as InstanceType; } case ConfigRepository: case CryptoRepository: { - return new key(); + return new key() as InstanceType; } case DatabaseRepository: { - return new key(db, LoggingRepository.create(), new ConfigRepository()); + return new key(db, LoggingRepository.create(), new ConfigRepository()) as InstanceType; } case EmailRepository: { - return new key(LoggingRepository.create()); + return new key(LoggingRepository.create()) as InstanceType; } case MediaRepository: case MetadataRepository: { - return new key(LoggingRepository.create()); + return new key(LoggingRepository.create()) as InstanceType; } case PluginRepository: { - return new key(db, LoggingRepository.create()); + return new key(db, LoggingRepository.create()) as InstanceType; } case StorageRepository: { - return new key(LoggingRepository.create()); + return new key(LoggingRepository.create()) as InstanceType; } case TagRepository: { - return new key(db, LoggingRepository.create()); + return new key(db, LoggingRepository.create()) as InstanceType; } - case LoggingRepository as unknown as ClassConstructor: { - return new key() as unknown as T; + case LoggingRepository: { + return new key(undefined, undefined) as InstanceType; } default: { diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 6dc66e3ed5..37603520f7 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -152,4 +152,23 @@ describe(MetadataService.name, () => { ).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') }); }); }); + + it('should handle float lens models (#30492)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ LensModel: 1.8 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .where('assetId', '=', asset.id) + .select('lensModel') + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lensModel: '1.8' }); + }); }); diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index 8bd65af73f..dfb4868c0d 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -24,7 +24,7 @@ import { getKyselyDB } from 'test/utils'; let isInitialized = false; -class WorkflowTestContext extends MediumTestContext { +class WorkflowTestContext extends MediumTestContext { constructor(database: Kysely) { super(WorkflowExecutionService, { database, diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index 84d7630909..01a998bf5c 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@