Compare commits

..

36 Commits

Author SHA1 Message Date
Alex The Bot
df9c05bef3 Version v1.65.0 2023-06-30 03:01:48 +00:00
Alex Tran
6c8c16c85f chore: update release note notes 2023-06-29 21:48:57 -05:00
Alex
b05f3fd266 fix(mobile): avatar without last name (#3038) 2023-06-29 17:05:12 -05:00
Alex
ca98d73d86 chore(mobile): update flutter to 3.10.5 (#3036) 2023-06-29 16:28:18 -05:00
Fynn Petersen-Frey
b7ae3be394 fix(mobile): rework album detail page header (#3035) 2023-06-29 16:11:56 -05:00
Alex Tran
621fa5ba54 update readme 2023-06-29 15:23:55 -05:00
Alex Tran
ca1b9bf7b3 fix(doc): format 2023-06-29 14:49:23 -05:00
Dhrumil Shah
6fa685d9d8 chore: add CLI tool to the server image (#2999)
* WIP: Added immich cli tool to `immich-server` image

* WIP: Added doc entry to show it is preinstalled

* WIP: Moved immich upload cli to `immich` and default to `immich-admin`

* WIP: undid previous commit

* WIP: Updated server docs with new `immich-admin` command
2023-06-29 14:48:16 -05:00
Fynn Petersen-Frey
ff26d3666e fix(mobile): set scrolling state only if changed (#3034)
* fix(mobile): set scrolling state only if changed

* fix: generate api

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-06-29 14:35:29 -05:00
faupau
e3557fd80e Fix(web): drag n drop shared link (#3030)
* add event to trigger uploadhandler

* add dragndrop store
to handle upload in album-viewer and individuel-shared-viewer
(only on shares)

* fix handleUploadAssets no parameter

* fix format
2023-06-29 10:26:25 -05:00
faupau
c065705608 fix(web): Share link multi-select download icon showing when not available #3006 (#3027)
* only show download button if allowDownload
add SelectAll to individual share

* fix allow download if not share
2023-06-29 10:11:37 -05:00
dependabot[bot]
3948247055 chore(deps): bump docker/setup-buildx-action from 2.7.0 to 2.8.0 (#3028)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.7.0 to 2.8.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.7.0...v2.8.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-06-29 08:11:17 -05:00
Sergey Kondrikov
dca48d7722 fix(web): aspect ratio for videos (#3023) 2023-06-29 08:11:00 -05:00
Alex
8e6c90e294 chore(mobile): minor UI tweak (#3021)
* chore(mobile): minor UI tweak

* fix test

* refactor
2023-06-28 22:33:57 -05:00
Thomas
e5908f2508 fix(server): use private cache (#3017)
The omission of additional cache-control directives implied the resource could
be stored in shared/public caches, which is not desirable.

In addition, the no-transform directive will ensure content is not
unintentionally mangled.

Fixes: #3014
2023-06-28 21:26:16 -05:00
martin
fbd98ec0f9 feat(web): persist info panel (#3013)
Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>
2023-06-28 21:14:16 -05:00
Fynn Petersen-Frey
1ab05e8de0 fix(mobile): fix endless rendering of asset grid when scrolling (#3010) 2023-06-28 21:13:18 -05:00
Sergey Kondrikov
86562f256f fix(web): aspect ratio for photos with Rotate 270 CW orientation (#3003)
* fix(web): aspect ratio for photos with Rotate 270 CW orientation

* Remove checks assuming we can have only numeric values

* Remove the -90 value check for the orientation

* Add comment to numeric values of the orientation tag
2023-06-28 13:04:32 -05:00
Jason Rasmussen
add5219d34 fix: live photos not playing in shared links/albums (#3008) 2023-06-28 12:58:38 -05:00
Keszei Balázs
6ae5d11ec0 chore(server): Image description disappears after toggle favorite (#3009)
* Fixed asset rewrite description on toggle favorite

* Fixed description removing error

* Rewrite description condition for asset update

---------

Co-authored-by: Balazs Keszei <balazs.keszei@clbr.hu>
2023-06-28 12:54:48 -05:00
Rohitt Vashishtha
b4e641548c fix(web): Add m: to search query upon loading results. (#2954)
Previously, we'd drop the m: from non-clip searches entirely. This
behavior incorrectly represents the page's status (results from
non-clip search but query implies a clip search). Also, any follow-up
searches change to clip searches, which feels like a jarring UX if you
have to add m: every time in a 'search-session'.
2023-06-28 17:48:53 +00:00
Mert
017214fd56 fix(server): empty tag responses should be considered valid (#2993)
* accept empty tag array

* renamed test
2023-06-28 09:40:30 -05:00
Alex Phillips
22a73b67d3 chore(server): check file extension for XMP instead of mimetype (#2990)
* just check file extension for XMP instead of mimetype

* use path to get extension instead of regex

* single quotes

* remove unused import

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-06-28 14:40:21 +00:00
Thomas
792ecc6cac fix(server): add missing avi mime types and add tests (#3001)
See https://github.com/immich-app/immich/pull/2952#pullrequestreview-1497194041

Fixes: #2975
2023-06-28 09:21:42 -05:00
Jason Rasmussen
e98398cab8 refactor(server): access permissions (#2910)
* refactor: access repo interface

* feat: access core

* fix: allow shared links to add to a shared link

* chore: comment out unused code

* fix: pr feedback

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-06-28 08:56:24 -05:00
Mert
df1e8679d9 chore(ml): added testing and github workflow (#2969)
* added testing

* github action for python, made mypy happy

* formatted with black

* minor fixes and styling

* test model cache

* cache test dependencies

* narrowed model cache tests

* moved endpoint tests to their own class

* cleaned up fixtures

* formatting

* removed unused dep
2023-06-27 18:21:33 -05:00
Alex Tran
5e3bdc76b2 chore(mobile): auto dispose future provider 2023-06-27 16:02:54 -05:00
Mert
47982641b2 fix(ml): clear model cache on load error (#2951)
* clear model cache on load error

* updated caught exceptions
2023-06-27 16:01:24 -05:00
Alex
39a885a37c feat(mobile): memories (#2988)
* Add page view

* Nice page view

* refactor file structure

* Added card

* invalidating data

* transition

* styling

* correct styleing

* refactor

* click to navigate

* styling

* TODO

* clean up

* clean up

* pr feedback

* pr feedback

* better loading indicator
2023-06-27 16:00:20 -05:00
Alex Tran
0e8d235148 fix(mobile): format 2023-06-27 12:28:15 -05:00
Alex Elkins
053a5235be chore(mobile): Capitalize Places cities in app (#2985) 2023-06-27 17:26:23 +00:00
Fynn Petersen-Frey
de42ebf3d8 feat(Android): find & delete corrupt asset backups (#2963)
* feat(mobile): find & delete corrupt asset backups

* show backup fix only for advanced troubleshooting
2023-06-27 12:25:00 -05:00
Mert
4d3ce0a65e fixed setting different clip, removed unused stubs (#2987) 2023-06-27 12:21:50 -05:00
Alex
b3e97a1a0c chore(web): Only show Copy button in HTTPS context (#2983) 2023-06-27 08:49:20 -05:00
Sergey Kondrikov
f5d9826b12 Fix download asset loading indicator position (#2974) 2023-06-27 08:48:20 -05:00
Alex
61e5e65173 feat(server): Add camera make and model to search criteria (#2982) 2023-06-27 06:53:07 -05:00
121 changed files with 2732 additions and 789 deletions

View File

@@ -45,7 +45,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.0"
flutter-version: "3.10.5"
cache: true
- name: Create the Keystore

View File

@@ -45,7 +45,7 @@ jobs:
uses: docker/setup-qemu-action@v2.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.7.0
uses: docker/setup-buildx-action@v2.8.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761

View File

@@ -23,7 +23,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.0"
flutter-version: "3.10.5"
- name: Install dependencies
run: dart pub get

View File

@@ -116,11 +116,41 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.0"
flutter-version: "3.10.5"
- name: Run tests
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

View File

@@ -70,7 +70,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Multi-user support | Yes | Yes |
| Album and Shared albums | Yes | Yes |
| Scrubbable/draggable scrollbar | Yes | Yes |
| Support RAW (HEIC, HEIF, DNG, Apple ProRaw) | Yes | Yes |
| Support raw formats | Yes | Yes |
| Metadata view (EXIF, map) | Yes | Yes |
| Search by metadata, objects, faces, and CLIP | Yes | Yes |
| Administrative functions (user management) | No | Yes |
@@ -84,8 +84,10 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Archive and Favorites | Yes | Yes |
| Global Map | No | Yes |
| Partner Sharing | Yes | Yes |
| Facial recognition and clustering | No | Yes |
| Facial recognition and clustering | Yes | Yes |
| Memories (x years ago) | Yes | Yes |
| Offline support | Yes | No |
| Read-only gallery | Yes | Yes |
# Support the project

View File

@@ -1,6 +1,6 @@
# Server Commands
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich`) that supports the following commands:
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------- |

View File

@@ -15,6 +15,12 @@ You can use the CLI to upload an existing gallery to the Immich server
npm i -g immich
```
Pre-installed on the `immich-server` container and can be easily accessed through
```
immich
```
## Quick Start
Specify user's credential, Immich's server address and port and the directory you would like to upload videos/photos from.

View File

@@ -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_"

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.64.0"
version = "1.65.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -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']

View File

@@ -7,15 +7,28 @@ As always, please consider supporting the project.
🎉 Cheer! 🎉
## Support
- - - -
And as always, bugs are fixed, and many other improvements also come with this release.
Please consider supporting the project.
## Support
<p align="center">
<img src="https://media.giphy.com/media/LStqgGESXW8XnuCv5y/giphy.gif" width="250" title="Loading ~4000 images/videos">
<img src="https://media.giphy.com/media/LStqgGESXW8XnuCv5y/giphy.gif" width="250" title="SUPPORT THE PROJECT!">
</p>
If you find the project helpful and it helps you in some ways, you can support the project [one time](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) or [monthly](https://github.com/sponsors/alextran1502) from GitHub Sponsors
If you find the project helpful, you can support Immich via the following channels.
- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502)
- One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
It is a great way to let me know that you want me to continue developing and working on this project for years to come.
## What's Changed

View File

@@ -1,4 +1,4 @@
{
"flutterSdkVersion": "3.10.0",
"flutterSdkVersion": "3.10.5",
"flavors": {}
}

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 87,
"android.injected.version.name" => "1.64.0",
"android.injected.version.code" => 88,
"android.injected.version.name" => "1.65.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,7 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- ReachabilitySwift
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@@ -33,6 +36,7 @@ PODS:
- photo_manager (2.0.0):
- Flutter
- FlutterMacOS
- ReachabilitySwift (5.0.0)
- SAMKeychain (1.5.3)
- share_plus (0.0.1):
- Flutter
@@ -51,6 +55,7 @@ PODS:
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@@ -75,10 +80,13 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- FMDB
- ReachabilitySwift
- SAMKeychain
- Toast
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
@@ -121,6 +129,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock/ios"
SPEC CHECKSUMS:
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
@@ -136,6 +145,7 @@ SPEC CHECKSUMS:
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.64.0"
version_number: "1.65.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -21,6 +21,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
required this.selected,
required this.selectionDisabled,
required this.titleFocusNode,
this.onAddPhotos,
this.onAddUsers,
}) : super(key: key);
final Album album;
@@ -28,6 +30,8 @@ class AlbumViewerAppbar extends HookConsumerWidget
final Set<Asset> selected;
final void Function() selectionDisabled;
final FocusNode titleFocusNode;
final Function(Album album)? onAddPhotos;
final Function(Album album)? onAddUsers;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -157,6 +161,32 @@ class AlbumViewerAppbar extends HookConsumerWidget
mainAxisSize: MainAxisSize.min,
children: [
buildBottomSheetActionButton(),
if (selected.isEmpty && onAddPhotos != null)
ListTile(
leading: const Icon(Icons.add_photo_alternate_outlined),
onTap: () {
Navigator.pop(context);
onAddPhotos!(album);
},
title: const Text(
"share_add_photos",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
if (selected.isEmpty &&
onAddPhotos != null &&
userId == album.ownerId)
ListTile(
leading: const Icon(Icons.person_add_alt_rounded),
onTap: () {
Navigator.pop(context);
onAddUsers!(album);
},
title: const Text(
"album_viewer_page_share_add_users",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
],
),
);

View File

@@ -18,7 +18,6 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerPage extends HookConsumerWidget {
@@ -33,7 +32,6 @@ class AlbumViewerPage extends HookConsumerWidget {
final userId = ref.watch(authenticationProvider).userId;
final selection = useState<Set<Asset>>({});
final multiSelectEnabled = useState(false);
bool? isTop;
Future<bool> onWillPop() async {
if (multiSelectEnabled.value) {
@@ -219,8 +217,6 @@ class AlbumViewerPage extends HookConsumerWidget {
);
}
final scroll = ScrollController();
return Scaffold(
appBar: album.when(
data: (data) => AlbumViewerAppbar(
@@ -229,6 +225,8 @@ class AlbumViewerPage extends HookConsumerWidget {
userId: userId,
selected: selection.value,
selectionDisabled: disableSelection,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
),
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
@@ -240,41 +238,17 @@ class AlbumViewerPage extends HookConsumerWidget {
onTap: () {
titleFocusNode.unfocus();
},
child: NestedScrollView(
controller: scroll,
floatHeaderSlivers: true,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverToBoxAdapter(child: buildHeader(data)),
SliverPersistentHeader(
pinned: true,
delegate: ImmichSliverPersistentAppBarDelegate(
minHeight: 50,
maxHeight: 50,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(data),
),
),
)
],
body: ImmichAssetGrid(
renderList: data.renderList,
listener: selectionListener,
selectionActive: multiSelectEnabled.value,
showMultiSelectIndicator: false,
visibleItemsListener: (start, end) {
final top = start.index == 0 && start.itemLeadingEdge == 0.0;
if (top != isTop) {
isTop = top;
scroll.animateTo(
top
? scroll.position.minScrollExtent
: scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 500),
curve: top ? Curves.easeOut : Curves.easeIn,
);
}
},
child: ImmichAssetGrid(
renderList: data.renderList,
listener: selectionListener,
selectionActive: multiSelectEnabled.value,
showMultiSelectIndicator: false,
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
),
),
),

View File

@@ -236,7 +236,7 @@ class SharingPage extends HookConsumerWidget {
SliverToBoxAdapter(child: buildTopBottons()),
if (partner.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 4),
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: const Text(
"partner_page_title",
@@ -246,11 +246,7 @@ class SharingPage extends HookConsumerWidget {
),
if (partner.isNotEmpty) PartnerList(partner: partner),
SliverPadding(
padding: EdgeInsets.only(
left: 12,
right: 12,
top: partner.isEmpty ? 0 : 16,
),
padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter(
child: const Text(
"sharing_page_album",

View File

@@ -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(),
),
),
],
);

View File

@@ -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),
),
);

View File

@@ -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(

View File

@@ -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(),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class GroupDividerTitle extends ConsumerWidget {
@@ -20,6 +21,7 @@ class GroupDividerTitle extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
void handleTitleIconClick() {
HapticFeedback.heavyImpact();
if (selected) {
onDeselect();
} else {
@@ -30,7 +32,7 @@ class GroupDividerTitle extends ConsumerWidget {
return Padding(
padding: const EdgeInsets.only(
top: 12.0,
bottom: 4.0,
bottom: 16.0,
left: 12.0,
right: 12.0,
),

View File

@@ -19,6 +19,45 @@ typedef ImmichAssetGridSelectionListener = void Function(
Set<Asset>,
);
class ImmichAssetGridView extends StatefulWidget {
final RenderList renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final Future<void> Function()? onRefresh;
final Set<Asset>? preselectedAssets;
final bool canDeselect;
final bool dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(ItemPosition start, ItemPosition end)?
visibleItemsListener;
final Widget? topWidget;
const ImmichAssetGridView({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
this.onRefresh,
this.preselectedAssets,
this.canDeselect = true,
this.dynamicLayout = true,
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
this.topWidget,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridViewState();
}
}
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ItemPositionsListener _itemPositionsListener =
@@ -273,9 +312,11 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
final useDragScrolling = widget.renderList.totalAssets >= 20;
void dragScrolling(bool active) {
setState(() {
_scrolling = active;
});
if (active != _scrolling) {
setState(() {
_scrolling = active;
});
}
}
final listWidget = ScrollablePositionedList.builder(
@@ -302,7 +343,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
child: listWidget,
)
: listWidget;
return widget.onRefresh == null
? child
: RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
@@ -388,42 +428,3 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
);
}
}
class ImmichAssetGridView extends StatefulWidget {
final RenderList renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final Future<void> Function()? onRefresh;
final Set<Asset>? preselectedAssets;
final bool canDeselect;
final bool dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(ItemPosition start, ItemPosition end)?
visibleItemsListener;
final Widget? topWidget;
const ImmichAssetGridView({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
this.onRefresh,
this.preselectedAssets,
this.canDeselect = true,
this.dynamicLayout = true,
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
this.topWidget,
});
@override
State<StatefulWidget> createState() {
return ImmichAssetGridViewState();
}
}

View File

@@ -71,8 +71,8 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
),
if (serverInfoState.isVersionMismatch)
Positioned(
bottom: 12,
right: 12,
bottom: 4,
right: 6,
child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(),
child: Material(

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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();
});

View File

@@ -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;
}
}
}

View File

@@ -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(),
)
],
),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
import 'package:openapi/api.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: () {
HapticFeedback.heavyImpact();
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,
type: ThumbnailFormat.JPEG,
),
),
),
Positioned(
bottom: 16,
left: 16,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 114,
),
child: Text(
memory.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
fontSize: 14,
),
),
),
),
],
),
),
);
},
),
)
: const SizedBox(),
)
.value;
return memoryLane ?? const SizedBox();
}
}

View File

@@ -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(),
],
);
},
),
),
);
}
}

View File

@@ -23,7 +23,14 @@ class PartnerList extends HookConsumerWidget {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
leading: userAvatar(context, p, radius: 30),
title: Text("${p.firstName} ${p.lastName}"),
title: Text(
"${p.firstName} ${p.lastName}'s photos",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Theme.of(context).primaryColor,
),
),
onTap: () => AutoRouter.of(context).push(PartnerDetailRoute(partner: p)),
);
}

View File

@@ -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,

View File

@@ -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,
),
)

View File

@@ -51,31 +51,34 @@ class LayoutSettings extends HookConsumerWidget {
children: [
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
"asset_list_layout_settings_dynamic_layout_title",
style: TextStyle(
fontSize: 12,
),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onChanged: switchChanged,
value: useDynamicLayout.value,
),
const Divider(
indent: 18,
endIndent: 18,
),
ListTile(
title: const Text(
"asset_list_layout_settings_group_by",
style: TextStyle(
fontSize: 12,
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
RadioListTile(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
"asset_list_layout_settings_group_by_month_day",
style: TextStyle(
fontSize: 12,
),
style: Theme.of(context).textTheme.labelLarge,
).tr(),
value: GroupAssetsBy.day,
groupValue: groupBy.value,
@@ -84,11 +87,9 @@ class LayoutSettings extends HookConsumerWidget {
),
RadioListTile(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
"asset_list_layout_settings_group_by_month",
style: TextStyle(
fontSize: 12,
),
style: Theme.of(context).textTheme.labelLarge,
).tr(),
value: GroupAssetsBy.month,
groupValue: groupBy.value,
@@ -97,11 +98,9 @@ class LayoutSettings extends HookConsumerWidget {
),
RadioListTile(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
"asset_list_layout_settings_group_automatically",
style: TextStyle(
fontSize: 12,
),
style: Theme.of(context).textTheme.labelLarge,
).tr(),
value: GroupAssetsBy.auto,
groupValue: groupBy.value,

View File

@@ -34,11 +34,12 @@ class StorageIndicator extends HookConsumerWidget {
return SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
"theme_setting_asset_list_storage_indicator_title",
style: TextStyle(
fontSize: 12,
),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onChanged: switchChanged,
value: showStorageIndicator.value,

View File

@@ -39,7 +39,7 @@ class TilesPerRow extends HookConsumerWidget {
title: const Text(
"theme_setting_asset_list_tiles_per_row_title",
style: TextStyle(
fontSize: 12,
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(args: ["${itemsValue.value.toInt()}"]),

View File

@@ -22,15 +22,21 @@ class SettingsSwitchListTile extends StatelessWidget {
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
value: valueNotifier.value,
onChanged: !enabled ? null : (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme
.of(context)
.primaryColor,
onChanged: !enabled
? null
: (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme.of(context).primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
title: Text(
title,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
subtitle: subtitle != null ? Text(subtitle!) : null,
);
}

View File

@@ -40,12 +40,12 @@ class ThemeSetting extends HookConsumerWidget {
children: [
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
'theme_setting_system_theme_switch',
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
value: currentTheme.value == ThemeMode.system,
onChanged: (bool isSystem) {
@@ -78,12 +78,12 @@ class ThemeSetting extends HookConsumerWidget {
if (currentTheme.value != ThemeMode.system)
SwitchListTile.adaptive(
activeColor: Theme.of(context).primaryColor,
title: const Text(
title: Text(
'theme_setting_dark_mode_switch',
style: TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
value: ref.watch(immichThemeProvider) == ThemeMode.dark,
onChanged: (bool isDark) {

View File

@@ -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 {

View File

@@ -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> {

View File

@@ -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();
}
}

View File

@@ -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 =

View File

@@ -6,6 +6,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 {
@@ -15,6 +16,7 @@ class ImmichImage extends StatelessWidget {
this.height,
this.fit = BoxFit.cover,
this.useGrayBoxPlaceholder = false,
this.type = api.ThumbnailFormat.WEBP,
super.key,
});
final Asset? asset;
@@ -22,6 +24,7 @@ class ImmichImage extends StatelessWidget {
final double? width;
final double? height;
final BoxFit fit;
final api.ThumbnailFormat type;
@override
Widget build(BuildContext context) {
@@ -85,11 +88,11 @@ 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"},
cacheKey: getThumbnailCacheKey(asset),
cacheKey: getThumbnailCacheKey(asset, type: type),
width: width,
height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
@@ -105,7 +108,7 @@ class ImmichImage extends StatelessWidget {
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
child: CircularProgressIndicator.adaptive(
value: downloadProgress.progress,
),
);

View File

@@ -1,38 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
class ImmichSliverPersistentAppBarDelegate
extends SliverPersistentHeaderDelegate {
final double minHeight;
final double maxHeight;
final Widget child;
ImmichSliverPersistentAppBarDelegate({
required this.minHeight,
required this.maxHeight,
required this.child,
});
@override
double get minExtent => minHeight;
@override
double get maxExtent => max(maxHeight, minHeight);
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return SizedBox.expand(child: child);
}
@override
bool shouldRebuild(ImmichSliverPersistentAppBarDelegate oldDelegate) {
return maxHeight != oldDelegate.maxHeight ||
minHeight != oldDelegate.minHeight ||
child != oldDelegate.child;
}
}

View File

@@ -6,6 +6,8 @@ import 'package:immich_mobile/shared/models/user.dart';
Widget userAvatar(BuildContext context, User u, {double? radius}) {
final url =
"${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}";
final firstNameFirstLetter = u.firstName.isNotEmpty ? u.firstName[0] : "";
final lastNameFirstLetter = u.lastName.isNotEmpty ? u.lastName[0] : "";
return CircleAvatar(
radius: radius,
backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
@@ -16,6 +18,6 @@ Widget userAvatar(BuildContext context, User u, {double? radius}) {
),
// silence errors if user has no profile image, use initials as fallback
onForegroundImageError: (exception, stackTrace) {},
child: Text((u.firstName[0] + u.lastName[0]).toUpperCase()),
child: Text((firstNameFirstLetter + lastNameFirstLetter).toUpperCase()),
);
}

View File

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

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.64.0
- API version: 1.65.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -72,7 +72,7 @@ class AdminSignupResponseDto {
email: mapValueOfType<String>(json, r'email')!,
firstName: mapValueOfType<String>(json, r'firstName')!,
lastName: mapValueOfType<String>(json, r'lastName')!,
createdAt: mapDateTime(json, r'createdAt', '')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
);
}
return null;

View File

@@ -128,14 +128,14 @@ class AlbumResponseDto {
id: mapValueOfType<String>(json, r'id')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
albumName: mapValueOfType<String>(json, r'albumName')!,
createdAt: mapDateTime(json, r'createdAt', '')!,
updatedAt: mapDateTime(json, r'updatedAt', '')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
shared: mapValueOfType<bool>(json, r'shared')!,
sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']),
assets: AssetResponseDto.listFromJson(json[r'assets']),
owner: UserResponseDto.fromJson(json[r'owner'])!,
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''),
lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''),
);
}
return null;

View File

@@ -64,8 +64,8 @@ class APIKeyResponseDto {
return APIKeyResponseDto(
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
createdAt: mapDateTime(json, r'createdAt', '')!,
updatedAt: mapDateTime(json, r'updatedAt', '')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
}
return null;

View File

@@ -213,9 +213,9 @@ class AssetResponseDto {
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
resized: mapValueOfType<bool>(json, r'resized')!,
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
updatedAt: mapDateTime(json, r'updatedAt', '')!,
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
mimeType: mapValueOfType<String>(json, r'mimeType'),

View File

@@ -243,31 +243,31 @@ class ExifResponseDto {
model: mapValueOfType<String>(json, r'model'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse(json[r'exifImageWidth'].toString()),
: num.parse('${json[r'exifImageWidth']}'),
exifImageHeight: json[r'exifImageHeight'] == null
? null
: num.parse(json[r'exifImageHeight'].toString()),
: num.parse('${json[r'exifImageHeight']}'),
orientation: mapValueOfType<String>(json, r'orientation'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''),
modifyDate: mapDateTime(json, r'modifyDate', ''),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
modifyDate: mapDateTime(json, r'modifyDate', r''),
timeZone: mapValueOfType<String>(json, r'timeZone'),
lensModel: mapValueOfType<String>(json, r'lensModel'),
fNumber: json[r'fNumber'] == null
? null
: num.parse(json[r'fNumber'].toString()),
: num.parse('${json[r'fNumber']}'),
focalLength: json[r'focalLength'] == null
? null
: num.parse(json[r'focalLength'].toString()),
: num.parse('${json[r'focalLength']}'),
iso: json[r'iso'] == null
? null
: num.parse(json[r'iso'].toString()),
: num.parse('${json[r'iso']}'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
latitude: json[r'latitude'] == null
? null
: num.parse(json[r'latitude'].toString()),
: num.parse('${json[r'latitude']}'),
longitude: json[r'longitude'] == null
? null
: num.parse(json[r'longitude'].toString()),
: num.parse('${json[r'longitude']}'),
city: mapValueOfType<String>(json, r'city'),
state: mapValueOfType<String>(json, r'state'),
country: mapValueOfType<String>(json, r'country'),

View File

@@ -156,8 +156,8 @@ class ImportAssetDto {
sidecarPath: mapValueOfType<String>(json, r'sidecarPath'),
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
deviceId: mapValueOfType<String>(json, r'deviceId')!,
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isArchived: mapValueOfType<bool>(json, r'isArchived'),
isVisible: mapValueOfType<bool>(json, r'isVisible'),

View File

@@ -116,7 +116,7 @@ class SharedLinkCreateDto {
: const [],
albumId: mapValueOfType<String>(json, r'albumId'),
description: mapValueOfType<String>(json, r'description'),
expiresAt: mapDateTime(json, r'expiresAt', ''),
expiresAt: mapDateTime(json, r'expiresAt', r''),
allowUpload: mapValueOfType<bool>(json, r'allowUpload') ?? false,
allowDownload: mapValueOfType<bool>(json, r'allowDownload') ?? true,
showExif: mapValueOfType<bool>(json, r'showExif') ?? true,

View File

@@ -113,7 +113,7 @@ class SharedLinkEditDto {
return SharedLinkEditDto(
description: mapValueOfType<String>(json, r'description'),
expiresAt: mapDateTime(json, r'expiresAt', ''),
expiresAt: mapDateTime(json, r'expiresAt', r''),
allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
showExif: mapValueOfType<bool>(json, r'showExif'),

View File

@@ -133,8 +133,8 @@ class SharedLinkResponseDto {
description: mapValueOfType<String>(json, r'description'),
userId: mapValueOfType<String>(json, r'userId')!,
key: mapValueOfType<String>(json, r'key')!,
createdAt: mapDateTime(json, r'createdAt', '')!,
expiresAt: mapDateTime(json, r'expiresAt', ''),
createdAt: mapDateTime(json, r'createdAt', r'')!,
expiresAt: mapDateTime(json, r'expiresAt', r''),
assets: AssetResponseDto.listFromJson(json[r'assets']),
album: AlbumResponseDto.fromJson(json[r'album']),
allowUpload: mapValueOfType<bool>(json, r'allowUpload')!,

View File

@@ -137,9 +137,9 @@ class UserResponseDto {
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
createdAt: mapDateTime(json, r'createdAt', '')!,
deletedAt: mapDateTime(json, r'deletedAt', ''),
updatedAt: mapDateTime(json, r'updatedAt', '')!,
createdAt: mapDateTime(json, r'createdAt', r'')!,
deletedAt: mapDateTime(json, r'deletedAt', r''),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!,
);
}

View File

@@ -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:
@@ -748,6 +772,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:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.64.0+87
version: 1.65.0+88
isar_version: &isar_version 3.1.0+1
environment:
@@ -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
@@ -80,6 +81,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

View File

@@ -10,7 +10,6 @@ RUN npm ci
COPY . .
FROM builder as prod
RUN npm run build

1
server/bin/admin-cli.sh Executable file
View File

@@ -0,0 +1 @@
node ./dist/main cli "$@"

View File

@@ -1 +1 @@
node ./dist/main cli "$@"
node ./node_modules/immich/bin/index "$@"

View File

@@ -4374,7 +4374,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.64.0",
"version": "1.65.0",
"contact": {}
},
"tags": [],

View File

@@ -66,7 +66,7 @@ class {{{classname}}} {
{{/isNullable}}
{{#isDateTime}}
{{#pattern}}
json[r'{{{baseName}}}'] = _dateEpochMarker == '{{{pattern}}}'
json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}')
? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch
: this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String();
{{/pattern}}
@@ -76,7 +76,7 @@ class {{{classname}}} {
{{/isDateTime}}
{{#isDate}}
{{#pattern}}
json[r'{{{baseName}}}'] = _dateEpochMarker == '{{{pattern}}}'
json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}')
? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch
: _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc());
{{/pattern}}
@@ -117,10 +117,10 @@ class {{{classname}}} {
return {{{classname}}}(
{{#vars}}
{{#isDateTime}}
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', '{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isDateTime}}
{{#isDate}}
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', '{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isDate}}
{{^isDateTime}}
{{^isDate}}
@@ -200,9 +200,9 @@ class {{{classname}}} {
{{/isMap}}
{{^isMap}}
{{#isNumber}}
{{{name}}}: json[r'{{{baseName}}}'] == null
{{{name}}}: {{#isNullable}}json[r'{{{baseName}}}'] == null
? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}
: {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()),
: {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'),
{{/isNumber}}
{{^isNumber}}
{{^isEnum}}

262
server/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.64.0",
"version": "1.65.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.64.0",
"version": "1.65.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -34,6 +34,7 @@
"fluent-ffmpeg": "^2.1.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"immich": "^0.39.0",
"ioredis": "^5.3.1",
"joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5",
@@ -54,7 +55,8 @@
"ua-parser-js": "^1.0.35"
},
"bin": {
"immich": "bin/cli.sh"
"immich": "./bin/cli.sh",
"immich-admin": "./bin/admin-cli.sh"
},
"devDependencies": {
"@nestjs/cli": "^9.1.8",
@@ -4522,6 +4524,17 @@
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
"integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="
},
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
"dependencies": {
"string-width": "^4.2.3"
},
"engines": {
"node": ">=4"
}
},
"node_modules/cli-spinners": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz",
@@ -6081,6 +6094,19 @@
"bser": "2.1.1"
}
},
"node_modules/fdir": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-5.3.0.tgz",
"integrity": "sha512-BtE53+jaa7nNHT+gPdfU6cFAXOJUWDs2b5GFox8dtl6zLXmfNf/N6im69b9nqNNwDyl27mpIWX8qR7AafWzSdQ==",
"peerDependencies": {
"picomatch": "2.x"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -6818,6 +6844,110 @@
"node": ">= 4"
}
},
"node_modules/immich": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz",
"integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==",
"dependencies": {
"axios": "^0.26.0",
"chalk": "^2.4.1",
"cli-progress": "^3.10.0",
"commander": "^9.0.0",
"fdir": "^5.2.0",
"form-data": "^4.0.0",
"mime-types": "^2.1.34",
"p-limit": "3.1.0",
"systeminformation": "^5.11.6"
},
"bin": {
"immich": "bin/index.js"
}
},
"node_modules/immich/node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/immich/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/immich/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/immich/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/immich/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/immich/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/immich/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/immich/node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"engines": {
"node": ">=4"
}
},
"node_modules/immich/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -10618,6 +10748,31 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"node_modules/systeminformation": {
"version": "5.18.6",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.18.6.tgz",
"integrity": "sha512-pLXv6kjJZ1xUcVs9SrCqbQ9y0x1rgRWxBUc8/KxpOp9IRxFGFfzVK5efsxBn/KdYog4C9rPcKk+kHNIL2SB/8Q==",
"os": [
"darwin",
"linux",
"win32",
"freebsd",
"openbsd",
"netbsd",
"sunos",
"android"
],
"bin": {
"systeminformation": "lib/cli.js"
},
"engines": {
"node": ">=8.0.0"
},
"funding": {
"type": "Buy me a coffee",
"url": "https://www.buymeacoffee.com/systeminfo"
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -15457,6 +15612,14 @@
}
}
},
"cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
"requires": {
"string-width": "^4.2.3"
}
},
"cli-spinners": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz",
@@ -16656,6 +16819,12 @@
"bser": "2.1.1"
}
},
"fdir": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-5.3.0.tgz",
"integrity": "sha512-BtE53+jaa7nNHT+gPdfU6cFAXOJUWDs2b5GFox8dtl6zLXmfNf/N6im69b9nqNNwDyl27mpIWX8qR7AafWzSdQ==",
"requires": {}
},
"figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -17192,6 +17361,88 @@
"integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
"dev": true
},
"immich": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz",
"integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==",
"requires": {
"axios": "^0.26.0",
"chalk": "^2.4.1",
"cli-progress": "^3.10.0",
"commander": "^9.0.0",
"fdir": "^5.2.0",
"form-data": "^4.0.0",
"mime-types": "^2.1.34",
"p-limit": "3.1.0",
"systeminformation": "^5.11.6"
},
"dependencies": {
"ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"requires": {
"color-convert": "^1.9.0"
}
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
}
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"requires": {
"color-name": "1.1.3"
}
},
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="
},
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"requires": {
"has-flag": "^3.0.0"
}
}
}
},
"import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -20055,6 +20306,11 @@
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true
},
"systeminformation": {
"version": "5.18.6",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.18.6.tgz",
"integrity": "sha512-pLXv6kjJZ1xUcVs9SrCqbQ9y0x1rgRWxBUc8/KxpOp9IRxFGFfzVK5efsxBn/KdYog4C9rPcKk+kHNIL2SB/8Q=="
},
"tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",

View File

@@ -1,12 +1,13 @@
{
"name": "immich",
"version": "1.64.0",
"version": "1.65.0",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"bin": {
"immich": "./bin/cli.sh"
"immich": "./bin/cli.sh",
"immich-admin": "./bin/admin-cli.sh"
},
"scripts": {
"build": "nest build",
@@ -80,7 +81,8 @@
"thumbhash": "^0.1.1",
"typeorm": "^0.3.11",
"typesense": "^1.5.3",
"ua-parser-js": "^1.0.35"
"ua-parser-js": "^1.0.35",
"immich": "^0.39.0"
},
"devDependencies": {
"@nestjs/cli": "^9.1.8",

View File

@@ -0,0 +1,134 @@
import { BadRequestException } from '@nestjs/common';
import { AuthUserDto } from '../auth';
import { IAccessRepository } from './access.repository';
export enum Permission {
// ASSET_CREATE = 'asset.create',
ASSET_READ = 'asset.read',
ASSET_UPDATE = 'asset.update',
ASSET_DELETE = 'asset.delete',
ASSET_SHARE = 'asset.share',
ASSET_VIEW = 'asset.view',
ASSET_DOWNLOAD = 'asset.download',
// ALBUM_CREATE = 'album.create',
// ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
ALBUM_SHARE = 'album.share',
LIBRARY_READ = 'library.read',
LIBRARY_DOWNLOAD = 'library.download',
}
export class AccessCore {
constructor(private repository: IAccessRepository) {}
async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
const hasAccess = await this.hasPermission(authUser, permission, ids);
if (!hasAccess) {
throw new BadRequestException(`Not found or no ${permission} access`);
}
}
async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
ids = Array.isArray(ids) ? ids : [ids];
const isSharedLink = authUser.isPublicUser ?? false;
for (const id of ids) {
const hasAccess = isSharedLink
? await this.hasSharedLinkAccess(authUser, permission, id)
: await this.hasOtherAccess(authUser, permission, id);
if (!hasAccess) {
return false;
}
}
return true;
}
private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) {
const sharedLinkId = authUser.sharedLinkId;
if (!sharedLinkId) {
return false;
}
switch (permission) {
case Permission.ASSET_READ:
return this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
case Permission.ASSET_VIEW:
return await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
case Permission.ASSET_DOWNLOAD:
return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id));
case Permission.ASSET_SHARE:
// TODO: fix this to not use authUser.id for shared link access control
return this.repository.asset.hasOwnerAccess(authUser.id, id);
// case Permission.ALBUM_READ:
// return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
default:
return false;
}
}
private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) {
switch (permission) {
case Permission.ASSET_READ:
return (
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
);
case Permission.ASSET_UPDATE:
return this.repository.asset.hasOwnerAccess(authUser.id, id);
case Permission.ASSET_DELETE:
return this.repository.asset.hasOwnerAccess(authUser.id, id);
case Permission.ASSET_SHARE:
return (
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
);
case Permission.ASSET_VIEW:
return (
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
);
case Permission.ASSET_DOWNLOAD:
return (
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
);
// case Permission.ALBUM_READ:
// return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_UPDATE:
return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_DELETE:
return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_SHARE:
return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.LIBRARY_READ:
return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
case Permission.LIBRARY_DOWNLOAD:
return authUser.id === id;
}
return false;
}
}

View File

@@ -1,12 +1,20 @@
export const IAccessRepository = 'IAccessRepository';
export interface IAccessRepository {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
asset: {
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;
hasAlbumAccess(userId: string, assetId: string): Promise<boolean>;
hasPartnerAccess(userId: string, assetId: string): Promise<boolean>;
hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise<boolean>;
};
hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
album: {
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
hasSharedAlbumAccess(userId: string, albumId: string): Promise<boolean>;
hasSharedLinkAccess(sharedLinkId: string, albumId: string): Promise<boolean>;
};
hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
library: {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
};
}

View File

@@ -1 +1,2 @@
export * from './access.core';
export * from './access.repository';

View File

@@ -1,7 +1,9 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import {
albumStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newAlbumRepositoryMock,
newAssetRepositoryMock,
newJobRepositoryMock,
@@ -17,18 +19,20 @@ import { AlbumService } from './album.service';
describe(AlbumService.name, () => {
let sut: AlbumService;
let accessMock: IAccessRepositoryMock;
let albumMock: jest.Mocked<IAlbumRepository>;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
sut = new AlbumService(accessMock, albumMock, assetMock, jobMock, userMock);
});
it('should work', () => {
@@ -210,16 +214,16 @@ describe(AlbumService.name, () => {
});
it('should prevent updating a not owned album (shared with auth user)', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.update(authStub.admin, albumStub.sharedWithAdmin.id, {
albumName: 'new album name',
}),
).rejects.toBeInstanceOf(ForbiddenException);
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require a valid thumbnail asset id', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.update.mockResolvedValue(albumStub.oneAsset);
albumMock.hasAsset.mockResolvedValue(false);
@@ -235,6 +239,8 @@ describe(AlbumService.name, () => {
});
it('should allow the owner to update the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
albumMock.update.mockResolvedValue(albumStub.oneAsset);
@@ -256,6 +262,7 @@ describe(AlbumService.name, () => {
describe('delete', () => {
it('should throw an error for an album not found', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([]);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
@@ -266,14 +273,18 @@ describe(AlbumService.name, () => {
});
it('should not let a shared user delete the album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(albumMock.delete).not.toHaveBeenCalled();
});
it('should let the owner delete an album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
await sut.delete(authStub.admin, albumStub.empty.id);
@@ -284,23 +295,16 @@ describe(AlbumService.name, () => {
});
describe('addUsers', () => {
it('should require a valid album id', async () => {
albumMock.getByIds.mockResolvedValue([]);
await expect(sut.addUsers(authStub.admin, 'album-1', { sharedUserIds: ['user-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should require the user to be the owner', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
it('should throw an error if the auth user is not the owner', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
).rejects.toBeInstanceOf(ForbiddenException);
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should throw an error if the userId is already added', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
@@ -309,6 +313,7 @@ describe(AlbumService.name, () => {
});
it('should throw an error if the userId does not exist', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
userMock.get.mockResolvedValue(null);
await expect(
@@ -318,6 +323,7 @@ describe(AlbumService.name, () => {
});
it('should add valid shared users', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(userEntityStub.user2);
@@ -332,12 +338,14 @@ describe(AlbumService.name, () => {
describe('removeUser', () => {
it('should require a valid album id', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([]);
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should remove a shared user from an owned album', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
await expect(
@@ -353,13 +361,15 @@ describe(AlbumService.name, () => {
});
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
await expect(
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
).rejects.toBeInstanceOf(ForbiddenException);
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, albumStub.sharedWithMultiple.id);
});
it('should allow a shared user to remove themselves', async () => {

View File

@@ -1,7 +1,8 @@
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { IAssetRepository, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { AccessCore, IAccessRepository, Permission } from '../index';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto';
@@ -10,12 +11,16 @@ import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto
@Injectable()
export class AlbumService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {}
) {
this.access = new AccessCore(accessRepository);
}
async getCount(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
const [owned, shared, notShared] = await Promise.all([
@@ -100,8 +105,9 @@ export class AlbumService {
}
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
const album = await this.get(id);
this.assertOwner(authUser, album);
if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
@@ -122,22 +128,21 @@ export class AlbumService {
}
async delete(authUser: AuthUserDto, id: string): Promise<void> {
await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
const [album] = await this.albumRepository.getByIds([id]);
if (!album) {
throw new BadRequestException('Album not found');
}
if (album.ownerId !== authUser.id) {
throw new ForbiddenException('Album not owned by user');
}
await this.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
}
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
const album = await this.get(id);
this.assertOwner(authUser, album);
for (const userId of dto.sharedUserIds) {
const exists = album.sharedUsers.find((user) => user.id === userId);
@@ -180,7 +185,7 @@ export class AlbumService {
// non-admin can remove themselves
if (authUser.id !== userId) {
this.assertOwner(authUser, album);
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
}
await this.albumRepository.update({
@@ -197,10 +202,4 @@ export class AlbumService {
}
return album;
}
private assertOwner(authUser: AuthUserDto, album: AlbumEntity) {
if (album.ownerId !== authUser.id) {
throw new ForbiddenException('Album not owned by user');
}
}
}

View File

@@ -0,0 +1,21 @@
import { validMimeTypes } from './domain.constant';
describe('valid mime types', () => {
it('should be a sorted list', () => {
expect(validMimeTypes).toEqual(validMimeTypes.sort());
});
it('should contain only unique values', () => {
expect(validMimeTypes).toEqual([...new Set(validMimeTypes)]);
});
it('should contain only image or video mime types', () => {
expect(validMimeTypes).toEqual(
validMimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')),
);
});
it('should contain only lowercase mime types', () => {
expect(validMimeTypes).toEqual(validMimeTypes.map((mimeType) => mimeType.toLowerCase()));
});
});

View File

@@ -28,16 +28,40 @@ export function assertMachineLearningEnabled() {
}
}
const validMimeTypes = [
export const validMimeTypes = [
'image/3fr',
'image/ari',
'image/arw',
'image/avif',
'image/cap',
'image/cin',
'image/cr2',
'image/cr3',
'image/crw',
'image/dcr',
'image/dng',
'image/erf',
'image/fff',
'image/gif',
'image/heic',
'image/heif',
'image/iiq',
'image/jpeg',
'image/jxl',
'image/k25',
'image/kdc',
'image/mrw',
'image/nef',
'image/orf',
'image/ori',
'image/pef',
'image/png',
'image/dng',
'image/raf',
'image/raw',
'image/rwl',
'image/sr2',
'image/srf',
'image/srw',
'image/tiff',
'image/webp',
'image/x-adobe-dng',
@@ -67,38 +91,15 @@ const validMimeTypes = [
'image/x-sony-arw',
'image/x-sony-sr2',
'image/x-sony-srf',
'image/dng',
'image/ari',
'image/cr2',
'image/cr3',
'image/crw',
'image/erf',
'image/raf',
'image/3fr',
'image/fff',
'image/dcr',
'image/k25',
'image/kdc',
'image/rwl',
'image/mrw',
'image/nef',
'image/orf',
'image/ori',
'image/raw',
'image/pef',
'image/cin',
'image/cap',
'image/iiq',
'image/srw',
'image/x3f',
'image/arw',
'image/sr2',
'image/srf',
'video/3gpp',
'video/avi',
'video/mp2t',
'video/mp4',
'video/mpeg',
'video/msvideo',
'video/quicktime',
'video/vnd.avi',
'video/webm',
'video/x-flv',
'video/x-matroska',

View File

@@ -3,6 +3,7 @@ import {
albumStub,
assetEntityStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newSharedLinkRepositoryMock,
@@ -12,13 +13,13 @@ import {
import { when } from 'jest-when';
import _ from 'lodash';
import { SharedLinkType } from '../../infra/entities/shared-link.entity';
import { AssetIdErrorReason, IAccessRepository, ICryptoRepository } from '../index';
import { AssetIdErrorReason, ICryptoRepository } from '../index';
import { ISharedLinkRepository } from './shared-link.repository';
import { SharedLinkService } from './shared-link.service';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let accessMock: jest.Mocked<IAccessRepository>;
let accessMock: IAccessRepositoryMock;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>;
@@ -89,7 +90,7 @@ describe(SharedLinkService.name, () => {
});
it('should not allow non-owners to create album shared links', async () => {
accessMock.hasAlbumOwnerAccess.mockResolvedValue(false);
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
).rejects.toBeInstanceOf(BadRequestException);
@@ -102,19 +103,19 @@ describe(SharedLinkService.name, () => {
});
it('should require asset ownership to make an individual shared link', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create an album shared link', async () => {
accessMock.hasAlbumOwnerAccess.mockResolvedValue(true);
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
shareMock.create.mockResolvedValue(sharedLinkStub.valid);
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
expect(accessMock.hasAlbumOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.ALBUM,
userId: authStub.admin.id,
@@ -130,7 +131,7 @@ describe(SharedLinkService.name, () => {
});
it('should create an individual shared link', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, {
@@ -141,7 +142,7 @@ describe(SharedLinkService.name, () => {
allowUpload: true,
});
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.id,
@@ -206,8 +207,8 @@ describe(SharedLinkService.name, () => {
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
await expect(
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2', 'asset-3'] }),
@@ -217,7 +218,7 @@ describe(SharedLinkService.name, () => {
{ assetId: 'asset-3', success: true },
]);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledTimes(2);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2);
expect(shareMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual,
assets: [assetEntityStub.image, { id: 'asset-3' }],

View File

@@ -1,6 +1,6 @@
import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { IAccessRepository } from '../access';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
@@ -10,11 +10,15 @@ import { ISharedLinkRepository } from './shared-link.repository';
@Injectable()
export class SharedLinkService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
) {}
) {
this.access = new AccessCore(accessRepository);
}
getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
@@ -43,12 +47,7 @@ export class SharedLinkService {
if (!dto.albumId) {
throw new BadRequestException('Invalid albumId');
}
const isAlbumOwner = await this.accessRepository.hasAlbumOwnerAccess(authUser.id, dto.albumId);
if (!isAlbumOwner) {
throw new BadRequestException('Invalid albumId');
}
await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, dto.albumId);
break;
case SharedLinkType.INDIVIDUAL:
@@ -56,12 +55,7 @@ export class SharedLinkService {
throw new BadRequestException('Invalid assetIds');
}
for (const assetId of dto.assetIds) {
const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
if (!hasAccess) {
throw new BadRequestException(`No access to assetId: ${assetId}`);
}
}
await this.access.requirePermission(authUser, Permission.ASSET_SHARE, dto.assetIds);
break;
}
@@ -124,7 +118,7 @@ export class SharedLinkService {
continue;
}
const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
if (!hasAccess) {
results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
continue;

View File

@@ -91,13 +91,13 @@ describe(SmartInfoService.name, () => {
});
});
it('should no update the smart info if no tags were returned', async () => {
it('should always overwrite old tags', async () => {
machineMock.classifyImage.mockResolvedValue([]);
await sut.handleClassifyImage({ id: asset.id });
expect(machineMock.classifyImage).toHaveBeenCalled();
expect(smartMock.upsert).not.toHaveBeenCalled();
expect(smartMock.upsert).toHaveBeenCalled();
});
});

View File

@@ -41,10 +41,6 @@ export class SmartInfoService {
}
const tags = await this.machineLearning.classifyImage({ imagePath: asset.resizePath });
if (tags.length === 0) {
return false;
}
await this.repository.upsert({ assetId: asset.id, tags });
return true;

View File

@@ -260,11 +260,15 @@ export class AssetRepository implements IAssetRepository {
asset.isArchived = dto.isArchived ?? asset.isArchived;
if (asset.exifInfo != null) {
asset.exifInfo.description = dto.description || '';
if (dto.description !== undefined) {
asset.exifInfo.description = dto.description;
}
await this.exifRepository.save(asset.exifInfo);
} else {
const exifInfo = new ExifEntity();
exifInfo.description = dto.description || '';
if (dto.description !== undefined) {
exifInfo.description = dto.description;
}
exifInfo.asset = asset;
await this.exifRepository.save(exifInfo);
asset.exifInfo = exifInfo;

View File

@@ -162,7 +162,7 @@ export class AssetController {
@SharedLinkRoute()
@Get('/file/:id')
@Header('Cache-Control', 'max-age=31536000')
@Header('Cache-Control', 'private, max-age=86400, no-transform')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
serveFile(
@AuthUser() authUser: AuthUserDto,
@@ -176,7 +176,7 @@ export class AssetController {
@SharedLinkRoute()
@Get('/thumbnail/:id')
@Header('Cache-Control', 'max-age=31536000')
@Header('Cache-Control', 'private, max-age=86400, no-transform')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
getAssetThumbnail(
@AuthUser() authUser: AuthUserDto,

View File

@@ -1,10 +1,11 @@
import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { ForbiddenException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
fileStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
@@ -120,7 +121,7 @@ const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: jest.Mocked<IAccessRepository>;
let accessMock: IAccessRepositoryMock;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
@@ -293,7 +294,7 @@ describe('AssetService', () => {
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
@@ -305,7 +306,7 @@ describe('AssetService', () => {
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
@@ -315,7 +316,7 @@ describe('AssetService', () => {
});
it('should delete a live photo', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
{ id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
@@ -364,7 +365,7 @@ describe('AssetService', () => {
.calledWith(asset2.id)
.mockResolvedValue(asset2 as AssetEntity);
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
@@ -409,7 +410,7 @@ describe('AssetService', () => {
describe('downloadFile', () => {
it('should download a single file', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
await sut.downloadFile(authStub.admin, 'id_1');
@@ -485,56 +486,56 @@ describe('AssetService', () => {
describe('getAssetById', () => {
it('should allow owner access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should allow shared link access', async () => {
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id);
expect(accessMock.hasSharedLinkAssetAccess).toHaveBeenCalledWith(
expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
assetEntityStub.image.id,
);
});
it('should allow partner sharing access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.hasPartnerAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.hasPartnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should allow shared album access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
accessMock.hasAlbumAssetAccess.mockResolvedValue(true);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
expect(accessMock.hasAlbumAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
});
it('should throw an error for no access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
accessMock.hasAlbumAssetAccess.mockResolvedValue(false);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.admin, assetEntityStub.image.id)).rejects.toBeInstanceOf(
ForbiddenException,
BadRequestException,
);
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
it('should throw an error for an invalid shared link', async () => {
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id)).rejects.toBeInstanceOf(
ForbiddenException,
BadRequestException,
);
expect(accessMock.hasOwnerAssetAccess).not.toHaveBeenCalled();
expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled();
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import {
AccessCore,
AssetResponseDto,
AuthUserDto,
getLivePhotoMotionFilename,
@@ -6,17 +7,16 @@ import {
ICryptoRepository,
IJobRepository,
ImmichReadStream,
isSidecarFileType,
isSupportedFileType,
IStorageRepository,
JobName,
mapAsset,
mapAssetWithoutExif,
Permission,
} from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
@@ -79,9 +79,10 @@ interface ServableFile {
export class AssetService {
readonly logger = new Logger(AssetService.name);
private assetCore: AssetCore;
private access: AccessCore;
constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@@ -90,6 +91,7 @@ export class AssetService {
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.access = new AccessCore(accessRepository);
}
public async uploadFile(
@@ -149,9 +151,8 @@ export class AssetService {
}
if (dto.sidecarPath) {
const sidecarType = mime.lookup(dto.sidecarPath) as string;
if (!isSidecarFileType(sidecarType)) {
throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`);
if (path.extname(dto.sidecarPath).toLowerCase() !== '.xmp') {
throw new BadRequestException(`Unsupported sidecar file type`);
}
}
@@ -208,32 +209,21 @@ export class AssetService {
}
public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
if (dto.userId && dto.userId !== authUser.id) {
await this.checkUserAccess(authUser, dto.userId);
}
const assets = await this._assetRepository.getAllByUserId(dto.userId || authUser.id, dto);
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
const assets = await this._assetRepository.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset));
}
public async getAssetByTimeBucket(
authUser: AuthUserDto,
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
): Promise<AssetResponseDto[]> {
if (getAssetByTimeBucketDto.userId) {
await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId);
}
const assets = await this._assetRepository.getAssetByTimeBucket(
getAssetByTimeBucketDto.userId || authUser.id,
getAssetByTimeBucketDto,
);
public async getAssetByTimeBucket(authUser: AuthUserDto, dto: GetAssetByTimeBucketDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
const assets = await this._assetRepository.getAssetByTimeBucket(userId, dto);
return assets.map((asset) => mapAsset(asset));
}
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
await this.checkAssetsAccess(authUser, [assetId]);
await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
const allowExif = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
@@ -246,7 +236,7 @@ export class AssetService {
}
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.checkAssetsAccess(authUser, [assetId], true);
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, assetId);
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
@@ -261,15 +251,15 @@ export class AssetService {
}
public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) {
this.checkDownloadAccess(authUser);
await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id);
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
return this.downloadService.downloadArchive(dto.name || `library`, assets);
}
public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) {
this.checkDownloadAccess(authUser);
await this.checkAssetsAccess(authUser, [...dto.assetIds]);
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds);
const assetToDownload = [];
@@ -289,8 +279,7 @@ export class AssetService {
}
public async downloadFile(authUser: AuthUserDto, assetId: string): Promise<ImmichReadStream> {
this.checkDownloadAccess(authUser);
await this.checkAssetsAccess(authUser, [assetId]);
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId);
try {
const asset = await this._assetRepository.get(assetId);
@@ -312,7 +301,8 @@ export class AssetService {
res: Res,
headers: Record<string, string>,
) {
await this.checkAssetsAccess(authUser, [assetId]);
await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);
const asset = await this._assetRepository.get(assetId);
if (!asset) {
throw new NotFoundException('Asset not found');
@@ -338,7 +328,8 @@ export class AssetService {
res: Res,
headers: Record<string, string>,
) {
await this.checkAssetsAccess(authUser, [assetId]);
// this is not quite right as sometimes this returns the original still
await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);
const allowOriginalFile = !!(!authUser.isPublicUser || authUser.isAllowDownload);
@@ -421,13 +412,17 @@ export class AssetService {
}
public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
await this.checkAssetsAccess(authUser, dto.ids, true);
const deleteQueue: Array<string | null> = [];
const result: DeleteAssetResponseDto[] = [];
const ids = dto.ids.slice();
for (const id of ids) {
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_DELETE, id);
if (!hasAccess) {
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
continue;
}
const asset = await this._assetRepository.get(id);
if (!asset) {
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
@@ -605,17 +600,11 @@ export class AssetService {
async getAssetCountByTimeBucket(
authUser: AuthUserDto,
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,
dto: GetAssetCountByTimeBucketDto,
): Promise<AssetCountByTimeBucketResponseDto> {
if (getAssetCountByTimeBucketDto.userId !== undefined) {
await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId);
}
const result = await this._assetRepository.getAssetCountByTimeBucket(
getAssetCountByTimeBucketDto.userId || authUser.id,
getAssetCountByTimeBucketDto,
);
const userId = dto.userId || authUser.id;
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
const result = await this._assetRepository.getAssetCountByTimeBucket(userId, dto);
return mapAssetCountByTimeBucket(result);
}
@@ -627,56 +616,6 @@ export class AssetService {
return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
}
private async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
const sharedLinkId = authUser.sharedLinkId;
for (const assetId of assetIds) {
if (sharedLinkId) {
const canAccess = await this.accessRepository.hasSharedLinkAssetAccess(sharedLinkId, assetId);
if (canAccess) {
continue;
}
throw new ForbiddenException();
}
const isOwner = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
if (isOwner) {
continue;
}
if (mustBeOwner) {
throw new ForbiddenException();
}
const isPartnerShared = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
if (isPartnerShared) {
continue;
}
const isAlbumShared = await this.accessRepository.hasAlbumAssetAccess(authUser.id, assetId);
if (isAlbumShared) {
continue;
}
throw new ForbiddenException();
}
}
private async checkUserAccess(authUser: AuthUserDto, userId: string) {
// Check if userId shares assets with authUser
const canAccess = await this.accessRepository.hasPartnerAccess(authUser.id, userId);
if (!canAccess) {
throw new ForbiddenException();
}
}
private checkDownloadAccess(authUser: AuthUserDto) {
if (authUser.isPublicUser && !authUser.isAllowDownload) {
throw new ForbiddenException();
}
}
getExifPermission(authUser: AuthUserDto) {
return !authUser.isPublicUser || authUser.isShowExif;
}

View File

@@ -50,14 +50,41 @@ describe('assetUploadOption', () => {
});
for (const { mimetype, extension } of [
// Please ensure this list is sorted.
{ mimetype: 'image/3fr', extension: '3fr' },
{ mimetype: 'image/ari', extension: 'ari' },
{ mimetype: 'image/arw', extension: 'arw' },
{ mimetype: 'image/avif', extension: 'avif' },
{ mimetype: 'image/cap', extension: 'cap' },
{ mimetype: 'image/cin', extension: 'cin' },
{ mimetype: 'image/cr2', extension: 'cr2' },
{ mimetype: 'image/cr3', extension: 'cr3' },
{ mimetype: 'image/crw', extension: 'crw' },
{ mimetype: 'image/dcr', extension: 'dcr' },
{ mimetype: 'image/dng', extension: 'dng' },
{ mimetype: 'image/erf', extension: 'erf' },
{ mimetype: 'image/fff', extension: 'fff' },
{ mimetype: 'image/gif', extension: 'gif' },
{ mimetype: 'image/heic', extension: 'heic' },
{ mimetype: 'image/heif', extension: 'heif' },
{ mimetype: 'image/iiq', extension: 'iiq' },
{ mimetype: 'image/jpeg', extension: 'jpeg' },
{ mimetype: 'image/jpeg', extension: 'jpg' },
{ mimetype: 'image/jxl', extension: 'jxl' },
{ mimetype: 'image/k25', extension: 'k25' },
{ mimetype: 'image/kdc', extension: 'kdc' },
{ mimetype: 'image/mrw', extension: 'mrw' },
{ mimetype: 'image/nef', extension: 'nef' },
{ mimetype: 'image/orf', extension: 'orf' },
{ mimetype: 'image/ori', extension: 'ori' },
{ mimetype: 'image/pef', extension: 'pef' },
{ mimetype: 'image/png', extension: 'png' },
{ mimetype: 'image/raf', extension: 'raf' },
{ mimetype: 'image/raw', extension: 'raw' },
{ mimetype: 'image/rwl', extension: 'rwl' },
{ mimetype: 'image/sr2', extension: 'sr2' },
{ mimetype: 'image/srf', extension: 'srf' },
{ mimetype: 'image/srw', extension: 'srw' },
{ mimetype: 'image/tiff', extension: 'tiff' },
{ mimetype: 'image/webp', extension: 'webp' },
{ mimetype: 'image/x-adobe-dng', extension: 'dng' },
@@ -87,40 +114,16 @@ describe('assetUploadOption', () => {
{ mimetype: 'image/x-sony-arw', extension: 'arw' },
{ mimetype: 'image/x-sony-sr2', extension: 'sr2' },
{ mimetype: 'image/x-sony-srf', extension: 'srf' },
{ mimetype: 'image/dng', extension: 'dng' },
{ mimetype: 'image/ari', extension: 'ari' },
{ mimetype: 'image/cr2', extension: 'cr2' },
{ mimetype: 'image/cr3', extension: 'cr3' },
{ mimetype: 'image/crw', extension: 'crw' },
{ mimetype: 'image/erf', extension: 'erf' },
{ mimetype: 'image/raf', extension: 'raf' },
{ mimetype: 'image/3fr', extension: '3fr' },
{ mimetype: 'image/fff', extension: 'fff' },
{ mimetype: 'image/dcr', extension: 'dcr' },
{ mimetype: 'image/k25', extension: 'k25' },
{ mimetype: 'image/kdc', extension: 'kdc' },
{ mimetype: 'image/rwl', extension: 'rwl' },
{ mimetype: 'image/mrw', extension: 'mrw' },
{ mimetype: 'image/nef', extension: 'nef' },
{ mimetype: 'image/orf', extension: 'orf' },
{ mimetype: 'image/ori', extension: 'ori' },
{ mimetype: 'image/raw', extension: 'raw' },
{ mimetype: 'image/pef', extension: 'pef' },
{ mimetype: 'image/cin', extension: 'cin' },
{ mimetype: 'image/cap', extension: 'cap' },
{ mimetype: 'image/iiq', extension: 'iiq' },
{ mimetype: 'image/srw', extension: 'srw' },
{ mimetype: 'image/x3f', extension: 'x3f' },
{ mimetype: 'image/arw', extension: 'arw' },
{ mimetype: 'image/sr2', extension: 'sr2' },
{ mimetype: 'image/srf', extension: 'srf' },
{ mimetype: 'video/3gpp', extension: '3gp' },
{ mimetype: 'video/avi', extension: 'avi' },
{ mimetype: 'video/mp2t', extension: 'm2ts' },
{ mimetype: 'video/mp2t', extension: 'mts' },
{ mimetype: 'video/mp4', extension: 'mp4' },
{ mimetype: 'video/mpeg', extension: 'mpg' },
{ mimetype: 'video/msvideo', extension: 'avi' },
{ mimetype: 'video/quicktime', extension: 'mov' },
{ mimetype: 'video/vnd.avi', extension: 'avi' },
{ mimetype: 'video/webm', extension: 'webm' },
{ mimetype: 'video/x-flv', extension: 'flv' },
{ mimetype: 'video/x-matroska', extension: 'mkv' },

View File

@@ -98,7 +98,7 @@ export class UserController {
}
@Get('/profile-image/:userId')
@Header('Cache-Control', 'max-age=600')
@Header('Cache-Control', 'private, max-age=86400, no-transform')
async getProfileImage(@Param() { userId }: UserIdDto, @Response({ passthrough: true }) res: Res): Promise<any> {
const readableStream = await this.service.getUserProfileImage(userId);
res.header('Content-Type', 'image/jpeg');

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