Переглянути джерело

Merge branch 'main' of github.com:immich-app/immich into pr/lukehmcc/2913

Alex Tran 2 роки тому
батько
коміт
f60d5e3bef
42 змінених файлів з 1615 додано та 150 видалено
  1. 30 0
      .github/workflows/test.yml
  2. 1 0
      machine-learning/app/config.py
  3. 119 0
      machine-learning/app/conftest.py
  4. 16 19
      machine-learning/app/main.py
  5. 1 1
      machine-learning/app/models/__init__.py
  6. 27 18
      machine-learning/app/models/base.py
  7. 20 11
      machine-learning/app/models/cache.py
  8. 2 17
      machine-learning/app/models/clip.py
  9. 8 7
      machine-learning/app/models/facial_recognition.py
  10. 9 13
      machine-learning/app/models/image_classification.py
  11. 1 6
      machine-learning/app/schemas.py
  12. 183 0
      machine-learning/app/test_main.py
  13. 213 36
      machine-learning/poetry.lock
  14. 30 5
      machine-learning/pyproject.toml
  15. BIN
      mobile/fonts/WorkSans-Black.ttf
  16. BIN
      mobile/fonts/WorkSans-Bold.ttf
  17. BIN
      mobile/fonts/WorkSans-ExtraBold.ttf
  18. BIN
      mobile/fonts/WorkSans-Medium.ttf
  19. BIN
      mobile/fonts/WorkSans-SemiBold.ttf
  20. 6 2
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  21. 232 0
      mobile/lib/modules/backup/services/backup_verification.service.dart
  22. 1 0
      mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart
  23. 114 0
      mobile/lib/modules/backup/views/backup_controller_page.dart
  24. 2 0
      mobile/lib/modules/home/views/home_page.dart
  25. 40 0
      mobile/lib/modules/memories/models/memory.dart
  26. 10 0
      mobile/lib/modules/memories/providers/memory.provider.dart
  27. 50 0
      mobile/lib/modules/memories/services/memory.service.dart
  28. 121 0
      mobile/lib/modules/memories/ui/memory_card.dart
  29. 89 0
      mobile/lib/modules/memories/ui/memory_lane.dart
  30. 140 0
      mobile/lib/modules/memories/views/memory_page.dart
  31. 2 2
      mobile/lib/modules/search/ui/thumbnail_with_info.dart
  32. 2 2
      mobile/lib/modules/search/views/curated_object_page.dart
  33. 3 0
      mobile/lib/routing/router.dart
  34. 58 0
      mobile/lib/routing/router.gr.dart
  35. 5 0
      mobile/lib/routing/tab_navigation_observer.dart
  36. 5 3
      mobile/lib/shared/providers/asset.provider.dart
  37. 5 3
      mobile/lib/shared/ui/immich_image.dart
  38. 9 0
      mobile/lib/utils/capitalize.dart
  39. 32 0
      mobile/pubspec.lock
  40. 11 0
      mobile/pubspec.yaml
  41. 10 2
      web/src/lib/components/forms/api-key-secret.svelte
  42. 8 3
      web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte

+ 30 - 0
.github/workflows/test.yml

@@ -121,6 +121,36 @@ jobs:
         working-directory: ./mobile
         run: flutter test -j 1
 
+  ml-unit-tests:
+    name: Run ML unit tests and checks
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: ./machine-learning
+    steps:
+      - uses: actions/checkout@v3
+      - name: Install poetry
+        run: pipx install poetry
+      - uses: actions/setup-python@v4
+        with:
+          python-version: 3.11
+          cache: "poetry"
+      - name: Install dependencies
+        run: |
+          poetry install --with dev
+      - name: Lint with ruff
+        run: |
+          poetry run ruff check --format=github app
+      - name: Check black formatting
+        run: |
+          poetry run black --check app
+      - name: Run mypy type checking
+        run: |
+          poetry run mypy --install-types --non-interactive app/
+      - name: Run tests and coverage
+        run: |
+          poetry run pytest --cov app
+
   generated-api-up-to-date:
     name: Check generated files are up-to-date
     runs-on: ubuntu-latest

+ 1 - 0
machine-learning/app/config.py

@@ -18,6 +18,7 @@ class Settings(BaseSettings):
     port: int = 3003
     workers: int = 1
     min_face_score: float = 0.7
+    test_full: bool = False
 
     class Config(BaseSettings.Config):
         env_prefix = "MACHINE_LEARNING_"

+ 119 - 0
machine-learning/app/conftest.py

@@ -0,0 +1,119 @@
+from types import SimpleNamespace
+from typing import Any, Iterator, TypeAlias
+from unittest import mock
+
+import numpy as np
+import pytest
+from fastapi.testclient import TestClient
+from PIL import Image
+
+from .main import app, init_state
+
+ndarray: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
+
+
+@pytest.fixture
+def pil_image() -> Image.Image:
+    return Image.new("RGB", (600, 800))
+
+
+@pytest.fixture
+def cv_image(pil_image: Image.Image) -> ndarray:
+    return np.asarray(pil_image)[:, :, ::-1]  # PIL uses RGB while cv2 uses BGR
+
+
+@pytest.fixture
+def mock_classifier_pipeline() -> Iterator[mock.Mock]:
+    with mock.patch("app.models.image_classification.pipeline") as model:
+        classifier_preds = [
+            {"label": "that's an image alright", "score": 0.8},
+            {"label": "well it ends with .jpg", "score": 0.1},
+            {"label": "idk, im just seeing bytes", "score": 0.05},
+            {"label": "not sure", "score": 0.04},
+            {"label": "probably a virus", "score": 0.01},
+        ]
+
+        def forward(
+            inputs: Image.Image | list[Image.Image], **kwargs: Any
+        ) -> list[dict[str, Any]] | list[list[dict[str, Any]]]:
+            if isinstance(inputs, list) and not all([isinstance(img, Image.Image) for img in inputs]):
+                raise TypeError
+            elif not isinstance(inputs, Image.Image):
+                raise TypeError
+
+            if isinstance(inputs, list):
+                return [classifier_preds] * len(inputs)
+
+            return classifier_preds
+
+        model.return_value = forward
+        yield model
+
+
+@pytest.fixture
+def mock_st() -> Iterator[mock.Mock]:
+    with mock.patch("app.models.clip.SentenceTransformer") as model:
+        embedding = np.random.rand(512).astype(np.float32)
+
+        def encode(inputs: Image.Image | list[Image.Image], **kwargs: Any) -> ndarray | list[ndarray]:
+            #  mypy complains unless isinstance(inputs, list) is used explicitly
+            img_batch = isinstance(inputs, list) and all([isinstance(inst, Image.Image) for inst in inputs])
+            text_batch = isinstance(inputs, list) and all([isinstance(inst, str) for inst in inputs])
+            if isinstance(inputs, list) and not any([img_batch, text_batch]):
+                raise TypeError
+
+            if isinstance(inputs, list):
+                return np.stack([embedding] * len(inputs))
+
+            return embedding
+
+        mocked = mock.Mock()
+        mocked.encode = encode
+        model.return_value = mocked
+        yield model
+
+
+@pytest.fixture
+def mock_faceanalysis() -> Iterator[mock.Mock]:
+    with mock.patch("app.models.facial_recognition.FaceAnalysis") as model:
+        face_preds = [
+            SimpleNamespace(  # this is so these fields can be accessed through dot notation
+                **{
+                    "bbox": np.random.rand(4).astype(np.float32),
+                    "kps": np.random.rand(5, 2).astype(np.float32),
+                    "det_score": np.array([0.67]).astype(np.float32),
+                    "normed_embedding": np.random.rand(512).astype(np.float32),
+                }
+            ),
+            SimpleNamespace(
+                **{
+                    "bbox": np.random.rand(4).astype(np.float32),
+                    "kps": np.random.rand(5, 2).astype(np.float32),
+                    "det_score": np.array([0.4]).astype(np.float32),
+                    "normed_embedding": np.random.rand(512).astype(np.float32),
+                }
+            ),
+        ]
+
+        def get(image: np.ndarray[int, np.dtype[np.float32]], **kwargs: Any) -> list[SimpleNamespace]:
+            if not isinstance(image, np.ndarray):
+                raise TypeError
+
+            return face_preds
+
+        mocked = mock.Mock()
+        mocked.get = get
+        model.return_value = mocked
+        yield model
+
+
+@pytest.fixture
+def mock_get_model() -> Iterator[mock.Mock]:
+    with mock.patch("app.models.cache.InferenceModel.from_model_type", autospec=True) as mocked:
+        yield mocked
+
+
+@pytest.fixture(scope="session")
+def deployed_app() -> TestClient:
+    init_state()
+    return TestClient(app)

+ 16 - 19
machine-learning/app/main.py

@@ -24,16 +24,15 @@ from .schemas import (
 app = FastAPI()
 
 
-@app.on_event("startup")
-async def startup_event() -> None:
+def init_state() -> None:
     app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=True)
-    same_clip = settings.clip_image_model == settings.clip_text_model
-    app.state.clip_vision_type = ModelType.CLIP if same_clip else ModelType.CLIP_VISION
-    app.state.clip_text_type = ModelType.CLIP if same_clip else ModelType.CLIP_TEXT
+
+
+async def load_models() -> None:
     models = [
         (settings.classification_model, ModelType.IMAGE_CLASSIFICATION),
-        (settings.clip_image_model, app.state.clip_vision_type),
-        (settings.clip_text_model, app.state.clip_text_type),
+        (settings.clip_image_model, ModelType.CLIP),
+        (settings.clip_text_model, ModelType.CLIP),
         (settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION),
     ]
 
@@ -45,6 +44,12 @@ async def startup_event() -> None:
             InferenceModel.from_model_type(model_type, model_name)
 
 
+@app.on_event("startup")
+async def startup_event() -> None:
+    init_state()
+    await load_models()
+
+
 def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
     return Image.open(BytesIO(byte_image))
 
@@ -72,9 +77,7 @@ def ping() -> str:
 async def image_classification(
     image: Image.Image = Depends(dep_pil_image),
 ) -> list[str]:
-    model = await app.state.model_cache.get(
-        settings.classification_model, ModelType.IMAGE_CLASSIFICATION
-    )
+    model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
     labels = model.predict(image)
     return labels
 
@@ -87,9 +90,7 @@ async def image_classification(
 async def clip_encode_image(
     image: Image.Image = Depends(dep_pil_image),
 ) -> list[float]:
-    model = await app.state.model_cache.get(
-        settings.clip_image_model, app.state.clip_vision_type
-    )
+    model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP)
     embedding = model.predict(image)
     return embedding
 
@@ -100,9 +101,7 @@ async def clip_encode_image(
     status_code=200,
 )
 async def clip_encode_text(payload: TextModelRequest) -> list[float]:
-    model = await app.state.model_cache.get(
-        settings.clip_text_model, app.state.clip_text_type
-    )
+    model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP)
     embedding = model.predict(payload.text)
     return embedding
 
@@ -115,9 +114,7 @@ async def clip_encode_text(payload: TextModelRequest) -> list[float]:
 async def facial_recognition(
     image: cv2.Mat = Depends(dep_cv_image),
 ) -> list[dict[str, Any]]:
-    model = await app.state.model_cache.get(
-        settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION
-    )
+    model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
     faces = model.predict(image)
     return faces
 

+ 1 - 1
machine-learning/app/models/__init__.py

@@ -1,3 +1,3 @@
-from .clip import CLIPSTTextEncoder, CLIPSTVisionEncoder
+from .clip import CLIPSTEncoder
 from .facial_recognition import FaceRecognizer
 from .image_classification import ImageClassifier

+ 27 - 18
machine-learning/app/models/base.py

@@ -1,9 +1,12 @@
 from __future__ import annotations
 
-from abc import abstractmethod, ABC
+from abc import ABC, abstractmethod
 from pathlib import Path
+from shutil import rmtree
 from typing import Any
 
+from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf  # type: ignore
+
 from ..config import get_cache_dir
 from ..schemas import ModelType
 
@@ -11,17 +14,19 @@ from ..schemas import ModelType
 class InferenceModel(ABC):
     _model_type: ModelType
 
-    def __init__(
-        self,
-        model_name: str,
-        cache_dir: Path | None = None,
-    ):
+    def __init__(self, model_name: str, cache_dir: Path | str | None = None, **model_kwargs: Any) -> None:
         self.model_name = model_name
-        self._cache_dir = (
-            cache_dir
-            if cache_dir is not None
-            else get_cache_dir(model_name, self.model_type)
-        )
+        self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
+
+        try:
+            self.load(**model_kwargs)
+        except (OSError, InvalidProtobuf):
+            self.clear_cache()
+            self.load(**model_kwargs)
+
+    @abstractmethod
+    def load(self, **model_kwargs: Any) -> None:
+        ...
 
     @abstractmethod
     def predict(self, inputs: Any) -> Any:
@@ -36,17 +41,21 @@ class InferenceModel(ABC):
         return self._cache_dir
 
     @cache_dir.setter
-    def cache_dir(self, cache_dir: Path):
+    def cache_dir(self, cache_dir: Path) -> None:
         self._cache_dir = cache_dir
 
     @classmethod
-    def from_model_type(
-        cls, model_type: ModelType, model_name, **model_kwargs
-    ) -> InferenceModel:
-        subclasses = {
-            subclass._model_type: subclass for subclass in cls.__subclasses__()
-        }
+    def from_model_type(cls, model_type: ModelType, model_name: str, **model_kwargs: Any) -> InferenceModel:
+        subclasses = {subclass._model_type: subclass for subclass in cls.__subclasses__()}
         if model_type not in subclasses:
             raise ValueError(f"Unsupported model type: {model_type}")
 
         return subclasses[model_type](model_name, **model_kwargs)
+
+    def clear_cache(self) -> None:
+        if not self.cache_dir.exists():
+            return
+        elif not rmtree.avoids_symlink_attacks:
+            raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform.")
+
+        rmtree(self.cache_dir)

+ 20 - 11
machine-learning/app/models/cache.py

@@ -1,4 +1,5 @@
 import asyncio
+from typing import Any
 
 from aiocache.backends.memory import SimpleMemoryCache
 from aiocache.lock import OptimisticLock
@@ -34,13 +35,9 @@ class ModelCache:
         if profiling:
             plugins.append(TimingPlugin())
 
-        self.cache = SimpleMemoryCache(
-            ttl=ttl, timeout=timeout, plugins=plugins, namespace=None
-        )
+        self.cache = SimpleMemoryCache(ttl=ttl, timeout=timeout, plugins=plugins, namespace=None)
 
-    async def get(
-        self, model_name: str, model_type: ModelType, **model_kwargs
-    ) -> InferenceModel:
+    async def get(self, model_name: str, model_type: ModelType, **model_kwargs: Any) -> InferenceModel:
         """
         Args:
             model_name: Name of model in the model hub used for the task.
@@ -56,9 +53,7 @@ class ModelCache:
             async with OptimisticLock(self.cache, key) as lock:
                 model = await asyncio.get_running_loop().run_in_executor(
                     None,
-                    lambda: InferenceModel.from_model_type(
-                        model_type, model_name, **model_kwargs
-                    ),
+                    lambda: InferenceModel.from_model_type(model_type, model_name, **model_kwargs),
                 )
                 await lock.cas(model, ttl=self.ttl)
         return model
@@ -73,7 +68,14 @@ class ModelCache:
 class RevalidationPlugin(BasePlugin):
     """Revalidates cache item's TTL after cache hit."""
 
-    async def post_get(self, client, key, ret=None, namespace=None, **kwargs):
+    async def post_get(
+        self,
+        client: SimpleMemoryCache,
+        key: str,
+        ret: Any | None = None,
+        namespace: str | None = None,
+        **kwargs: Any,
+    ) -> None:
         if ret is None:
             return
         if namespace is not None:
@@ -81,7 +83,14 @@ class RevalidationPlugin(BasePlugin):
         if key in client._handlers:
             await client.expire(key, client.ttl)
 
-    async def post_multi_get(self, client, keys, ret=None, namespace=None, **kwargs):
+    async def post_multi_get(
+        self,
+        client: SimpleMemoryCache,
+        keys: list[str],
+        ret: list[Any] | None = None,
+        namespace: str | None = None,
+        **kwargs: Any,
+    ) -> None:
         if ret is None:
             return
 

+ 2 - 17
machine-learning/app/models/clip.py

@@ -1,4 +1,5 @@
 from pathlib import Path
+from typing import Any
 
 from PIL.Image import Image
 from sentence_transformers import SentenceTransformer
@@ -10,13 +11,7 @@ from .base import InferenceModel
 class CLIPSTEncoder(InferenceModel):
     _model_type = ModelType.CLIP
 
-    def __init__(
-        self,
-        model_name: str,
-        cache_dir: Path | None = None,
-        **model_kwargs,
-    ):
-        super().__init__(model_name, cache_dir)
+    def load(self, **model_kwargs: Any) -> None:
         self.model = SentenceTransformer(
             self.model_name,
             cache_folder=self.cache_dir.as_posix(),
@@ -25,13 +20,3 @@ class CLIPSTEncoder(InferenceModel):
 
     def predict(self, image_or_text: Image | str) -> list[float]:
         return self.model.encode(image_or_text).tolist()
-
-
-# stubs to allow different behavior between the two in the future
-# and handle loading different image and text clip models
-class CLIPSTVisionEncoder(CLIPSTEncoder):
-    _model_type = ModelType.CLIP_VISION
-
-
-class CLIPSTTextEncoder(CLIPSTEncoder):
-    _model_type = ModelType.CLIP_TEXT

+ 8 - 7
machine-learning/app/models/facial_recognition.py

@@ -16,23 +16,24 @@ class FaceRecognizer(InferenceModel):
         self,
         model_name: str,
         min_score: float = settings.min_face_score,
-        cache_dir: Path | None = None,
-        **model_kwargs,
-    ):
-        super().__init__(model_name, cache_dir)
+        cache_dir: Path | str | None = None,
+        **model_kwargs: Any,
+    ) -> None:
         self.min_score = min_score
-        model = FaceAnalysis(
+        super().__init__(model_name, cache_dir, **model_kwargs)
+
+    def load(self, **model_kwargs: Any) -> None:
+        self.model = FaceAnalysis(
             name=self.model_name,
             root=self.cache_dir.as_posix(),
             allowed_modules=["detection", "recognition"],
             **model_kwargs,
         )
-        model.prepare(
+        self.model.prepare(
             ctx_id=0,
             det_thresh=self.min_score,
             det_size=(640, 640),
         )
-        self.model = model
 
     def predict(self, image: cv2.Mat) -> list[dict[str, Any]]:
         height, width, _ = image.shape

+ 9 - 13
machine-learning/app/models/image_classification.py

@@ -1,4 +1,5 @@
 from pathlib import Path
+from typing import Any
 
 from PIL.Image import Image
 from transformers.pipelines import pipeline
@@ -15,12 +16,13 @@ class ImageClassifier(InferenceModel):
         self,
         model_name: str,
         min_score: float = settings.min_tag_score,
-        cache_dir: Path | None = None,
-        **model_kwargs,
-    ):
-        super().__init__(model_name, cache_dir)
+        cache_dir: Path | str | None = None,
+        **model_kwargs: Any,
+    ) -> None:
         self.min_score = min_score
+        super().__init__(model_name, cache_dir, **model_kwargs)
 
+    def load(self, **model_kwargs: Any) -> None:
         self.model = pipeline(
             self.model_type.value,
             self.model_name,
@@ -28,13 +30,7 @@ class ImageClassifier(InferenceModel):
         )
 
     def predict(self, image: Image) -> list[str]:
-        predictions = self.model(image)
-        tags = list(
-            {
-                tag
-                for pred in predictions
-                for tag in pred["label"].split(", ")
-                if pred["score"] >= self.min_score
-            }
-        )
+        predictions: list[dict[str, Any]] = self.model(image)  # type: ignore
+        tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
+
         return tags

+ 1 - 6
machine-learning/app/schemas.py

@@ -4,10 +4,7 @@ from pydantic import BaseModel
 
 
 def to_lower_camel(string: str) -> str:
-    tokens = [
-        token.capitalize() if i > 0 else token
-        for i, token in enumerate(string.split("_"))
-    ]
+    tokens = [token.capitalize() if i > 0 else token for i, token in enumerate(string.split("_"))]
     return "".join(tokens)
 
 
@@ -61,6 +58,4 @@ class FaceResponse(BaseModel):
 class ModelType(Enum):
     IMAGE_CLASSIFICATION = "image-classification"
     CLIP = "clip"
-    CLIP_VISION = "clip-vision"
-    CLIP_TEXT = "clip-text"
     FACIAL_RECOGNITION = "facial-recognition"

+ 183 - 0
machine-learning/app/test_main.py

@@ -0,0 +1,183 @@
+from io import BytesIO
+from pathlib import Path
+from unittest import mock
+
+import cv2
+import pytest
+from fastapi.testclient import TestClient
+from PIL import Image
+
+from .config import settings
+from .models.cache import ModelCache
+from .models.clip import CLIPSTEncoder
+from .models.facial_recognition import FaceRecognizer
+from .models.image_classification import ImageClassifier
+from .schemas import ModelType
+
+
+class TestImageClassifier:
+    def test_init(self, mock_classifier_pipeline: mock.Mock) -> None:
+        cache_dir = Path("test_cache")
+        classifier = ImageClassifier("test_model_name", 0.5, cache_dir=cache_dir)
+
+        assert classifier.min_score == 0.5
+        mock_classifier_pipeline.assert_called_once_with(
+            "image-classification",
+            "test_model_name",
+            model_kwargs={"cache_dir": cache_dir},
+        )
+
+    def test_min_score(self, pil_image: Image.Image, mock_classifier_pipeline: mock.Mock) -> None:
+        classifier = ImageClassifier("test_model_name", min_score=0.0)
+        classifier.min_score = 0.0
+        all_labels = classifier.predict(pil_image)
+        classifier.min_score = 0.5
+        filtered_labels = classifier.predict(pil_image)
+
+        assert all_labels == [
+            "that's an image alright",
+            "well it ends with .jpg",
+            "idk",
+            "im just seeing bytes",
+            "not sure",
+            "probably a virus",
+        ]
+        assert filtered_labels == ["that's an image alright"]
+
+
+class TestCLIP:
+    def test_init(self, mock_st: mock.Mock) -> None:
+        CLIPSTEncoder("test_model_name", cache_dir="test_cache")
+
+        mock_st.assert_called_once_with("test_model_name", cache_folder="test_cache")
+
+    def test_basic_image(self, pil_image: Image.Image, mock_st: mock.Mock) -> None:
+        clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
+        embedding = clip_encoder.predict(pil_image)
+
+        assert isinstance(embedding, list)
+        assert len(embedding) == 512
+        assert all([isinstance(num, float) for num in embedding])
+        mock_st.assert_called_once()
+
+    def test_basic_text(self, mock_st: mock.Mock) -> None:
+        clip_encoder = CLIPSTEncoder("test_model_name", cache_dir="test_cache")
+        embedding = clip_encoder.predict("test search query")
+
+        assert isinstance(embedding, list)
+        assert len(embedding) == 512
+        assert all([isinstance(num, float) for num in embedding])
+        mock_st.assert_called_once()
+
+
+class TestFaceRecognition:
+    def test_init(self, mock_faceanalysis: mock.Mock) -> None:
+        FaceRecognizer("test_model_name", cache_dir="test_cache")
+
+        mock_faceanalysis.assert_called_once_with(
+            name="test_model_name",
+            root="test_cache",
+            allowed_modules=["detection", "recognition"],
+        )
+
+    def test_basic(self, cv_image: cv2.Mat, mock_faceanalysis: mock.Mock) -> None:
+        face_recognizer = FaceRecognizer("test_model_name", min_score=0.0, cache_dir="test_cache")
+        faces = face_recognizer.predict(cv_image)
+
+        assert len(faces) == 2
+        for face in faces:
+            assert face["imageHeight"] == 800
+            assert face["imageWidth"] == 600
+            assert isinstance(face["embedding"], list)
+            assert len(face["embedding"]) == 512
+            assert all([isinstance(num, float) for num in face["embedding"]])
+
+        mock_faceanalysis.assert_called_once()
+
+
+@pytest.mark.asyncio
+class TestCache:
+    async def test_caches(self, mock_get_model: mock.Mock) -> None:
+        model_cache = ModelCache()
+        await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
+        await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
+        assert len(model_cache.cache._cache) == 1
+        mock_get_model.assert_called_once()
+
+    async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None:
+        model_cache = ModelCache()
+        await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION, cache_dir="test_cache")
+        mock_get_model.assert_called_once_with(
+            ModelType.IMAGE_CLASSIFICATION, "test_model_name", cache_dir="test_cache"
+        )
+
+    async def test_different_clip(self, mock_get_model: mock.Mock) -> None:
+        model_cache = ModelCache()
+        await model_cache.get("test_image_model_name", ModelType.CLIP)
+        await model_cache.get("test_text_model_name", ModelType.CLIP)
+        mock_get_model.assert_has_calls(
+            [
+                mock.call(ModelType.CLIP, "test_image_model_name"),
+                mock.call(ModelType.CLIP, "test_text_model_name"),
+            ]
+        )
+        assert len(model_cache.cache._cache) == 2
+
+    @mock.patch("app.models.cache.OptimisticLock", autospec=True)
+    async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
+        model_cache = ModelCache(ttl=100)
+        await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
+        mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
+
+    @mock.patch("app.models.cache.SimpleMemoryCache.expire")
+    async def test_revalidate(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
+        model_cache = ModelCache(ttl=100, revalidate=True)
+        await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
+        await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
+        mock_cache_expire.assert_called_once_with(mock.ANY, 100)
+
+
+@pytest.mark.skipif(
+    not settings.test_full,
+    reason="More time-consuming since it deploys the app and loads models.",
+)
+class TestEndpoints:
+    def test_tagging_endpoint(self, pil_image: Image.Image, deployed_app: TestClient) -> None:
+        byte_image = BytesIO()
+        pil_image.save(byte_image, format="jpeg")
+        headers = {"Content-Type": "image/jpg"}
+        response = deployed_app.post(
+            "http://localhost:3003/image-classifier/tag-image",
+            content=byte_image.getvalue(),
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    def test_clip_image_endpoint(self, pil_image: Image.Image, deployed_app: TestClient) -> None:
+        byte_image = BytesIO()
+        pil_image.save(byte_image, format="jpeg")
+        headers = {"Content-Type": "image/jpg"}
+        response = deployed_app.post(
+            "http://localhost:3003/sentence-transformer/encode-image",
+            content=byte_image.getvalue(),
+            headers=headers,
+        )
+        assert response.status_code == 200
+
+    def test_clip_text_endpoint(self, deployed_app: TestClient) -> None:
+        response = deployed_app.post(
+            "http://localhost:3003/sentence-transformer/encode-text",
+            json={"text": "test search query"},
+        )
+        assert response.status_code == 200
+
+    def test_face_endpoint(self, pil_image: Image.Image, deployed_app: TestClient) -> None:
+        byte_image = BytesIO()
+        pil_image.save(byte_image, format="jpeg")
+        headers = {"Content-Type": "image/jpg"}
+        response = deployed_app.post(
+            "http://localhost:3003/facial-recognition/detect-faces",
+            content=byte_image.getvalue(),
+            headers=headers,
+        )
+        assert response.status_code == 200

+ 213 - 36
machine-learning/poetry.lock

@@ -424,13 +424,13 @@ cron = ["capturer (>=2.4)"]
 
 [[package]]
 name = "configargparse"
-version = "1.5.3"
+version = "1.5.5"
 description = "A drop-in replacement for argparse that allows options to also be set via config files and/or environment variables."
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
-    {file = "ConfigArgParse-1.5.3-py3-none-any.whl", hash = "sha256:18f6535a2db9f6e02bd5626cc7455eac3e96b9ab3d969d366f9aafd5c5c00fe7"},
-    {file = "ConfigArgParse-1.5.3.tar.gz", hash = "sha256:1b0b3cbf664ab59dada57123c81eff3d9737e0d11d8cf79e3d6eb10823f1739f"},
+    {file = "ConfigArgParse-1.5.5-py3-none-any.whl", hash = "sha256:541360ddc1b15c517f95c0d02d1fca4591266628f3667acdc5d13dccc78884ca"},
+    {file = "ConfigArgParse-1.5.5.tar.gz", hash = "sha256:363d80a6d35614bd446e2f2b1b216f3b33741d03ac6d0a92803306f40e555b58"},
 ]
 
 [package.extras]
@@ -495,6 +495,78 @@ mypy = ["contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.2.0)", "types-Pill
 test = ["Pillow", "contourpy[test-no-images]", "matplotlib"]
 test-no-images = ["pytest", "pytest-cov", "wurlitzer"]
 
+[[package]]
+name = "coverage"
+version = "7.2.7"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"},
+    {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"},
+    {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"},
+    {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"},
+    {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"},
+    {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"},
+    {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"},
+    {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"},
+    {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"},
+    {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"},
+    {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"},
+    {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"},
+    {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"},
+    {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"},
+    {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"},
+    {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"},
+    {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"},
+    {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"},
+    {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"},
+    {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"},
+    {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"},
+    {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"},
+    {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"},
+    {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"},
+    {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"},
+    {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"},
+    {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"},
+    {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"},
+    {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"},
+    {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"},
+    {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"},
+    {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"},
+    {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"},
+    {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"},
+    {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"},
+    {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
 [[package]]
 name = "cycler"
 version = "0.11.0"
@@ -639,18 +711,17 @@ Flask = "*"
 
 [[package]]
 name = "flask-cors"
-version = "3.0.10"
+version = "4.0.0"
 description = "A Flask extension adding a decorator for CORS support"
 optional = false
 python-versions = "*"
 files = [
-    {file = "Flask-Cors-3.0.10.tar.gz", hash = "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de"},
-    {file = "Flask_Cors-3.0.10-py2.py3-none-any.whl", hash = "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438"},
+    {file = "Flask-Cors-4.0.0.tar.gz", hash = "sha256:f268522fcb2f73e2ecdde1ef45e2fd5c71cc48fe03cffb4b441c6d1b40684eb0"},
+    {file = "Flask_Cors-4.0.0-py2.py3-none-any.whl", hash = "sha256:bc3492bfd6368d27cfe79c7821df5a8a319e1a6d5eab277a3794be19bdc51783"},
 ]
 
 [package.dependencies]
 Flask = ">=0.9"
-Six = "*"
 
 [[package]]
 name = "flatbuffers"
@@ -1039,6 +1110,27 @@ files = [
     {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
 ]
 
+[[package]]
+name = "httpcore"
+version = "0.17.2"
+description = "A minimal low-level HTTP client."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "httpcore-0.17.2-py3-none-any.whl", hash = "sha256:5581b9c12379c4288fe70f43c710d16060c10080617001e6b22a3b6dbcbefd36"},
+    {file = "httpcore-0.17.2.tar.gz", hash = "sha256:125f8375ab60036db632f34f4b627a9ad085048eef7cb7d2616fea0f739f98af"},
+]
+
+[package.dependencies]
+anyio = ">=3.0,<5.0"
+certifi = "*"
+h11 = ">=0.13,<0.15"
+sniffio = "==1.*"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+
 [[package]]
 name = "httptools"
 version = "0.5.0"
@@ -1092,6 +1184,29 @@ files = [
 [package.extras]
 test = ["Cython (>=0.29.24,<0.30.0)"]
 
+[[package]]
+name = "httpx"
+version = "0.24.1"
+description = "The next generation HTTP client."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"},
+    {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"},
+]
+
+[package.dependencies]
+certifi = "*"
+httpcore = ">=0.15.0,<0.18.0"
+idna = "*"
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotli", "brotlicffi"]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+
 [[package]]
 name = "huggingface-hub"
 version = "0.15.1"
@@ -1584,42 +1699,42 @@ files = [
 
 [[package]]
 name = "mypy"
-version = "1.4.0"
+version = "1.4.1"
 description = "Optional static typing for Python"
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "mypy-1.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3af348e0925a59213244f28c7c0c3a2c2088b4ba2fe9d6c8d4fbb0aba0b7d05"},
-    {file = "mypy-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0b2e0da7ff9dd8d2066d093d35a169305fc4e38db378281fce096768a3dbdbf"},
-    {file = "mypy-1.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210fe0f39ec5be45dd9d0de253cb79245f0a6f27631d62e0c9c7988be7152965"},
-    {file = "mypy-1.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f7a5971490fd4a5a436e143105a1f78fa8b3fe95b30fff2a77542b4f3227a01f"},
-    {file = "mypy-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:50f65f0e9985f1e50040e603baebab83efed9eb37e15a22a4246fa7cd660f981"},
-    {file = "mypy-1.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1b5c875fcf3e7217a3de7f708166f641ca154b589664c44a6fd6d9f17d9e7e"},
-    {file = "mypy-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4c734d947e761c7ceb1f09a98359dd5666460acbc39f7d0a6b6beec373c5840"},
-    {file = "mypy-1.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5984a8d13d35624e3b235a793c814433d810acba9eeefe665cdfed3d08bc3af"},
-    {file = "mypy-1.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0f98973e39e4a98709546a9afd82e1ffcc50c6ec9ce6f7870f33ebbf0bd4f26d"},
-    {file = "mypy-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:19d42b08c7532d736a7e0fb29525855e355fa51fd6aef4f9bbc80749ff64b1a2"},
-    {file = "mypy-1.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6ba9a69172abaa73910643744d3848877d6aac4a20c41742027dcfd8d78f05d9"},
-    {file = "mypy-1.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a34eed094c16cad0f6b0d889811592c7a9b7acf10d10a7356349e325d8704b4f"},
-    {file = "mypy-1.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:53c2a1fed81e05ded10a4557fe12bae05b9ecf9153f162c662a71d924d504135"},
-    {file = "mypy-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bba57b4d2328740749f676807fcf3036e9de723530781405cc5a5e41fc6e20de"},
-    {file = "mypy-1.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:653863c75f0dbb687d92eb0d4bd9fe7047d096987ecac93bb7b1bc336de48ebd"},
-    {file = "mypy-1.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7461469e163f87a087a5e7aa224102a30f037c11a096a0ceeb721cb0dce274c8"},
-    {file = "mypy-1.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cf0ca95e4b8adeaf07815a78b4096b65adf64ea7871b39a2116c19497fcd0dd"},
-    {file = "mypy-1.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:94a81b9354545123feb1a99b960faeff9e1fa204fce47e0042335b473d71530d"},
-    {file = "mypy-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:67242d5b28ed0fa88edd8f880aed24da481929467fdbca6487167cb5e3fd31ff"},
-    {file = "mypy-1.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3f2b353eebef669529d9bd5ae3566905a685ae98b3af3aad7476d0d519714758"},
-    {file = "mypy-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62bf18d97c6b089f77f0067b4e321db089d8520cdeefc6ae3ec0f873621c22e5"},
-    {file = "mypy-1.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca33ab70a4aaa75bb01086a0b04f0ba8441e51e06fc57e28585176b08cad533b"},
-    {file = "mypy-1.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5a0ee54c2cb0f957f8a6f41794d68f1a7e32b9968675ade5846f538504856d42"},
-    {file = "mypy-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:6c34d43e3d54ad05024576aef28081d9d0580f6fa7f131255f54020eb12f5352"},
-    {file = "mypy-1.4.0-py3-none-any.whl", hash = "sha256:f051ca656be0c179c735a4c3193f307d34c92fdc4908d44fd4516fbe8b10567d"},
-    {file = "mypy-1.4.0.tar.gz", hash = "sha256:de1e7e68148a213036276d1f5303b3836ad9a774188961eb2684eddff593b042"},
+    {file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"},
+    {file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"},
+    {file = "mypy-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd"},
+    {file = "mypy-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc"},
+    {file = "mypy-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1"},
+    {file = "mypy-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462"},
+    {file = "mypy-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258"},
+    {file = "mypy-1.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2"},
+    {file = "mypy-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7"},
+    {file = "mypy-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01"},
+    {file = "mypy-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b"},
+    {file = "mypy-1.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b"},
+    {file = "mypy-1.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7"},
+    {file = "mypy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9"},
+    {file = "mypy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042"},
+    {file = "mypy-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3"},
+    {file = "mypy-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6"},
+    {file = "mypy-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f"},
+    {file = "mypy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc"},
+    {file = "mypy-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828"},
+    {file = "mypy-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3"},
+    {file = "mypy-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816"},
+    {file = "mypy-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c"},
+    {file = "mypy-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f"},
+    {file = "mypy-1.4.1-py3-none-any.whl", hash = "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4"},
+    {file = "mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b"},
 ]
 
 [package.dependencies]
 mypy-extensions = ">=1.0.0"
-typing-extensions = ">=3.10"
+typing-extensions = ">=4.1.0"
 
 [package.extras]
 dmypy = ["psutil (>=4.0)"]
@@ -2133,6 +2248,42 @@ pluggy = ">=0.12,<2.0"
 [package.extras]
 testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
 
+[[package]]
+name = "pytest-asyncio"
+version = "0.21.0"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-asyncio-0.21.0.tar.gz", hash = "sha256:2b38a496aef56f56b0e87557ec313e11e1ab9276fc3863f6a7be0f1d0e415e1b"},
+    {file = "pytest_asyncio-0.21.0-py3-none-any.whl", hash = "sha256:f2b3366b7cd501a4056858bd39349d5af19742aed2d81660b7998b6341c7eb9c"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0"
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
+testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
+
+[[package]]
+name = "pytest-cov"
+version = "4.1.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"},
+    {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"},
+]
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
+
 [[package]]
 name = "python-dateutil"
 version = "2.8.2"
@@ -2504,6 +2655,32 @@ files = [
     {file = "roundrobin-0.0.4.tar.gz", hash = "sha256:7e9d19a5bd6123d99993fb935fa86d25c88bb2096e493885f61737ed0f5e9abd"},
 ]
 
+[[package]]
+name = "ruff"
+version = "0.0.272"
+description = "An extremely fast Python linter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "ruff-0.0.272-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:ae9b57546e118660175d45d264b87e9b4c19405c75b587b6e4d21e6a17bf4fdf"},
+    {file = "ruff-0.0.272-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:1609b864a8d7ee75a8c07578bdea0a7db75a144404e75ef3162e0042bfdc100d"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee76b4f05fcfff37bd6ac209d1370520d509ea70b5a637bdf0a04d0c99e13dff"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48eccf225615e106341a641f826b15224b8a4240b84269ead62f0afd6d7e2d95"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:677284430ac539bb23421a2b431b4ebc588097ef3ef918d0e0a8d8ed31fea216"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9c4bfb75456a8e1efe14c52fcefb89cfb8f2a0d31ed8d804b82c6cf2dc29c42c"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86bc788245361a8148ff98667da938a01e1606b28a45e50ac977b09d3ad2c538"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27b2ea68d2aa69fff1b20b67636b1e3e22a6a39e476c880da1282c3e4bf6ee5a"},
+    {file = "ruff-0.0.272-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd2bbe337a3f84958f796c77820d55ac2db1e6753f39d1d1baed44e07f13f96d"},
+    {file = "ruff-0.0.272-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d5a208f8ef0e51d4746930589f54f9f92f84bb69a7d15b1de34ce80a7681bc00"},
+    {file = "ruff-0.0.272-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:905ff8f3d6206ad56fcd70674453527b9011c8b0dc73ead27618426feff6908e"},
+    {file = "ruff-0.0.272-py3-none-musllinux_1_2_i686.whl", hash = "sha256:19643d448f76b1eb8a764719072e9c885968971bfba872e14e7257e08bc2f2b7"},
+    {file = "ruff-0.0.272-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:691d72a00a99707a4e0b2846690961157aef7b17b6b884f6b4420a9f25cd39b5"},
+    {file = "ruff-0.0.272-py3-none-win32.whl", hash = "sha256:dc406e5d756d932da95f3af082814d2467943631a587339ee65e5a4f4fbe83eb"},
+    {file = "ruff-0.0.272-py3-none-win_amd64.whl", hash = "sha256:a37ec80e238ead2969b746d7d1b6b0d31aa799498e9ba4281ab505b93e1f4b28"},
+    {file = "ruff-0.0.272-py3-none-win_arm64.whl", hash = "sha256:06b8ee4eb8711ab119db51028dd9f5384b44728c23586424fd6e241a5b9c4a3b"},
+    {file = "ruff-0.0.272.tar.gz", hash = "sha256:273a01dc8c3c4fd4c2af7ea7a67c8d39bb09bce466e640dd170034da75d14cab"},
+]
+
 [[package]]
 name = "safetensors"
 version = "0.3.1"
@@ -3425,4 +3602,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.11"
-content-hash = "2981003c319d9990f05abec1e3d02dc1ea6680b0bf1590376c5e47801311d89f"
+content-hash = "e0ac37404f0c11ee5b478d2c7113986a2d40d02e2b985ff18846374a65025a26"

+ 30 - 5
machine-learning/pyproject.toml

@@ -22,6 +22,8 @@ fastapi = "^0.95.2"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 pydantic = "^1.10.8"
 aiocache = "^0.12.1"
+pytest-cov = "^4.1.0"
+ruff = "^0.0.272"
 
 [tool.poetry.group.dev.dependencies]
 mypy = "^1.3.0"
@@ -29,6 +31,8 @@ black = "^23.3.0"
 pytest = "^7.3.1"
 locust = "^2.15.1"
 gunicorn = "^20.1.0"
+httpx = "^0.24.1"
+pytest-asyncio = "^0.21.0"
 
 [[tool.poetry.source]]
 name = "pytorch-cpu"
@@ -39,9 +43,6 @@ priority = "explicit"
 requires = ["poetry-core"]
 build-backend = "poetry.core.masonry.api"
 
-[tool.flake8]
-max-line-length = 120
-
 [tool.mypy]
 python_version = "3.11"
 plugins = "pydantic.mypy"
@@ -49,11 +50,35 @@ follow_imports = "silent"
 warn_redundant_casts = true
 disallow_any_generics = true
 check_untyped_defs = true
-no_implicit_reexport = true
 disallow_untyped_defs = true
 
 [tool.pydantic-mypy]
 init_forbid_extra = true
 init_typed = true
 warn_required_dynamic_aliases = true
-warn_untyped_fields = true
+warn_untyped_fields = true
+
+[[tool.mypy.overrides]]
+module = [
+    "transformers.pipelines",
+    "cv2",
+    "insightface.app",
+    "sentence_transformers",
+    "aiocache.backends.memory",
+    "aiocache.lock",
+    "aiocache.plugins"
+]
+ignore_missing_imports = true
+
+[tool.ruff]
+line-length = 120
+target-version = "py311"
+select = ["E", "F", "I"]
+ignore = ["F401"]
+
+[tool.ruff.per-file-ignores]
+"test_main.py" = ["F403"]
+
+[tool.black]
+line-length = 120
+target-version = ['py311']

BIN
mobile/fonts/WorkSans-Black.ttf


BIN
mobile/fonts/WorkSans-Bold.ttf


BIN
mobile/fonts/WorkSans-ExtraBold.ttf


BIN
mobile/fonts/WorkSans-Medium.ttf


BIN
mobile/fonts/WorkSans-SemiBold.ttf


+ 6 - 2
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -73,8 +73,12 @@ class VideoViewerPage extends HookConsumerWidget {
           placeholder: placeholder,
         ),
         if (downloadAssetStatus == DownloadAssetStatus.loading)
-          const Center(
-            child: ImmichLoadingIndicator(),
+          SizedBox(
+            height: MediaQuery.of(context).size.height,
+            width: MediaQuery.of(context).size.width,
+            child: const Center(
+              child: ImmichLoadingIndicator(),
+            ),
           ),
       ],
     );

+ 232 - 0
mobile/lib/modules/backup/services/backup_verification.service.dart

@@ -0,0 +1,232 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:immich_mobile/utils/diff.dart';
+import 'package:isar/isar.dart';
+import 'package:photo_manager/photo_manager.dart' show PhotoManager;
+
+/// Finds duplicates originating from missing EXIF information
+class BackupVerificationService {
+  final Isar _db;
+
+  BackupVerificationService(this._db);
+
+  /// Returns at most [limit] assets that were backed up without exif
+  Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
+    final owner = Store.get(StoreKey.currentUser).isarId;
+    final List<Asset> onlyLocal = await _db.assets
+        .where()
+        .remoteIdIsNull()
+        .filter()
+        .ownerIdEqualTo(owner)
+        .localIdIsNotNull()
+        .findAll();
+    final List<Asset> remoteMatches = await _getMatches(
+      _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(),
+      owner,
+      onlyLocal,
+      limit,
+    );
+    final List<Asset> localMatches = await _getMatches(
+      _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(),
+      owner,
+      remoteMatches,
+      limit,
+    );
+
+    final List<Asset> deleteCandidates = [], originals = [];
+
+    await diffSortedLists(
+      remoteMatches,
+      localMatches,
+      compare: (a, b) => a.fileName.compareTo(b.fileName),
+      both: (a, b) async {
+        a.exifInfo = await _db.exifInfos.get(a.id);
+        deleteCandidates.add(a);
+        originals.add(b);
+        return false;
+      },
+      onlyFirst: (a) {},
+      onlySecond: (b) {},
+    );
+    final isolateToken = ServicesBinding.rootIsolateToken!;
+    final List<Asset> toDelete;
+    if (deleteCandidates.length > 10) {
+      // performs 2 checks in parallel for a nice speedup
+      final half = deleteCandidates.length ~/ 2;
+      final lower = compute(
+        _computeSaveToDelete,
+        (
+          deleteCandidates: deleteCandidates.slice(0, half),
+          originals: originals.slice(0, half),
+          auth: Store.get(StoreKey.accessToken),
+          endpoint: Store.get(StoreKey.serverEndpoint),
+          rootIsolateToken: isolateToken,
+        ),
+      );
+      final upper = compute(
+        _computeSaveToDelete,
+        (
+          deleteCandidates: deleteCandidates.slice(half),
+          originals: originals.slice(half),
+          auth: Store.get(StoreKey.accessToken),
+          endpoint: Store.get(StoreKey.serverEndpoint),
+          rootIsolateToken: isolateToken,
+        ),
+      );
+      toDelete = await lower + await upper;
+    } else {
+      toDelete = await compute(
+        _computeSaveToDelete,
+        (
+          deleteCandidates: deleteCandidates,
+          originals: originals,
+          auth: Store.get(StoreKey.accessToken),
+          endpoint: Store.get(StoreKey.serverEndpoint),
+          rootIsolateToken: isolateToken,
+        ),
+      );
+    }
+    return toDelete;
+  }
+
+  static Future<List<Asset>> _computeSaveToDelete(
+    ({
+      List<Asset> deleteCandidates,
+      List<Asset> originals,
+      String auth,
+      String endpoint,
+      RootIsolateToken rootIsolateToken,
+    }) tuple,
+  ) async {
+    assert(tuple.deleteCandidates.length == tuple.originals.length);
+    final List<Asset> result = [];
+    BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
+    await PhotoManager.setIgnorePermissionCheck(true);
+    final ApiService apiService = ApiService();
+    apiService.setEndpoint(tuple.endpoint);
+    apiService.setAccessToken(tuple.auth);
+    for (int i = 0; i < tuple.deleteCandidates.length; i++) {
+      if (await _compareAssets(
+        tuple.deleteCandidates[i],
+        tuple.originals[i],
+        apiService,
+      )) {
+        result.add(tuple.deleteCandidates[i]);
+      }
+    }
+    return result;
+  }
+
+  static Future<bool> _compareAssets(
+    Asset remote,
+    Asset local,
+    ApiService apiService,
+  ) async {
+    if (remote.checksum == local.checksum) return false;
+    ExifInfo? exif = remote.exifInfo;
+    if (exif != null && exif.lat != null) return false;
+    if (exif == null || exif.fileSize == null) {
+      final dto = await apiService.assetApi.getAssetById(remote.remoteId!);
+      if (dto != null && dto.exifInfo != null) {
+        exif = ExifInfo.fromDto(dto.exifInfo!);
+      }
+    }
+    final file = await local.local!.originFile;
+    if (exif != null && file != null && exif.fileSize != null) {
+      final origSize = await file.length();
+      if (exif.fileSize! == origSize || exif.fileSize! != origSize) {
+        final latLng = await local.local!.latlngAsync();
+
+        if (exif.lat == null &&
+            latLng.latitude != null &&
+            (remote.fileCreatedAt.isAtSameMomentAs(local.fileCreatedAt) ||
+                remote.fileModifiedAt.isAtSameMomentAs(local.fileModifiedAt) ||
+                _sameExceptTimeZone(
+                  remote.fileCreatedAt,
+                  local.fileCreatedAt,
+                ))) {
+          if (remote.type == AssetType.video) {
+            // it's very unlikely that a video of same length, filesize, name
+            // and date is wrong match. Cannot easily compare videos anyway
+            return true;
+          }
+
+          // for images: make sure they are pixel-wise identical
+          // (skip first few KBs containing metadata)
+          final Uint64List localImage =
+              _fakeDecodeImg(local, await file.readAsBytes());
+          final res = await apiService.assetApi
+              .downloadFileWithHttpInfo(remote.remoteId!);
+          final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes);
+
+          final eq = const ListEquality().equals(remoteImage, localImage);
+          return eq;
+        }
+      }
+    }
+
+    return false;
+  }
+
+  static Uint64List _fakeDecodeImg(Asset asset, Uint8List bytes) {
+    const headerLength = 131072; // assume header is at most 128 KB
+    final start = bytes.length < headerLength * 2
+        ? (bytes.length ~/ (4 * 8)) * 8
+        : headerLength;
+    return bytes.buffer.asUint64List(start);
+  }
+
+  static Future<List<Asset>> _getMatches(
+    QueryBuilder<Asset, Asset, QAfterFilterCondition> query,
+    int ownerId,
+    List<Asset> assets,
+    int limit,
+  ) =>
+      query
+          .ownerIdEqualTo(ownerId)
+          .anyOf(
+            assets,
+            (q, Asset a) => q
+                .fileNameEqualTo(a.fileName)
+                .and()
+                .durationInSecondsEqualTo(a.durationInSeconds)
+                .and()
+                .fileCreatedAtBetween(
+                  a.fileCreatedAt.subtract(const Duration(hours: 12)),
+                  a.fileCreatedAt.add(const Duration(hours: 12)),
+                )
+                .and()
+                .not()
+                .checksumEqualTo(a.checksum),
+          )
+          .sortByFileName()
+          .thenByFileCreatedAt()
+          .thenByFileModifiedAt()
+          .limit(limit)
+          .findAll();
+
+  static bool _sameExceptTimeZone(DateTime a, DateTime b) {
+    final ms = a.isAfter(b)
+        ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch
+        : b.millisecondsSinceEpoch - a.microsecondsSinceEpoch;
+    final x = ms / (1000 * 60 * 30);
+    final y = ms ~/ (1000 * 60 * 30);
+    return y.toDouble() == x && y < 24;
+  }
+}
+
+final backupVerificationServiceProvider = Provider(
+  (ref) => BackupVerificationService(
+    ref.watch(dbProvider),
+  ),
+);

+ 1 - 0
mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart

@@ -138,6 +138,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
     return FutureBuilder<Uint8List?>(
       future: buildAssetThumbnail(),
       builder: (context, thumbnail) => ListTile(
+        isThreeLine: true,
         leading: AnimatedCrossFade(
           alignment: Alignment.centerLeft,
           firstChild: GestureDetector(

+ 114 - 0
mobile/lib/modules/backup/views/backup_controller_page.dart

@@ -1,6 +1,7 @@
 import 'dart:io';
 
 import 'package:auto_route/auto_route.dart';
+import 'package:connectivity_plus/connectivity_plus.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
@@ -8,15 +9,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
 import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
 import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
+import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
 import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart';
 import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
 import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
+import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
+import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
+import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:permission_handler/permission_handler.dart';
 import 'package:url_launcher/url_launcher.dart';
+import 'package:wakelock/wakelock.dart';
 
 class BackupControllerPage extends HookConsumerWidget {
   const BackupControllerPage({Key? key}) : super(key: key);
@@ -25,6 +34,9 @@ class BackupControllerPage extends HookConsumerWidget {
   Widget build(BuildContext context, WidgetRef ref) {
     BackUpState backupState = ref.watch(backupProvider);
     final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
+    final settingsService = ref.watch(appSettingsServiceProvider);
+    final showBackupFix = Platform.isAndroid &&
+        settingsService.getSetting(AppSettingsEnum.advancedTroubleshooting);
 
     final appRefreshDisabled =
         Platform.isIOS && settings?.appRefreshEnabled != true;
@@ -37,6 +49,7 @@ class BackupControllerPage extends HookConsumerWidget {
         ? false
         : true;
     var isDarkMode = Theme.of(context).brightness == Brightness.dark;
+    final checkInProgress = useState(false);
 
     useEffect(
       () {
@@ -59,6 +72,104 @@ class BackupControllerPage extends HookConsumerWidget {
       [],
     );
 
+    Future<void> performDeletion(List<Asset> assets) async {
+      try {
+        checkInProgress.value = true;
+        ImmichToast.show(
+          context: context,
+          msg: "Deleting ${assets.length} assets on the server...",
+        );
+        await ref.read(assetProvider.notifier).deleteAssets(assets);
+        ImmichToast.show(
+          context: context,
+          msg: "Deleted ${assets.length} assets on the server. "
+              "You can now start a manual backup",
+          toastType: ToastType.success,
+        );
+      } finally {
+        checkInProgress.value = false;
+      }
+    }
+
+    void performBackupCheck() async {
+      try {
+        checkInProgress.value = true;
+        if (backupState.allUniqueAssets.length >
+            backupState.selectedAlbumsBackupAssetsIds.length) {
+          ImmichToast.show(
+            context: context,
+            msg: "Backup all assets before starting this check!",
+            toastType: ToastType.error,
+          );
+          return;
+        }
+        final connection = await Connectivity().checkConnectivity();
+        if (connection != ConnectivityResult.wifi) {
+          ImmichToast.show(
+            context: context,
+            msg: "Make sure to be connected to unmetered Wi-Fi",
+            toastType: ToastType.error,
+          );
+          return;
+        }
+        Wakelock.enable();
+        const limit = 100;
+        final toDelete = await ref
+            .read(backupVerificationServiceProvider)
+            .findWronglyBackedUpAssets(limit: limit);
+        if (toDelete.isEmpty) {
+          ImmichToast.show(
+            context: context,
+            msg: "Did not find any corrupt asset backups!",
+            toastType: ToastType.success,
+          );
+        } else {
+          await showDialog(
+            context: context,
+            builder: (context) => ConfirmDialog(
+              onOk: () => performDeletion(toDelete),
+              title: "Corrupt backups!",
+              ok: "Delete",
+              content:
+                  "Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
+                  "Run the check again to find more.\n"
+                  "Do you want to delete the corrupt asset backups now?",
+            ),
+          );
+        }
+      } finally {
+        Wakelock.disable();
+        checkInProgress.value = false;
+      }
+    }
+
+    Widget buildCheckCorruptBackups() {
+      return ListTile(
+        leading: Icon(
+          Icons.warning_rounded,
+          color: Theme.of(context).primaryColor,
+        ),
+        title: const Text(
+          "Check for corrupt asset backups",
+          style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
+        ),
+        isThreeLine: true,
+        subtitle: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            const Text("Run this check only over Wi-Fi and once all assets "
+                "have been backed-up. The procedure might take a few minutes."),
+            ElevatedButton(
+              onPressed: checkInProgress.value ? null : performBackupCheck,
+              child: checkInProgress.value
+                  ? const CircularProgressIndicator()
+                  : const Text("Perform check"),
+            ),
+          ],
+        ),
+      );
+    }
+
     Widget buildStorageInformation() {
       return ListTile(
         leading: Icon(
@@ -69,6 +180,7 @@ class BackupControllerPage extends HookConsumerWidget {
           "backup_controller_page_server_storage",
           style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
         ).tr(),
+        isThreeLine: true,
         subtitle: Padding(
           padding: const EdgeInsets.only(top: 8.0),
           child: Column(
@@ -648,6 +760,8 @@ class BackupControllerPage extends HookConsumerWidget {
                       : buildBackgroundBackupController())
                   : buildBackgroundBackupController(),
             ),
+            if (showBackupFix) const Divider(),
+            if (showBackupFix) buildCheckCorruptBackups(),
             const Divider(),
             buildStorageInformation(),
             const Divider(),

+ 2 - 0
mobile/lib/modules/home/views/home_page.dart

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
 import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart';
+import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
 import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
@@ -310,6 +311,7 @@ class HomePage extends HookConsumerWidget {
                           listener: selectionListener,
                           selectionActive: selectionEnabledHook.value,
                           onRefresh: refreshAssets,
+                          topWidget: const MemoryLane(),
                         ),
                   error: (error, _) => Center(child: Text(error.toString())),
                   loading: buildLoadingIndicator,

+ 40 - 0
mobile/lib/modules/memories/models/memory.dart

@@ -0,0 +1,40 @@
+// ignore_for_file: public_member_api_docs, sort_constructors_first
+
+import 'package:collection/collection.dart';
+
+import 'package:immich_mobile/shared/models/asset.dart';
+
+class Memory {
+  final String title;
+  final List<Asset> assets;
+  Memory({
+    required this.title,
+    required this.assets,
+  });
+
+  Memory copyWith({
+    String? title,
+    List<Asset>? assets,
+  }) {
+    return Memory(
+      title: title ?? this.title,
+      assets: assets ?? this.assets,
+    );
+  }
+
+  @override
+  String toString() => 'Memory(title: $title, assets: $assets)';
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) return true;
+    final listEquals = const DeepCollectionEquality().equals;
+
+    return other is Memory &&
+        other.title == title &&
+        listEquals(other.assets, assets);
+  }
+
+  @override
+  int get hashCode => title.hashCode ^ assets.hashCode;
+}

+ 10 - 0
mobile/lib/modules/memories/providers/memory.provider.dart

@@ -0,0 +1,10 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/modules/memories/services/memory.service.dart';
+
+final memoryFutureProvider =
+    FutureProvider.autoDispose<List<Memory>?>((ref) async {
+  final service = ref.watch(memoryServiceProvider);
+
+  return await service.getMemoryLane();
+});

+ 50 - 0
mobile/lib/modules/memories/services/memory.service.dart

@@ -0,0 +1,50 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:logging/logging.dart';
+import 'package:openapi/api.dart';
+
+final memoryServiceProvider = StateProvider<MemoryService>((ref) {
+  return MemoryService(
+    ref.watch(apiServiceProvider),
+  );
+});
+
+class MemoryService {
+  final log = Logger("MemoryService");
+
+  final ApiService _apiService;
+
+  MemoryService(this._apiService);
+
+  Future<List<Memory>?> getMemoryLane() async {
+    try {
+      final now = DateTime.now();
+      final beginningOfDate = DateTime(now.year, now.month, now.day);
+      final data = await _apiService.assetApi.getMemoryLane(
+        beginningOfDate,
+      );
+
+      if (data == null) {
+        return null;
+      }
+
+      List<Memory> memories = [];
+      for (final MemoryLaneResponseDto(:title, :assets) in data) {
+        memories.add(
+          Memory(
+            title: title,
+            assets: assets.map((a) => Asset.remote(a)).toList(),
+          ),
+        );
+      }
+
+      return memories.isNotEmpty ? memories : null;
+    } catch (error, stack) {
+      log.severe("Cannot get memories ${error.toString()}", error, stack);
+      return null;
+    }
+  }
+}

+ 121 - 0
mobile/lib/modules/memories/ui/memory_card.dart

@@ -0,0 +1,121 @@
+import 'dart:ui';
+
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+import 'package:openapi/api.dart';
+
+class MemoryCard extends HookConsumerWidget {
+  final Asset asset;
+  final void Function() onTap;
+  final void Function() onClose;
+  final String title;
+  final String? rightCornerText;
+  final bool showTitle;
+
+  const MemoryCard({
+    required this.asset,
+    required this.onTap,
+    required this.onClose,
+    required this.title,
+    required this.showTitle,
+    this.rightCornerText,
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
+
+    buildTitle() {
+      return Text(
+        title,
+        style: const TextStyle(
+          color: Colors.white,
+          fontWeight: FontWeight.bold,
+          fontSize: 24.0,
+        ),
+      );
+    }
+
+    return Card(
+      color: Colors.black,
+      shape: RoundedRectangleBorder(
+        borderRadius: BorderRadius.circular(25.0),
+        side: const BorderSide(
+          color: Colors.black,
+          width: 1.0,
+        ),
+      ),
+      clipBehavior: Clip.hardEdge,
+      child: Stack(
+        children: [
+          Container(
+            decoration: BoxDecoration(
+              image: DecorationImage(
+                image: CachedNetworkImageProvider(
+                  getThumbnailUrl(
+                    asset,
+                  ),
+                  cacheKey: getThumbnailCacheKey(
+                    asset,
+                  ),
+                  headers: {"Authorization": authToken},
+                ),
+                fit: BoxFit.cover,
+              ),
+            ),
+            child: BackdropFilter(
+              filter: ImageFilter.blur(sigmaX: 60, sigmaY: 60),
+              child: Container(
+                decoration:
+                    BoxDecoration(color: Colors.black.withOpacity(0.25)),
+              ),
+            ),
+          ),
+          GestureDetector(
+            onTap: onTap,
+            child: ImmichImage(
+              asset,
+              fit: BoxFit.fitWidth,
+              height: double.infinity,
+              width: double.infinity,
+              type: ThumbnailFormat.JPEG,
+            ),
+          ),
+          Positioned(
+            top: 2.0,
+            left: 2.0,
+            child: IconButton(
+              onPressed: onClose,
+              icon: const Icon(Icons.close_rounded),
+              color: Colors.grey[400],
+            ),
+          ),
+          Positioned(
+            right: 18.0,
+            top: 18.0,
+            child: Text(
+              rightCornerText ?? "",
+              style: TextStyle(
+                color: Colors.grey[200],
+                fontSize: 12.0,
+                fontWeight: FontWeight.bold,
+              ),
+            ),
+          ),
+          if (showTitle)
+            Positioned(
+              left: 18.0,
+              bottom: 18.0,
+              child: buildTitle(),
+            )
+        ],
+      ),
+    );
+  }
+}

+ 89 - 0
mobile/lib/modules/memories/ui/memory_lane.dart

@@ -0,0 +1,89 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
+
+class MemoryLane extends HookConsumerWidget {
+  const MemoryLane({super.key});
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final memoryLaneFutureProvider = ref.watch(memoryFutureProvider);
+
+    final memoryLane = memoryLaneFutureProvider
+        .whenData(
+          (memories) => memories != null
+              ? SizedBox(
+                  height: 200,
+                  child: ListView.builder(
+                    scrollDirection: Axis.horizontal,
+                    shrinkWrap: true,
+                    itemCount: memories.length,
+                    itemBuilder: (context, index) {
+                      final memory = memories[index];
+
+                      return Padding(
+                        padding: const EdgeInsets.only(right: 8.0, bottom: 8),
+                        child: GestureDetector(
+                          onTap: () {
+                            AutoRouter.of(context).push(
+                              VerticalRouteView(
+                                memories: memories,
+                                memoryIndex: index,
+                              ),
+                            );
+                          },
+                          child: Stack(
+                            children: [
+                              Card(
+                                elevation: 3,
+                                shape: RoundedRectangleBorder(
+                                  borderRadius: BorderRadius.circular(13.0),
+                                ),
+                                clipBehavior: Clip.hardEdge,
+                                child: ColorFiltered(
+                                  colorFilter: ColorFilter.mode(
+                                    Colors.black.withOpacity(0.1),
+                                    BlendMode.darken,
+                                  ),
+                                  child: ImmichImage(
+                                    memory.assets[0],
+                                    fit: BoxFit.cover,
+                                    width: 130,
+                                    height: 200,
+                                    useGrayBoxPlaceholder: true,
+                                  ),
+                                ),
+                              ),
+                              Positioned(
+                                bottom: 16,
+                                left: 16,
+                                child: ConstrainedBox(
+                                  constraints: const BoxConstraints(
+                                    maxWidth: 114,
+                                  ),
+                                  child: Text(
+                                    memory.title,
+                                    style: const TextStyle(
+                                      fontWeight: FontWeight.w500,
+                                      color: Colors.white,
+                                      fontSize: 14,
+                                    ),
+                                  ),
+                                ),
+                              ),
+                            ],
+                          ),
+                        ),
+                      );
+                    },
+                  ),
+                )
+              : const SizedBox(),
+        )
+        .value;
+
+    return memoryLane ?? const SizedBox();
+  }
+}

+ 140 - 0
mobile/lib/modules/memories/views/memory_page.dart

@@ -0,0 +1,140 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
+import 'package:intl/intl.dart';
+
+class MemoryPage extends HookConsumerWidget {
+  final List<Memory> memories;
+  final int memoryIndex;
+
+  const MemoryPage({
+    required this.memories,
+    required this.memoryIndex,
+    super.key,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final memoryPageController = usePageController(initialPage: memoryIndex);
+    final memoryAssetPageController = usePageController();
+    final currentMemory = useState(memories[memoryIndex]);
+    final currentAssetPage = useState(0);
+    final assetProgress = useState(
+      "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
+    );
+    const bgColor = Colors.black;
+
+    toNextMemory() {
+      memoryPageController.nextPage(
+        duration: const Duration(milliseconds: 500),
+        curve: Curves.easeIn,
+      );
+    }
+
+    toNextAsset(int currentAssetIndex) {
+      (currentAssetIndex + 1 < currentMemory.value.assets.length)
+          ? memoryAssetPageController.jumpToPage(
+              (currentAssetIndex + 1),
+            )
+          : toNextMemory();
+    }
+
+    updateProgressText() {
+      assetProgress.value =
+          "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
+    }
+
+    onMemoryChanged(int otherIndex) {
+      HapticFeedback.mediumImpact();
+      currentMemory.value = memories[otherIndex];
+      currentAssetPage.value = 0;
+      updateProgressText();
+    }
+
+    onAssetChanged(int otherIndex) {
+      HapticFeedback.selectionClick();
+
+      currentAssetPage.value = otherIndex;
+      updateProgressText();
+    }
+
+    buildBottomInfo() {
+      return Padding(
+        padding: const EdgeInsets.all(16.0),
+        child: Row(
+          children: [
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(
+                  currentMemory.value.title,
+                  style: TextStyle(
+                    color: Colors.grey[400],
+                    fontSize: 11.0,
+                    fontWeight: FontWeight.w600,
+                  ),
+                ),
+                Text(
+                  DateFormat.yMMMMd().format(
+                    currentMemory.value.assets[0].fileCreatedAt,
+                  ),
+                  style: const TextStyle(
+                    color: Colors.white,
+                    fontSize: 14.0,
+                    fontWeight: FontWeight.w500,
+                  ),
+                ),
+              ],
+            ),
+          ],
+        ),
+      );
+    }
+
+    return Scaffold(
+      backgroundColor: bgColor,
+      body: SafeArea(
+        child: PageView.builder(
+          scrollDirection: Axis.vertical,
+          controller: memoryPageController,
+          onPageChanged: onMemoryChanged,
+          itemCount: memories.length,
+          itemBuilder: (context, mIndex) {
+            // Build horizontal page
+            return Column(
+              children: [
+                Expanded(
+                  child: PageView.builder(
+                    controller: memoryAssetPageController,
+                    onPageChanged: onAssetChanged,
+                    scrollDirection: Axis.horizontal,
+                    itemCount: memories[mIndex].assets.length,
+                    itemBuilder: (context, index) {
+                      final asset = memories[mIndex].assets[index];
+                      return Container(
+                        color: Colors.black,
+                        child: MemoryCard(
+                          asset: asset,
+                          onTap: () => toNextAsset(index),
+                          onClose: () => AutoRouter.of(context).pop(),
+                          rightCornerText: assetProgress.value,
+                          title: memories[mIndex].title,
+                          showTitle: index == 0,
+                        ),
+                      );
+                    },
+                  ),
+                ),
+                buildBottomInfo(),
+              ],
+            );
+          },
+        ),
+      ),
+    );
+  }
+}

+ 2 - 2
mobile/lib/modules/search/ui/thumbnail_with_info.dart

@@ -1,7 +1,7 @@
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:immich_mobile/shared/models/store.dart';
-import 'package:immich_mobile/utils/capitalize_first_letter.dart';
+import 'package:immich_mobile/utils/capitalize.dart';
 
 // ignore: must_be_immutable
 class ThumbnailWithInfo extends StatelessWidget {
@@ -80,7 +80,7 @@ class ThumbnailWithInfo extends StatelessWidget {
             bottom: 12,
             left: 14,
             child: Text(
-              textInfo == '' ? textInfo : textInfo.capitalizeFirstLetter(),
+              textInfo == '' ? textInfo : textInfo.capitalize(),
               style: const TextStyle(
                 color: Colors.white,
                 fontWeight: FontWeight.bold,

+ 2 - 2
mobile/lib/modules/search/views/curated_object_page.dart

@@ -6,7 +6,7 @@ import 'package:immich_mobile/modules/search/models/curated_content.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
-import 'package:immich_mobile/utils/capitalize_first_letter.dart';
+import 'package:immich_mobile/utils/capitalize.dart';
 import 'package:openapi/api.dart';
 
 class CuratedObjectPage extends HookConsumerWidget {
@@ -43,7 +43,7 @@ class CuratedObjectPage extends HookConsumerWidget {
           curatedContent: curatedLocations
               .map(
                 (l) => CuratedContent(
-                  label: l.object.capitalizeFirstLetter(),
+                  label: l.object.capitalize(),
                   id: l.id,
                 ),
               )

+ 3 - 0
mobile/lib/routing/router.dart

@@ -6,6 +6,8 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
 import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
 import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/memories/models/memory.dart';
+import 'package:immich_mobile/modules/memories/views/memory_page.dart';
 import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
 import 'package:immich_mobile/modules/partner/views/partner_page.dart';
 import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
@@ -151,6 +153,7 @@ part 'router.gr.dart';
       ],
     ),
     AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
   ],
 )
 class AppRouter extends _$AppRouter {

+ 58 - 0
mobile/lib/routing/router.gr.dart

@@ -290,6 +290,17 @@ class _$AppRouter extends RootStackRouter {
         child: const AllPeoplePage(),
       );
     },
+    VerticalRouteView.name: (routeData) {
+      final args = routeData.argsAs<VerticalRouteViewArgs>();
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: MemoryPage(
+          memories: args.memories,
+          memoryIndex: args.memoryIndex,
+          key: args.key,
+        ),
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -589,6 +600,14 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          VerticalRouteView.name,
+          path: '/vertical-page-view',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -1281,6 +1300,45 @@ class AllPeopleRoute extends PageRouteInfo<void> {
   static const String name = 'AllPeopleRoute';
 }
 
+/// generated route for
+/// [MemoryPage]
+class VerticalRouteView extends PageRouteInfo<VerticalRouteViewArgs> {
+  VerticalRouteView({
+    required List<Memory> memories,
+    required int memoryIndex,
+    Key? key,
+  }) : super(
+          VerticalRouteView.name,
+          path: '/vertical-page-view',
+          args: VerticalRouteViewArgs(
+            memories: memories,
+            memoryIndex: memoryIndex,
+            key: key,
+          ),
+        );
+
+  static const String name = 'VerticalRouteView';
+}
+
+class VerticalRouteViewArgs {
+  const VerticalRouteViewArgs({
+    required this.memories,
+    required this.memoryIndex,
+    this.key,
+  });
+
+  final List<Memory> memories;
+
+  final int memoryIndex;
+
+  final Key? key;
+
+  @override
+  String toString() {
+    return 'VerticalRouteViewArgs{memories: $memories, memoryIndex: $memoryIndex, key: $key}';
+  }
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 5 - 0
mobile/lib/routing/tab_navigation_observer.dart

@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
+import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
 import 'package:immich_mobile/modules/search/providers/people.provider.dart';
 
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
@@ -43,6 +44,10 @@ class TabNavigationObserver extends AutoRouterObserver {
     if (route.name == 'LibraryRoute') {
       ref.read(albumProvider.notifier).getAllAlbums();
     }
+
+    if (route.name == 'HomeRoute') {
+      ref.invalidate(memoryFutureProvider);
+    }
     ref.watch(serverInfoProvider.notifier).getServerVersion();
   }
 }

+ 5 - 3
mobile/lib/shared/providers/asset.provider.dart

@@ -75,7 +75,7 @@ class AssetNotifier extends StateNotifier<bool> {
     await _syncService.syncNewAssetToDb(newAsset);
   }
 
-  Future<void> deleteAssets(Set<Asset> deleteAssets) async {
+  Future<void> deleteAssets(Iterable<Asset> deleteAssets) async {
     _deleteInProgress = true;
     state = true;
     try {
@@ -94,7 +94,9 @@ class AssetNotifier extends StateNotifier<bool> {
     }
   }
 
-  Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
+  Future<List<String>> _deleteLocalAssets(
+    Iterable<Asset> assetsToDelete,
+  ) async {
     final List<String> local =
         assetsToDelete.where((a) => a.isLocal).map((a) => a.localId!).toList();
     // Delete asset from device
@@ -109,7 +111,7 @@ class AssetNotifier extends StateNotifier<bool> {
   }
 
   Future<Iterable<String>> _deleteRemoteAssets(
-    Set<Asset> assetsToDelete,
+    Iterable<Asset> assetsToDelete,
   ) async {
     final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
     final List<DeleteAssetResponseDto> deleteAssetResult =

+ 5 - 3
mobile/lib/shared/ui/immich_image.dart

@@ -7,6 +7,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/utils/image_url_builder.dart';
 import 'package:photo_manager/photo_manager.dart';
+import 'package:openapi/api.dart' as api;
 
 /// Renders an Asset using local data if available, else remote data
 class ImmichImage extends StatelessWidget {
@@ -16,6 +17,7 @@ class ImmichImage extends StatelessWidget {
     this.height,
     this.fit = BoxFit.cover,
     this.useGrayBoxPlaceholder = false,
+    this.type = api.ThumbnailFormat.WEBP,
     super.key,
   });
   final Asset? asset;
@@ -23,6 +25,7 @@ class ImmichImage extends StatelessWidget {
   final double? width;
   final double? height;
   final BoxFit fit;
+  final api.ThumbnailFormat type;
 
   @override
   Widget build(BuildContext context) {
@@ -86,8 +89,7 @@ class ImmichImage extends StatelessWidget {
       );
     }
     final String? token = Store.get(StoreKey.accessToken);
-    final String thumbnailRequestUrl = getThumbnailUrl(asset);
-
+    final String thumbnailRequestUrl = getThumbnailUrl(asset, type: type);
     return CachedNetworkImage(
       imageUrl: thumbnailRequestUrl,
       httpHeaders: {"Authorization": "Bearer $token"},
@@ -114,7 +116,7 @@ class ImmichImage extends StatelessWidget {
         }
         return Transform.scale(
           scale: 0.2,
-          child: CircularProgressIndicator(
+          child: CircularProgressIndicator.adaptive(
             value: downloadProgress.progress,
           ),
         );

+ 9 - 0
mobile/lib/utils/capitalize.dart

@@ -0,0 +1,9 @@
+extension StringExtension on String {
+  String capitalize() {
+    return split(" ")
+        .map(
+          (str) => str.isEmpty ? str : str[0].toUpperCase() + str.substring(1),
+        )
+        .join(" ");
+  }
+}

+ 32 - 0
mobile/pubspec.lock

@@ -225,6 +225,22 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.17.1"
+  connectivity_plus:
+    dependency: "direct main"
+    description:
+      name: connectivity_plus
+      sha256: "8599ae9edca5ff96163fca3e36f8e481ea917d1e71cdad912c084b5579913f34"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.1"
+  connectivity_plus_platform_interface:
+    dependency: transitive
+    description:
+      name: connectivity_plus_platform_interface
+      sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.4"
   convert:
     dependency: transitive
     description:
@@ -281,6 +297,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.1.0"
+  dbus:
+    dependency: transitive
+    description:
+      name: dbus
+      sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.8"
   device_info_plus:
     dependency: "direct main"
     description:
@@ -756,6 +780,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "1.0.0"
+  nm:
+    dependency: transitive
+    description:
+      name: nm
+      sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.5.0"
   octo_image:
     dependency: transitive
     description:

+ 11 - 0
mobile/pubspec.yaml

@@ -46,6 +46,7 @@ dependencies:
   isar_flutter_libs: *isar_version # contains Isar Core
   permission_handler: ^10.2.0
   device_info_plus: ^8.1.0
+  connectivity_plus: ^4.0.1
   crypto: ^3.0.3 # TODO remove once native crypto is used on iOS
   wakelock: ^0.6.2
   flutter_thumbhash: 0.1.0+1
@@ -81,6 +82,16 @@ flutter:
         - asset: fonts/WorkSans.ttf
         - asset: fonts/WorkSans-Italic.ttf
           style: italic
+        # - asset: fonts/WorkSans-Medium.ttf
+        #   weight: 500
+        # - asset: fonts/WorkSans-SemiBold.ttf
+        #   weight: 600
+        # - asset: fonts/WorkSans-Bold.ttf
+        #   weight: 700
+        # - asset: fonts/WorkSans-ExtraBold.ttf
+        #   weight: 800
+        # - asset: fonts/WorkSans-Black.ttf
+        #   weight: 900
     - family: SnowburstOne
       fonts:
         - asset: fonts/SnowburstOne.ttf

+ 10 - 2
web/src/lib/components/forms/api-key-secret.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { createEventDispatcher } from 'svelte';
+	import { createEventDispatcher, onMount } from 'svelte';
 	import KeyVariant from 'svelte-material-icons/KeyVariant.svelte';
 	import { handleError } from '../../utils/handle-error';
 	import FullScreenModal from '../shared-components/full-screen-modal.svelte';
@@ -13,6 +13,12 @@
 
 	const dispatch = createEventDispatcher();
 	const handleDone = () => dispatch('done');
+	let canCopyImagesToClipboard = true;
+
+	onMount(async () => {
+		const module = await import('copy-image-clipboard');
+		canCopyImagesToClipboard = module.canCopyImagesToClipboard();
+	});
 	const handleCopy = async () => {
 		try {
 			await navigator.clipboard.writeText(secret);
@@ -55,7 +61,9 @@
 		</div>
 
 		<div class="flex w-full px-4 gap-4 mt-8">
-			<Button on:click={() => handleCopy()} fullwidth>Copy to Clipboard</Button>
+			{#if canCopyImagesToClipboard}
+				<Button on:click={() => handleCopy()} fullwidth>Copy to Clipboard</Button>
+			{/if}
 			<Button on:click={() => handleDone()} fullwidth>Done</Button>
 		</div>
 	</div>

+ 8 - 3
web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte

@@ -31,7 +31,7 @@
 	let showExif = true;
 	let expirationTime = '';
 	let shouldChangeExpirationTime = false;
-
+	let canCopyImagesToClipboard = true;
 	const dispatch = createEventDispatcher();
 
 	const expiredDateOption: ImmichDropDownOption = {
@@ -39,7 +39,7 @@
 		options: ['Never', '30 minutes', '1 hour', '6 hours', '1 day', '7 days', '30 days']
 	};
 
-	onMount(() => {
+	onMount(async () => {
 		if (editingLink) {
 			if (editingLink.description) {
 				description = editingLink.description;
@@ -48,6 +48,9 @@
 			allowDownload = editingLink.allowDownload;
 			showExif = editingLink.showExif;
 		}
+
+		const module = await import('copy-image-clipboard');
+		canCopyImagesToClipboard = module.canCopyImagesToClipboard();
 	});
 
 	const handleCreateSharedLink = async () => {
@@ -247,7 +250,9 @@
 			<div class="flex w-full gap-4">
 				<input class="immich-form-input w-full" bind:value={sharedLink} disabled />
 
-				<Button on:click={() => handleCopy()}>Copy</Button>
+				{#if canCopyImagesToClipboard}
+					<Button on:click={() => handleCopy()}>Copy</Button>
+				{/if}
 			</div>
 		{/if}
 	</section>