Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df9c05bef3 | ||
|
|
6c8c16c85f | ||
|
|
b05f3fd266 | ||
|
|
ca98d73d86 | ||
|
|
b7ae3be394 | ||
|
|
621fa5ba54 | ||
|
|
ca1b9bf7b3 | ||
|
|
6fa685d9d8 | ||
|
|
ff26d3666e | ||
|
|
e3557fd80e | ||
|
|
c065705608 | ||
|
|
3948247055 | ||
|
|
dca48d7722 | ||
|
|
8e6c90e294 | ||
|
|
e5908f2508 | ||
|
|
fbd98ec0f9 | ||
|
|
1ab05e8de0 | ||
|
|
86562f256f | ||
|
|
add5219d34 | ||
|
|
6ae5d11ec0 | ||
|
|
b4e641548c | ||
|
|
017214fd56 | ||
|
|
22a73b67d3 | ||
|
|
792ecc6cac | ||
|
|
e98398cab8 | ||
|
|
df1e8679d9 | ||
|
|
5e3bdc76b2 | ||
|
|
47982641b2 | ||
|
|
39a885a37c | ||
|
|
0e8d235148 | ||
|
|
053a5235be | ||
|
|
de42ebf3d8 | ||
|
|
4d3ce0a65e | ||
|
|
b3e97a1a0c | ||
|
|
f5d9826b12 | ||
|
|
61e5e65173 |
2
.github/workflows/build-mobile.yml
vendored
2
.github/workflows/build-mobile.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -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
|
||||
|
||||
32
.github/workflows/test.yml
vendored
32
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| ------------------------ | ------------------------------------- |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,6 +18,7 @@ class Settings(BaseSettings):
|
||||
port: int = 3003
|
||||
workers: int = 1
|
||||
min_face_score: float = 0.7
|
||||
test_full: bool = False
|
||||
|
||||
class Config(BaseSettings.Config):
|
||||
env_prefix = "MACHINE_LEARNING_"
|
||||
|
||||
119
machine-learning/app/conftest.py
Normal file
119
machine-learning/app/conftest.py
Normal 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)
|
||||
@@ -24,16 +24,15 @@ from .schemas import (
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
def init_state() -> None:
|
||||
app.state.model_cache = ModelCache(ttl=settings.model_ttl, revalidate=True)
|
||||
same_clip = settings.clip_image_model == settings.clip_text_model
|
||||
app.state.clip_vision_type = ModelType.CLIP if same_clip else ModelType.CLIP_VISION
|
||||
app.state.clip_text_type = ModelType.CLIP if same_clip else ModelType.CLIP_TEXT
|
||||
|
||||
|
||||
async def load_models() -> None:
|
||||
models = [
|
||||
(settings.classification_model, ModelType.IMAGE_CLASSIFICATION),
|
||||
(settings.clip_image_model, app.state.clip_vision_type),
|
||||
(settings.clip_text_model, app.state.clip_text_type),
|
||||
(settings.clip_image_model, ModelType.CLIP),
|
||||
(settings.clip_text_model, ModelType.CLIP),
|
||||
(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION),
|
||||
]
|
||||
|
||||
@@ -45,6 +44,12 @@ async def startup_event() -> None:
|
||||
InferenceModel.from_model_type(model_type, model_name)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event() -> None:
|
||||
init_state()
|
||||
await load_models()
|
||||
|
||||
|
||||
def dep_pil_image(byte_image: bytes = Body(...)) -> Image.Image:
|
||||
return Image.open(BytesIO(byte_image))
|
||||
|
||||
@@ -72,9 +77,7 @@ def ping() -> str:
|
||||
async def image_classification(
|
||||
image: Image.Image = Depends(dep_pil_image),
|
||||
) -> list[str]:
|
||||
model = await app.state.model_cache.get(
|
||||
settings.classification_model, ModelType.IMAGE_CLASSIFICATION
|
||||
)
|
||||
model = await app.state.model_cache.get(settings.classification_model, ModelType.IMAGE_CLASSIFICATION)
|
||||
labels = model.predict(image)
|
||||
return labels
|
||||
|
||||
@@ -87,9 +90,7 @@ async def image_classification(
|
||||
async def clip_encode_image(
|
||||
image: Image.Image = Depends(dep_pil_image),
|
||||
) -> list[float]:
|
||||
model = await app.state.model_cache.get(
|
||||
settings.clip_image_model, app.state.clip_vision_type
|
||||
)
|
||||
model = await app.state.model_cache.get(settings.clip_image_model, ModelType.CLIP)
|
||||
embedding = model.predict(image)
|
||||
return embedding
|
||||
|
||||
@@ -100,9 +101,7 @@ async def clip_encode_image(
|
||||
status_code=200,
|
||||
)
|
||||
async def clip_encode_text(payload: TextModelRequest) -> list[float]:
|
||||
model = await app.state.model_cache.get(
|
||||
settings.clip_text_model, app.state.clip_text_type
|
||||
)
|
||||
model = await app.state.model_cache.get(settings.clip_text_model, ModelType.CLIP)
|
||||
embedding = model.predict(payload.text)
|
||||
return embedding
|
||||
|
||||
@@ -115,9 +114,7 @@ async def clip_encode_text(payload: TextModelRequest) -> list[float]:
|
||||
async def facial_recognition(
|
||||
image: cv2.Mat = Depends(dep_cv_image),
|
||||
) -> list[dict[str, Any]]:
|
||||
model = await app.state.model_cache.get(
|
||||
settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION
|
||||
)
|
||||
model = await app.state.model_cache.get(settings.facial_recognition_model, ModelType.FACIAL_RECOGNITION)
|
||||
faces = model.predict(image)
|
||||
return faces
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .clip import CLIPSTTextEncoder, CLIPSTVisionEncoder
|
||||
from .clip import CLIPSTEncoder
|
||||
from .facial_recognition import FaceRecognizer
|
||||
from .image_classification import ImageClassifier
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,7 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
def to_lower_camel(string: str) -> str:
|
||||
tokens = [
|
||||
token.capitalize() if i > 0 else token
|
||||
for i, token in enumerate(string.split("_"))
|
||||
]
|
||||
tokens = [token.capitalize() if i > 0 else token for i, token in enumerate(string.split("_"))]
|
||||
return "".join(tokens)
|
||||
|
||||
|
||||
@@ -61,6 +58,4 @@ class FaceResponse(BaseModel):
|
||||
class ModelType(Enum):
|
||||
IMAGE_CLASSIFICATION = "image-classification"
|
||||
CLIP = "clip"
|
||||
CLIP_VISION = "clip-vision"
|
||||
CLIP_TEXT = "clip-text"
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
|
||||
183
machine-learning/app/test_main.py
Normal file
183
machine-learning/app/test_main.py
Normal 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
|
||||
249
machine-learning/poetry.lock
generated
249
machine-learning/poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"flutterSdkVersion": "3.10.0",
|
||||
"flutterSdkVersion": "3.10.5",
|
||||
"flavors": {}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
BIN
mobile/fonts/WorkSans-Black.ttf
Normal file
BIN
mobile/fonts/WorkSans-Black.ttf
Normal file
Binary file not shown.
BIN
mobile/fonts/WorkSans-Bold.ttf
Normal file
BIN
mobile/fonts/WorkSans-Bold.ttf
Normal file
Binary file not shown.
BIN
mobile/fonts/WorkSans-ExtraBold.ttf
Normal file
BIN
mobile/fonts/WorkSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
mobile/fonts/WorkSans-Medium.ttf
Normal file
BIN
mobile/fonts/WorkSans-Medium.ttf
Normal file
Binary file not shown.
BIN
mobile/fonts/WorkSans-SemiBold.ttf
Normal file
BIN
mobile/fonts/WorkSans-SemiBold.ttf
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
@@ -310,6 +311,7 @@ class HomePage extends HookConsumerWidget {
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
onRefresh: refreshAssets,
|
||||
topWidget: const MemoryLane(),
|
||||
),
|
||||
error: (error, _) => Center(child: Text(error.toString())),
|
||||
loading: buildLoadingIndicator,
|
||||
|
||||
40
mobile/lib/modules/memories/models/memory.dart
Normal file
40
mobile/lib/modules/memories/models/memory.dart
Normal 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;
|
||||
}
|
||||
10
mobile/lib/modules/memories/providers/memory.provider.dart
Normal file
10
mobile/lib/modules/memories/providers/memory.provider.dart
Normal 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();
|
||||
});
|
||||
50
mobile/lib/modules/memories/services/memory.service.dart
Normal file
50
mobile/lib/modules/memories/services/memory.service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
mobile/lib/modules/memories/ui/memory_card.dart
Normal file
121
mobile/lib/modules/memories/ui/memory_card.dart
Normal 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(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
mobile/lib/modules/memories/ui/memory_lane.dart
Normal file
93
mobile/lib/modules/memories/ui/memory_lane.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
140
mobile/lib/modules/memories/views/memory_page.dart
Normal file
140
mobile/lib/modules/memories/views/memory_page.dart
Normal 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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}"]),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
9
mobile/lib/utils/capitalize.dart
Normal file
9
mobile/lib/utils/capitalize.dart
Normal 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(" ");
|
||||
}
|
||||
}
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
mobile/openapi/lib/model/album_response_dto.dart
generated
6
mobile/openapi/lib/model/album_response_dto.dart
generated
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
6
mobile/openapi/lib/model/asset_response_dto.dart
generated
6
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -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'),
|
||||
|
||||
18
mobile/openapi/lib/model/exif_response_dto.dart
generated
18
mobile/openapi/lib/model/exif_response_dto.dart
generated
@@ -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'),
|
||||
|
||||
4
mobile/openapi/lib/model/import_asset_dto.dart
generated
4
mobile/openapi/lib/model/import_asset_dto.dart
generated
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')!,
|
||||
|
||||
6
mobile/openapi/lib/model/user_response_dto.dart
generated
6
mobile/openapi/lib/model/user_response_dto.dart
generated
@@ -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')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,6 @@ RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
FROM builder as prod
|
||||
|
||||
RUN npm run build
|
||||
|
||||
1
server/bin/admin-cli.sh
Executable file
1
server/bin/admin-cli.sh
Executable file
@@ -0,0 +1 @@
|
||||
node ./dist/main cli "$@"
|
||||
@@ -1 +1 @@
|
||||
node ./dist/main cli "$@"
|
||||
node ./node_modules/immich/bin/index "$@"
|
||||
|
||||
@@ -4374,7 +4374,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.64.0",
|
||||
"version": "1.65.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
@@ -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
262
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
134
server/src/domain/access/access.core.ts
Normal file
134
server/src/domain/access/access.core.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './access.core';
|
||||
export * from './access.repository';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
server/src/domain/domain.constant.spec.ts
Normal file
21
server/src/domain/domain.constant.spec.ts
Normal 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()));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user